Compare commits
35 Commits
roster/pla
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
815146b70b | ||
|
|
2665a6a2da | ||
|
|
307fb4cea5 | ||
|
|
d56800805e | ||
|
|
f804c2b49d | ||
|
|
2b9a863d39 | ||
|
|
ec6a317933 | ||
|
|
d6453757d1 | ||
|
|
50d877b49f | ||
|
|
48c2f53d18 | ||
|
|
f33f7a6309 | ||
|
|
27bbcf43d0 | ||
|
|
0edf2fe3df | ||
|
|
912359f38b | ||
|
|
a510ac3848 | ||
|
|
b6831a1e48 | ||
|
|
2c3d7e9814 | ||
|
|
0298f414ed | ||
|
|
bae6771b34 | ||
|
|
c9fbbdbe9e | ||
|
|
455a66aeb9 | ||
|
|
b7b7da783b | ||
|
|
c8377a208a | ||
|
|
a7a428f261 | ||
|
|
368e22d57e | ||
|
|
5e57b72a8f | ||
|
|
6f709dd8e1 | ||
|
|
8b23367939 | ||
|
|
4b377eb887 | ||
|
|
15976342e4 | ||
|
|
67395cd35e | ||
|
|
dadda9fd49 | ||
|
|
70bf25ea84 | ||
|
|
c4de33d448 | ||
|
|
bc5bb06794 |
29
apps/ops/src/api/address.js
Normal file
29
apps/ops/src/api/address.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* API Conformité des adresses — appelle targo-hub /address/conformity/*.
|
||||
* Source de vérité : Service Location (lien AQ local rqa_addresses). Voir services/targo-hub/lib/address-conformity.js.
|
||||
*/
|
||||
import { HUB_URL as HUB } from 'src/config/hub'
|
||||
|
||||
async function jget (path) {
|
||||
const r = await fetch(HUB + path)
|
||||
if (!r.ok) throw new Error('Address API ' + r.status)
|
||||
return r.json()
|
||||
}
|
||||
async function jpost (path, body) {
|
||||
const r = await fetch(HUB + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })
|
||||
if (!r.ok) throw new Error('Address API ' + r.status)
|
||||
return r.json()
|
||||
}
|
||||
|
||||
export const conformityStats = () => jget('/address/conformity/stats')
|
||||
export const conformityList = (p) => jget('/address/conformity/list?' + new URLSearchParams(p).toString())
|
||||
export const conformityCandidates = (q) => jget('/address/conformity/candidates?q=' + encodeURIComponent(q))
|
||||
// action: approve | correct | gps | reject (+ aq_address_id/linked_address/latitude/longitude selon l'action)
|
||||
export const conformityResolve = (body) => jpost('/address/conformity/resolve', body)
|
||||
|
||||
// ── Registre des campings (géoloc de remplacement fixe par camping) ──
|
||||
export const campingsList = () => jget('/address/conformity/campings')
|
||||
export const campingsUpsert = (body) => jpost('/address/conformity/campings', body) // {keyword,name,address,latitude,longitude}
|
||||
export const campingsApply = () => jpost('/address/conformity/campings/apply', {})
|
||||
// Dernier repli : placer les unmatched restants au centre du code postal (sinon ville) → statut 'area'
|
||||
export const conformityApplyArea = () => jpost('/address/conformity/apply-area', {})
|
||||
|
|
@ -93,5 +93,11 @@ export const redistributePlan = (plan) => jpost('/roster/skill-impact/redistribu
|
|||
export const unassignedJobs = () => jget('/roster/unassigned-jobs')
|
||||
// Assigner un job à un tech (date = case déposée)
|
||||
export const assignJob = (job, tech, date) => jpost('/roster/assign-job', { job, tech, date })
|
||||
// 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é)
|
||||
export const unassignJobRoster = (job) => jpost('/roster/unassign-job', { job })
|
||||
// Aviser le client d'un report : désassigne + SMS lien /book — { job, phone?, message? }
|
||||
export const notifyReschedule = (body) => jpost('/roster/job/notify-reschedule', body)
|
||||
|
|
|
|||
|
|
@ -7,32 +7,42 @@ export function useBottomPanel (store, todayStr, unscheduledJobs, { pushUndo, sm
|
|||
const bottomPanelH = ref(parseInt(localStorage.getItem('sbv2-bottomH')) || 220)
|
||||
watch(bottomPanelOpen, v => localStorage.setItem('sbv2-bottomPanel', v ? 'true' : 'false'))
|
||||
|
||||
// ── Grouped by date ──────────────────────────────────────────────────────────
|
||||
// ── Tri / regroupement (date · ville · priorité) ─────────────────────────────
|
||||
const bottomSort = ref(localStorage.getItem('sbv2-bottomSort') || 'date')
|
||||
watch(bottomSort, v => localStorage.setItem('sbv2-bottomSort', v))
|
||||
const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 }
|
||||
// Ville : 2e segment de l'adresse libre, sinon 1er token du sujet avant « | » (tickets legacy), sinon vide.
|
||||
function cityOf (job) {
|
||||
const a = String(job.address || '')
|
||||
const parts = a.split(',').map(s => s.trim()).filter(Boolean)
|
||||
if (parts.length >= 2) return parts[1]
|
||||
const subj = String(job.subject || ''); if (subj.includes('|')) return subj.split('|')[0].trim()
|
||||
return parts[0] || ''
|
||||
}
|
||||
const unassignedGrouped = computed(() => {
|
||||
const today = todayStr
|
||||
const sort = bottomSort.value
|
||||
const jobs = unscheduledJobs.value.slice()
|
||||
const byDate = (a, b) => { // ordre secondaire : date (aujourd'hui d'abord)
|
||||
const da = a.scheduledDate || '9999-99-99'; const db = b.scheduledDate || '9999-99-99'
|
||||
const at = da === today ? 0 : 1; const bt = db === today ? 0 : 1
|
||||
return at !== bt ? at - bt : da.localeCompare(db)
|
||||
}
|
||||
jobs.sort((a, b) => {
|
||||
const da = a.scheduledDate || '9999-99-99'
|
||||
const db = b.scheduledDate || '9999-99-99'
|
||||
const aToday = da === today ? 0 : 1
|
||||
const bToday = db === today ? 0 : 1
|
||||
if (aToday !== bToday) return aToday - bToday
|
||||
if (da !== db) return da.localeCompare(db)
|
||||
const prio = { high: 0, medium: 1, low: 2 }
|
||||
return (prio[a.priority] ?? 2) - (prio[b.priority] ?? 2)
|
||||
if (sort === 'priority') return (PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3) || byDate(a, b)
|
||||
if (sort === 'city') return cityOf(a).localeCompare(cityOf(b)) || byDate(a, b)
|
||||
return byDate(a, b) || ((PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3))
|
||||
})
|
||||
const groups = []
|
||||
let currentDate = null
|
||||
const keyOf = job => sort === 'priority' ? (job.priority || 'low') : sort === 'city' ? (cityOf(job) || 'Sans ville') : (job.scheduledDate || null)
|
||||
const labelOf = key => {
|
||||
if (sort === 'priority') return ({ urgent: '🔴 Urgent', high: '🟠 Élevée', medium: '🔵 Moyenne', low: '⚪ Basse' })[key] || key
|
||||
if (sort === 'city') return key
|
||||
return key === today ? "Aujourd'hui" : (key ? fmtDate(new Date(key + 'T00:00:00')) : 'Sans date')
|
||||
}
|
||||
const groups = []; let cur = Symbol('init')
|
||||
jobs.forEach(job => {
|
||||
const d = job.scheduledDate || null
|
||||
if (d !== currentDate) {
|
||||
currentDate = d
|
||||
let label = d === today ? "Aujourd'hui" : d ? d : 'Sans date'
|
||||
if (d && d !== today) {
|
||||
label = fmtDate(new Date(d + 'T00:00:00'))
|
||||
}
|
||||
groups.push({ date: d, label, jobs: [] })
|
||||
}
|
||||
const k = keyOf(job)
|
||||
if (k !== cur) { cur = k; groups.push({ key: String(k), date: sort === 'date' ? k : null, label: labelOf(k), jobs: [] }) }
|
||||
groups.at(-1).jobs.push(job)
|
||||
})
|
||||
return groups
|
||||
|
|
@ -111,7 +121,7 @@ export function useBottomPanel (store, todayStr, unscheduledJobs, { pushUndo, sm
|
|||
}
|
||||
|
||||
return {
|
||||
bottomPanelOpen, bottomPanelH, unassignedGrouped, startBottomResize,
|
||||
bottomPanelOpen, bottomPanelH, unassignedGrouped, bottomSort, startBottomResize,
|
||||
bottomSelected, toggleBottomSelect, selectAllBottom, clearBottomSelect, batchAssignBottom,
|
||||
dispatchCriteria, dispatchCriteriaModal, saveDispatchCriteria, moveCriterion,
|
||||
btColWidths, btColW, startColResize,
|
||||
|
|
|
|||
|
|
@ -81,12 +81,38 @@ export function jobSvcCode (job) {
|
|||
return 'WO'
|
||||
}
|
||||
|
||||
// Couleur par TYPE de ticket, calquée sur le board legacy (osTicket). Téléphonie = vert
|
||||
// PLUS PÂLE que l'installation. Ordre des tests important (téléph avant télé ; télé avant install).
|
||||
export function legacyDeptColor (dept) {
|
||||
if (!dept) return null
|
||||
const d = String(dept).toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
if (d.includes('teleph')) return '#8fce93' // Téléphonie → vert pâle
|
||||
if (d.includes('tele') || d.includes('televis')) return '#ec5fb0' // Télé (install/réparation) → rose
|
||||
if (d.includes('desinstall') || d.includes('retrait')) return '#c0392b' // Désinstallation → rouge foncé
|
||||
if (d.includes('repar')) return '#f1c84b' // Réparation (Fibre) → jaune/or
|
||||
if (d.includes('installation') || d.includes('monteur') || d.includes('fusionneur')) return '#46992f' // Installation (Fibre) → vert
|
||||
return null
|
||||
}
|
||||
|
||||
// Lien « Répondre au ticket » du serveur legacy (osTicket custom) — celui que les techs reçoivent
|
||||
// pour écrire dans le ticket. Format observé : reply_ticket.php?ticket=<id>&staff=<staffId>.
|
||||
// staff par défaut = 3301 (compte « Tech Targo » sous lequel le ticket est assigné dans legacy).
|
||||
export const LEGACY_REPLY_BASE = 'https://store.targo.ca/targo/reply_ticket.php'
|
||||
export function legacyReplyUrl (job, staffId) {
|
||||
const id = job && job.legacyTicketId
|
||||
if (!id) return null
|
||||
return `${LEGACY_REPLY_BASE}?ticket=${encodeURIComponent(id)}&staff=${staffId || 3301}`
|
||||
}
|
||||
|
||||
export function jobColor (job, techColors, store) {
|
||||
// Tech en pause/absent (statut interne 'off') → ses jobs en ROUGE (à réassigner)
|
||||
// Tech en pause/absent (statut interne 'off') → ses jobs en ROUGE vif (à réassigner) — priorité opérationnelle
|
||||
if (job.assignedTech && store) {
|
||||
const at = store.technicians.find(x => x.id === job.assignedTech)
|
||||
if (at && at.status === 'off') return '#e53935'
|
||||
}
|
||||
// Type legacy (pont osTicket) → couleur « comme legacy »
|
||||
const ld = legacyDeptColor(job.legacyDept)
|
||||
if (ld) return ld
|
||||
if (SVC_COLORS[job.service_type]) return SVC_COLORS[job.service_type]
|
||||
const s = (job.subject||'').toLowerCase()
|
||||
if (s.includes('internet')) return '#3b82f6'
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export const navItems = [
|
|||
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users' },
|
||||
{ path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' },
|
||||
{ path: '/campaigns', icon: 'Gift', label: 'Campagnes', requires: 'manage_users' },
|
||||
{ path: '/conformite-adresses', icon: 'MapPinned', label: 'Conformité adresses', requires: 'view_settings' },
|
||||
{ path: '/email-queue', icon: 'Mail', label: 'File courriels', requires: 'view_settings' },
|
||||
{ path: '/settings', icon: 'Settings', label: 'Paramètres', requires: 'view_settings' },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -124,13 +124,13 @@ import { navItems as allNavItems } from 'src/config/nav'
|
|||
import {
|
||||
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
|
||||
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail,
|
||||
CalendarRange, CalendarClock, Sparkles,
|
||||
CalendarRange, CalendarClock, Sparkles, MapPinned,
|
||||
} from 'lucide-vue-next'
|
||||
import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
|
||||
import { useConversations } from 'src/composables/useConversations'
|
||||
import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue'
|
||||
|
||||
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles }
|
||||
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned }
|
||||
|
||||
const { panelOpen, activeCount: convCount } = useConversations()
|
||||
function toggleConvPanel () { panelOpen.value = !panelOpen.value }
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ const props = defineProps({
|
|||
unscheduledCount: Number,
|
||||
selected: Object, // Set
|
||||
dropActive: Boolean,
|
||||
sort: { type: String, default: 'date' }, // tri du pool : date | city | priority
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:open', 'update:height', 'resize-start',
|
||||
'update:open', 'update:height', 'resize-start', 'update:sort',
|
||||
'toggle-select', 'select-all', 'clear-select', 'batch-assign',
|
||||
'auto-distribute', 'open-criteria',
|
||||
'row-click', 'row-dblclick', 'row-dragstart',
|
||||
|
|
@ -25,6 +26,9 @@ const jobColor = inject('jobColor')
|
|||
const btColW = inject('btColW')
|
||||
const startColResize = inject('startColResize')
|
||||
|
||||
// Aujourd'hui (fuseau Québec) → un groupe de date PASSÉE = tickets « en retard » (à traiter/fermer).
|
||||
const todayISO = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
|
||||
|
||||
// ── Lasso selection ───────────────────────────────────────────────────────────
|
||||
const btLasso = ref(null)
|
||||
const btScrollRef = ref(null)
|
||||
|
|
@ -103,6 +107,11 @@ function btLassoEnd () {
|
|||
</span>
|
||||
<button v-if="unscheduledCount" class="sbf-auto-btn" @click="emit('auto-distribute')" title="Répartir automatiquement">⚡ Répartir auto</button>
|
||||
<button class="sbf-auto-btn" style="border-color:rgba(255,255,255,0.12)" @click="emit('open-criteria')" title="Critères de dispatch">⚙ Critères</button>
|
||||
<label style="display:inline-flex;align-items:center;gap:4px;font-size:0.72rem;opacity:.85" title="Trier le pool">⇅
|
||||
<select :value="sort" @change="emit('update:sort', $event.target.value)" style="background:rgba(255,255,255,0.06);color:inherit;border:1px solid rgba(255,255,255,0.12);border-radius:5px;padding:1px 4px;font-size:0.72rem">
|
||||
<option value="date">Date</option><option value="city">Ville</option><option value="priority">Priorité</option>
|
||||
</select>
|
||||
</label>
|
||||
<!-- Batch assign bar -->
|
||||
<template v-if="selected.size">
|
||||
<span class="sb-bottom-sel-count">{{ selected.size }} sélectionnée{{ selected.size>1?'s':'' }}</span>
|
||||
|
|
@ -138,10 +147,11 @@ function btLassoEnd () {
|
|||
</thead>
|
||||
</table>
|
||||
<div class="sb-bottom-scroll" ref="btScrollRef" @mousedown="btLassoStart" style="position:relative">
|
||||
<template v-for="group in groups" :key="group.date||'nodate'">
|
||||
<template v-for="group in groups" :key="group.key || group.date || 'nodate'">
|
||||
<div class="sb-bottom-date-sep">
|
||||
<span class="sb-bottom-date-label">{{ group.label }}</span>
|
||||
<span class="sb-bottom-date-count">{{ group.jobs.length }}</span>
|
||||
<span v-if="group.date && group.date < todayISO" :style="{ marginLeft:'8px', color:'#e53935', fontWeight:700, fontSize:'10px', letterSpacing:'.3px' }" title="Date dépassée — à replanifier ou fermer">⏰ EN RETARD</span>
|
||||
</div>
|
||||
<table class="sb-bottom-table">
|
||||
<tbody>
|
||||
|
|
@ -159,6 +169,7 @@ function btLassoEnd () {
|
|||
<span class="sb-bt-prio-dot" :style="'background:'+prioColor(job.priority)" :title="prioLabel(job.priority)"></span>
|
||||
</td>
|
||||
<td class="sb-bt-name" :style="'width:'+btColW('name',200)">
|
||||
<span :style="{ display:'inline-block', width:'9px', height:'9px', borderRadius:'2px', marginRight:'6px', verticalAlign:'middle', flex:'0 0 auto', background: jobColor(job) }" :title="job.legacyDept || job.jobType || ''"></span>
|
||||
<span class="sb-bt-name-text">{{ job.subject }}</span>
|
||||
</td>
|
||||
<td class="sb-bt-addr" :style="'width:'+btColW('addr',180)">{{ shortAddr(job.address) || '—' }}</td>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,25 @@
|
|||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
import { fmtDur, prioLabel, prioClass, ICON } from 'src/composables/useHelpers'
|
||||
import { inject, ref, watch } from 'vue'
|
||||
import { fmtDur, prioLabel, prioClass, ICON, legacyReplyUrl } from 'src/composables/useHelpers'
|
||||
import { legacyTicketThread } from 'src/api/roster'
|
||||
import TagEditor from 'src/components/shared/TagEditor.vue'
|
||||
|
||||
const props = defineProps({
|
||||
panel: Object, // { mode, data: { job, tech } } or null
|
||||
})
|
||||
|
||||
// Fil legacy (description + commentaires/réponses) chargé À LA DEMANDE au clic.
|
||||
const thread = ref(null); const threadLoading = ref(false); const threadOpen = ref(false)
|
||||
async function toggleThread () {
|
||||
const id = props.panel?.data?.job?.legacyTicketId; if (!id) return
|
||||
threadOpen.value = !threadOpen.value
|
||||
if (!threadOpen.value || thread.value) return
|
||||
threadLoading.value = true
|
||||
try { thread.value = await legacyTicketThread(id) } catch (e) { thread.value = { error: String(e.message || e) } } finally { threadLoading.value = false }
|
||||
}
|
||||
function fmtThreadDate (iso) { if (!iso) return ''; const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleString('fr-CA', { dateStyle: 'short', timeStyle: 'short' }) }
|
||||
watch(() => props.panel?.data?.job?.legacyTicketId, () => { thread.value = null; threadOpen.value = false }) // reset quand on change de job
|
||||
|
||||
const emit = defineEmits([
|
||||
'close', 'edit', 'move', 'geofix', 'unassign',
|
||||
'set-end-date', 'remove-assistant', 'assign-pending',
|
||||
|
|
@ -16,6 +29,7 @@ const emit = defineEmits([
|
|||
const store = inject('store')
|
||||
const TECH_COLORS = inject('TECH_COLORS')
|
||||
const jobColor = inject('jobColor')
|
||||
const todayISO = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) // pour le badge « en retard »
|
||||
const getTagColor = inject('getTagColor')
|
||||
const onCreateTag = inject('onCreateTag')
|
||||
const onUpdateTag = inject('onUpdateTag')
|
||||
|
|
@ -72,6 +86,8 @@ const onDeleteTag = inject('onDeleteTag')
|
|||
<div class="sb-rp-field"><span class="sb-rp-lbl">Date planifiée</span>
|
||||
{{ panel.data?.job?.scheduledDate || '—' }}
|
||||
<span v-if="panel.data?.job?.endDate"> → {{ panel.data.job.endDate }}</span>
|
||||
<span v-if="panel.data?.job?.scheduledDate && panel.data.job.scheduledDate < todayISO && panel.data?.job?.status !== 'Completed'"
|
||||
:style="{ marginLeft:'6px', color:'#fff', background:'#e53935', borderRadius:'4px', padding:'0 5px', fontSize:'10px', fontWeight:700 }">⏰ en retard</span>
|
||||
</div>
|
||||
<div v-if="panel.data?.job?.assignedTech" class="sb-rp-field">
|
||||
<span class="sb-rp-lbl">Date de fin</span>
|
||||
|
|
@ -79,6 +95,34 @@ const onDeleteTag = inject('onDeleteTag')
|
|||
@change="emit('set-end-date', panel.data.job, $event.target.value)" style="margin-top:2px" />
|
||||
</div>
|
||||
<div class="sb-rp-field"><span class="sb-rp-lbl">Statut</span>{{ panel.data?.job?.status }}</div>
|
||||
<div v-if="panel.data?.job?.legacyTicketId" class="sb-rp-field">
|
||||
<span class="sb-rp-lbl">Ticket legacy</span>
|
||||
<a class="sb-rp-link" :href="legacyReplyUrl(panel.data.job)" target="_blank" rel="noopener"
|
||||
:title="'Répondre / écrire dans le ticket #' + panel.data.job.legacyTicketId + ' (serveur legacy)'">
|
||||
#{{ panel.data.job.legacyTicketId }}<span v-if="panel.data?.job?.legacyDept"> · {{ panel.data.job.legacyDept }}</span>
|
||||
<span class="sb-rp-link-icon">↗</span>
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="panel.data?.job?.legacyDetail" class="sb-rp-field">
|
||||
<span class="sb-rp-lbl">Détails du ticket</span>
|
||||
<div style="white-space:pre-wrap;max-height:200px;overflow:auto;font-size:0.78rem;line-height:1.4;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);border-radius:6px;padding:6px 8px;margin-top:2px">{{ panel.data.job.legacyDetail }}</div>
|
||||
</div>
|
||||
<!-- Fil complet du ticket legacy : commentaires / réponses des collaborateurs (chargé au clic) -->
|
||||
<div v-if="panel.data?.job?.legacyTicketId" class="sb-rp-field">
|
||||
<button class="sb-rp-btn" style="width:100%;text-align:left" @click="toggleThread">
|
||||
💬 {{ threadOpen ? 'Masquer' : 'Voir' }} le fil du ticket / commentaires
|
||||
<span v-if="thread && thread.count != null" style="opacity:.7">({{ thread.count }})</span>
|
||||
</button>
|
||||
<div v-if="threadOpen" style="margin-top:6px;max-height:300px;overflow:auto">
|
||||
<div v-if="threadLoading" style="font-size:.78rem;opacity:.7;padding:4px">Chargement…</div>
|
||||
<div v-else-if="thread && thread.error" style="font-size:.78rem;color:#ef4444;padding:4px">Erreur : {{ thread.error }}</div>
|
||||
<div v-else-if="thread && !thread.messages?.length" style="font-size:.78rem;opacity:.7;padding:4px">Aucun message.</div>
|
||||
<div v-for="(m, i) in (thread?.messages || [])" :key="i" style="border-left:2px solid rgba(255,255,255,0.12);padding:3px 8px;margin-bottom:6px">
|
||||
<div style="font-size:.68rem;opacity:.7"><b>{{ m.author }}</b> · {{ fmtThreadDate(m.at) }}</div>
|
||||
<div style="white-space:pre-wrap;font-size:.76rem;line-height:1.35">{{ m.text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="panel.data?.job?.note" class="sb-rp-field"><span class="sb-rp-lbl">Note</span>{{ panel.data.job.note }}</div>
|
||||
<div class="sb-rp-field">
|
||||
<span class="sb-rp-lbl">Tags</span>
|
||||
|
|
@ -104,6 +148,8 @@ const onDeleteTag = inject('onDeleteTag')
|
|||
<button class="sb-rp-primary" @click="emit('edit', panel.data.job)">✏ Modifier</button>
|
||||
<button class="sb-rp-btn" @click="emit('move', panel.data.job, panel.data.tech?.id)">↔ Déplacer / Réassigner</button>
|
||||
<button class="sb-rp-btn" @click="emit('geofix', panel.data.job)">📍 Géofixer sur la carte</button>
|
||||
<a v-if="legacyReplyUrl(panel.data?.job)" class="sb-rp-btn" :href="legacyReplyUrl(panel.data.job)" target="_blank" rel="noopener" style="text-decoration:none;text-align:center">📝 Répondre dans legacy</a>
|
||||
<a v-if="panel.data?.job?.legacyActivationUrl" class="sb-rp-btn" :href="panel.data.job.legacyActivationUrl" target="_blank" rel="noopener" style="text-decoration:none;text-align:center;background:#7e3ff2;color:#fff;border-color:#7e3ff2" title="Connecter / activer le(s) STB sur Ministra (lien legacy du ticket)">📺 Activer STB (Ministra)</a>
|
||||
<button v-if="panel.data?.job?.assignedTech" class="sb-rp-btn sb-ctx-warn" @click="emit('unassign', panel.data.job)">✕ Désaffecter</button>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
271
apps/ops/src/pages/AddressConformityPage.vue
Normal file
271
apps/ops/src/pages/AddressConformityPage.vue
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
<template>
|
||||
<q-page class="q-pa-md ops-page">
|
||||
<div class="row items-center q-mb-sm">
|
||||
<div class="text-h6 text-weight-bold">Conformité des adresses</div>
|
||||
<q-space />
|
||||
<q-btn flat dense no-caps size="sm" icon="my_location" label="≈ Centre CP/ville (reste)" color="blue-grey" :loading="areaSaving" @click="applyArea"><q-tooltip>Placer les adresses non matchées restantes au centre de leur code postal (sinon ville) — dernier repli</q-tooltip></q-btn>
|
||||
<q-btn flat dense round icon="refresh" @click="reload" :loading="loading"><q-tooltip>Rafraîchir</q-tooltip></q-btn>
|
||||
</div>
|
||||
<div class="text-caption text-grey-7 q-mb-md">
|
||||
Source de vérité : adresse canonique Adresses Québec (RQA locale) liée à chaque emplacement de service.
|
||||
Résous ici les adresses non conformes — la décision est persistée (plus de re-recherche).
|
||||
</div>
|
||||
|
||||
<!-- Statistiques -->
|
||||
<div class="row q-col-gutter-sm q-mb-md">
|
||||
<div v-for="s in statusCards" :key="s.key" class="col-6 col-sm-3">
|
||||
<q-card flat bordered class="text-center q-pa-sm">
|
||||
<div class="text-h6 text-weight-bold" :class="s.cls">{{ s.n }}</div>
|
||||
<div class="text-caption text-grey-7">{{ s.label }}</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Campings : géoloc de remplacement fixe (le lot pointe vers l'adresse principale du camping) -->
|
||||
<q-expansion-item icon="cottage" label="Campings — géoloc de remplacement fixe" class="q-mb-sm" header-class="text-weight-medium">
|
||||
<q-card flat bordered class="q-pa-sm">
|
||||
<div class="text-caption text-grey-7 q-mb-sm">Pour un lot de camping, l'adresse est souvent la <b>résidence</b> du client, pas le terrain. On force la position du <b>camping</b> (le tech y navigue, puis trouve le terrain). Ajoute un camping → appliqué à tous ses lots (match sur la ville).</div>
|
||||
<q-markup-table flat dense wrap-cells class="q-mb-sm">
|
||||
<thead><tr><th class="text-left">Camping (mot-clé)</th><th class="text-left">Adresse principale</th><th>GPS</th><th>Lots</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="c in campings" :key="c.id">
|
||||
<td class="text-left">{{ c.name }} <span class="text-grey-5">({{ c.keyword }})</span></td>
|
||||
<td class="text-left">{{ c.address }}</td>
|
||||
<td class="text-center"><a :href="'https://www.google.com/maps?q=' + c.latitude + ',' + c.longitude" target="_blank">{{ (+c.latitude).toFixed(4) }}, {{ (+c.longitude).toFixed(4) }}</a></td>
|
||||
<td class="text-center">{{ campingLots[c.name] || 0 }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</q-markup-table>
|
||||
<div class="row q-col-gutter-xs items-center">
|
||||
<q-input class="col-3" dense outlined v-model="newCamp.name" label="Nom" />
|
||||
<q-input class="col-2" dense outlined v-model="newCamp.keyword" label="Mot-clé (ville)" />
|
||||
<q-input class="col-3" dense outlined v-model="newCamp.address" label="Adresse principale" />
|
||||
<q-input class="col" dense outlined v-model.number="newCamp.latitude" label="Lat" />
|
||||
<q-input class="col" dense outlined v-model.number="newCamp.longitude" label="Lon" />
|
||||
<q-btn dense unelevated color="primary" icon="add" :disable="!campValid" :loading="campSaving" @click="addCamping"><q-tooltip>Ajouter + appliquer</q-tooltip></q-btn>
|
||||
<q-btn dense flat color="indigo" icon="sync" :loading="campSaving" @click="applyCampings"><q-tooltip>Réappliquer tous les campings</q-tooltip></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- Filtres par type -->
|
||||
<div class="row items-center q-gutter-sm q-mb-sm">
|
||||
<q-chip v-for="t in typeChips" :key="t.key" clickable :selected="filter.type === t.key"
|
||||
:color="filter.type === t.key ? 'primary' : 'grey-3'" :text-color="filter.type === t.key ? 'white' : 'grey-9'"
|
||||
@click="setType(t.key)">{{ t.label }} <q-badge v-if="t.n != null" color="white" text-color="primary" class="q-ml-xs">{{ t.n }}</q-badge></q-chip>
|
||||
<q-space />
|
||||
<q-input dense outlined v-model="filter.q" debounce="400" placeholder="Rechercher adresse / ville / client" style="min-width:260px" @update:model-value="reload">
|
||||
<template #prepend><q-icon name="search" /></template>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<!-- Liste -->
|
||||
<q-table flat bordered :rows="rows" :columns="columns" row-key="name" :loading="loading"
|
||||
v-model:pagination="pagination" :rows-per-page-options="[25,50,100]" @request="onRequest" :rows-number="total" binary-state-sort>
|
||||
<template #body-cell-original="props">
|
||||
<q-td :props="props">
|
||||
<div class="text-weight-medium">{{ props.row.address_line }}</div>
|
||||
<div class="text-caption text-grey-7">{{ props.row.city }} {{ props.row.postal_code }} · <span class="text-grey-6">{{ props.row.customer }}</span></div>
|
||||
</q-td>
|
||||
</template>
|
||||
<template #body-cell-type="props">
|
||||
<q-td :props="props"><q-chip dense square :color="typeColor(props.row.type)" text-color="white" class="text-caption">{{ typeLabel(props.row.type) }}</q-chip></q-td>
|
||||
</template>
|
||||
<template #body-cell-proposition="props">
|
||||
<q-td :props="props">
|
||||
<div v-if="props.row.linked_address">{{ props.row.linked_address }}
|
||||
<q-badge v-if="hasCoord(props.row)" color="green-6" class="q-ml-xs">GPS</q-badge>
|
||||
</div>
|
||||
<span v-else class="text-grey-5">— (aucune ; utiliser Corriger)</span>
|
||||
</q-td>
|
||||
</template>
|
||||
<template #body-cell-actions="props">
|
||||
<q-td :props="props" class="text-right" style="white-space:nowrap">
|
||||
<q-btn v-if="props.row.linked_address" dense flat round color="positive" icon="check" @click="resolve(props.row,'approve')"><q-tooltip>Approuver la proposition</q-tooltip></q-btn>
|
||||
<q-btn dense flat round color="primary" icon="edit_location_alt" @click="openCorrect(props.row)"><q-tooltip>Corriger (chercher la bonne adresse AQ)</q-tooltip></q-btn>
|
||||
<q-btn dense flat round color="teal" icon="my_location" @click="openGps(props.row)"><q-tooltip>Saisir/relever le GPS</q-tooltip></q-btn>
|
||||
<q-btn dense flat round color="grey-7" icon="map" type="a" :href="mapUrl(props.row)" target="_blank"><q-tooltip>Voir sur map.targointernet.com (géoloc des unités)</q-tooltip></q-btn>
|
||||
<q-btn dense flat round color="negative" icon="block" @click="resolve(props.row,'reject')"><q-tooltip>Rejeter (pas d'adresse civique)</q-tooltip></q-btn>
|
||||
</q-td>
|
||||
</template>
|
||||
<template #no-data><div class="full-width text-center q-pa-md text-grey-6">Aucune adresse à traiter dans ce filtre 🎉</div></template>
|
||||
</q-table>
|
||||
|
||||
<!-- Dialogue Corriger : recherche AQ locale -->
|
||||
<q-dialog v-model="correct.open">
|
||||
<q-card style="min-width:420px;max-width:560px">
|
||||
<q-card-section class="q-pb-none">
|
||||
<div class="text-subtitle1 text-weight-bold">Corriger l'adresse</div>
|
||||
<div class="text-caption text-grey-7">Originale : {{ correct.row && correct.row.address_line }}, {{ correct.row && correct.row.city }}</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-input dense outlined autofocus v-model="correct.q" debounce="350" placeholder="Chercher l'adresse Adresses Québec…" @update:model-value="searchCandidates" :loading="correct.loading">
|
||||
<template #prepend><q-icon name="search" /></template>
|
||||
</q-input>
|
||||
<q-list separator class="q-mt-sm" style="max-height:300px;overflow:auto">
|
||||
<q-item v-for="c in correct.candidates" :key="c.id" clickable v-ripple @click="applyCorrect(c)">
|
||||
<q-item-section>
|
||||
<q-item-label>{{ c.address_full }}</q-item-label>
|
||||
<q-item-label caption>{{ Math.round((c.similarity_score||0)*100) }}% · {{ c.latitude && (+c.latitude).toFixed(5) }}, {{ c.longitude && (+c.longitude).toFixed(5) }}</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side><q-badge v-if="c.fiber_available" color="green-6">FIBRE</q-badge></q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="correct.q && !correct.loading && !correct.candidates.length"><q-item-section class="text-grey-6">Aucun résultat — affine la recherche.</q-item-section></q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right"><q-btn flat label="Fermer" v-close-popup /></q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Dialogue GPS manuel -->
|
||||
<q-dialog v-model="gps.open">
|
||||
<q-card style="min-width:360px">
|
||||
<q-card-section class="q-pb-none">
|
||||
<div class="text-subtitle1 text-weight-bold">Position GPS</div>
|
||||
<div class="text-caption text-grey-7">{{ gps.row && gps.row.address_line }}, {{ gps.row && gps.row.city }}</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-gutter-sm">
|
||||
<q-banner dense class="bg-grey-2 text-caption">Astuce : relève la coordonnée de l'unité sur
|
||||
<a :href="gps.row && mapUrl(gps.row)" target="_blank">map.targointernet.com</a> puis colle « lat, long » ci-dessous.</q-banner>
|
||||
<q-input dense outlined v-model="gps.paste" label="Coller « lat, long »" @update:model-value="parsePaste" />
|
||||
<div class="row q-col-gutter-sm">
|
||||
<q-input class="col" dense outlined type="number" step="any" v-model.number="gps.lat" label="Latitude" />
|
||||
<q-input class="col" dense outlined type="number" step="any" v-model.number="gps.lon" label="Longitude" />
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Annuler" v-close-popup />
|
||||
<q-btn unelevated color="teal" label="Enregistrer" :disable="!gpsValid" @click="saveGps" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import * as addr from 'src/api/address'
|
||||
|
||||
const $q = useQuasar()
|
||||
const loading = ref(false)
|
||||
const rows = ref([])
|
||||
const total = ref(0)
|
||||
const stats = reactive({ by_status: [], by_type: [] })
|
||||
const filter = reactive({ type: '', q: '' })
|
||||
const pagination = ref({ page: 1, rowsPerPage: 50, rowsNumber: 0 })
|
||||
|
||||
const columns = [
|
||||
{ name: 'original', label: 'Adresse (originale)', field: 'address_line', align: 'left' },
|
||||
{ name: 'type', label: 'Type', field: 'type', align: 'left' },
|
||||
{ name: 'proposition', label: 'Proposition AQ (canonique)', field: 'linked_address', align: 'left' },
|
||||
{ name: 'status', label: 'Statut', field: 'status', align: 'left' },
|
||||
{ name: 'actions', label: '', field: 'actions', align: 'right' },
|
||||
]
|
||||
const TYPES = { camping: { label: 'Camping', color: 'deep-orange-5' }, civique: { label: 'Civique à corriger', color: 'blue-6' }, review: { label: 'À confirmer', color: 'amber-7' }, non_adresse: { label: 'Non-adresse', color: 'grey-6' } }
|
||||
const typeLabel = (t) => (TYPES[t] || {}).label || t
|
||||
const typeColor = (t) => (TYPES[t] || {}).color || 'grey-6'
|
||||
const hasCoord = (r) => r.latitude != null && Math.abs(parseFloat(r.latitude)) > 0.0001
|
||||
|
||||
const statN = (k) => { const x = stats.by_status.find(s => s.s === k); return x ? Number(x.n) : 0 }
|
||||
const typeN = (k) => { const x = stats.by_type.find(s => s.t === k); return x ? Number(x.n) : 0 }
|
||||
const statusCards = computed(() => [
|
||||
{ key: 'validated', label: 'Validées (conformes)', n: statN('validated'), cls: 'text-positive' },
|
||||
{ key: 'review', label: 'À confirmer', n: statN('review'), cls: 'text-amber-8' },
|
||||
{ key: 'area', label: '≈ Secteur (CP/ville)', n: statN('area'), cls: 'text-blue-grey-7' },
|
||||
{ key: 'unmatched', label: 'Non matchées', n: statN('unmatched'), cls: 'text-deep-orange' },
|
||||
{ key: 'no_address', label: 'Rejetées (sans adresse)', n: statN('no_address'), cls: 'text-grey-7' },
|
||||
])
|
||||
const typeChips = computed(() => [
|
||||
{ key: '', label: 'Tout', n: statN('review') + statN('unmatched') },
|
||||
{ key: 'civique', label: 'Civique à corriger', n: typeN('civique') },
|
||||
{ key: 'camping', label: 'Camping', n: typeN('camping') },
|
||||
{ key: 'review', label: 'À confirmer', n: typeN('review') },
|
||||
{ key: 'non_adresse', label: 'Non-adresse', n: typeN('non_adresse') },
|
||||
])
|
||||
|
||||
function mapUrl (row) {
|
||||
const q = encodeURIComponent([row.address_line, row.city].filter(Boolean).join(', '))
|
||||
return 'https://map.targointernet.com/infrastructure/map.php?q=' + q
|
||||
}
|
||||
|
||||
async function loadStats () { try { const r = await addr.conformityStats(); stats.by_status = r.by_status || []; stats.by_type = r.by_type || [] } catch (e) {} }
|
||||
async function loadList () {
|
||||
loading.value = true
|
||||
try {
|
||||
const offset = (pagination.value.page - 1) * pagination.value.rowsPerPage
|
||||
const r = await addr.conformityList({ type: filter.type, q: filter.q, limit: pagination.value.rowsPerPage, offset })
|
||||
rows.value = r.rows || []; total.value = r.total || 0; pagination.value.rowsNumber = r.total || 0
|
||||
} catch (e) { $q.notify({ type: 'negative', message: 'Chargement échoué: ' + e.message }) } finally { loading.value = false }
|
||||
}
|
||||
function onRequest (props) { pagination.value.page = props.pagination.page; pagination.value.rowsPerPage = props.pagination.rowsPerPage; loadList() }
|
||||
function setType (t) { filter.type = t; pagination.value.page = 1; loadList() }
|
||||
function reload () { pagination.value.page = 1; loadStats(); loadList() }
|
||||
|
||||
async function resolve (row, action, extra) {
|
||||
try {
|
||||
await addr.conformityResolve({ name: row.name, action, ...(extra || {}) })
|
||||
rows.value = rows.value.filter(r => r.name !== row.name); total.value = Math.max(0, total.value - 1)
|
||||
loadStats()
|
||||
$q.notify({ type: 'positive', message: action === 'reject' ? 'Marquée sans adresse' : 'Adresse validée ✓', timeout: 1200 })
|
||||
} catch (e) { $q.notify({ type: 'negative', message: 'Échec: ' + e.message }) }
|
||||
}
|
||||
|
||||
// ── Corriger ──
|
||||
const correct = reactive({ open: false, row: null, q: '', candidates: [], loading: false })
|
||||
function openCorrect (row) { correct.row = row; correct.q = [row.address_line, row.city].filter(Boolean).join(' '); correct.candidates = []; correct.open = true; searchCandidates() }
|
||||
async function searchCandidates () {
|
||||
if (!correct.q || correct.q.length < 3) { correct.candidates = []; return }
|
||||
correct.loading = true
|
||||
try { const r = await addr.conformityCandidates(correct.q); correct.candidates = r.results || [] } catch (e) {} finally { correct.loading = false }
|
||||
}
|
||||
function applyCorrect (c) {
|
||||
const row = correct.row
|
||||
resolve(row, 'correct', { aq_address_id: String(c.id), linked_address: c.address_full, latitude: c.latitude, longitude: c.longitude })
|
||||
correct.open = false
|
||||
}
|
||||
|
||||
// ── GPS manuel ──
|
||||
const gps = reactive({ open: false, row: null, lat: null, lon: null, paste: '' })
|
||||
const gpsValid = computed(() => isFinite(gps.lat) && isFinite(gps.lon) && Math.abs(gps.lat) > 0.0001 && Math.abs(gps.lon) > 0.0001)
|
||||
function openGps (row) { gps.row = row; gps.lat = row.latitude ? +row.latitude : null; gps.lon = row.longitude ? +row.longitude : null; gps.paste = ''; gps.open = true }
|
||||
function parsePaste () { const m = String(gps.paste).match(/(-?\d+\.\d+)[ ,]+(-?\d+\.\d+)/); if (m) { gps.lat = +m[1]; gps.lon = +m[2] } }
|
||||
function saveGps () { if (!gpsValid.value) return; resolve(gps.row, 'gps', { latitude: gps.lat, longitude: gps.lon }); gps.open = false }
|
||||
|
||||
// ── Campings (géoloc de remplacement fixe) ──
|
||||
const campings = ref([])
|
||||
const campingLots = ref({})
|
||||
const campSaving = ref(false)
|
||||
const newCamp = reactive({ name: '', keyword: '', address: '', latitude: null, longitude: null })
|
||||
const campValid = computed(() => !!newCamp.name && !!newCamp.keyword && isFinite(newCamp.latitude) && isFinite(newCamp.longitude))
|
||||
async function loadCampings () { try { const r = await addr.campingsList(); campings.value = r.campings || []; campingLots.value = r.lots || {} } catch (e) {} }
|
||||
function campApplied (r) { return (r.applied || []).reduce((s, x) => s + (x.n || 0), 0) }
|
||||
async function addCamping () {
|
||||
if (!campValid.value) return
|
||||
campSaving.value = true
|
||||
try {
|
||||
const r = await addr.campingsUpsert({ name: newCamp.name, keyword: newCamp.keyword, address: newCamp.address, latitude: newCamp.latitude, longitude: newCamp.longitude })
|
||||
$q.notify({ type: 'positive', message: `Camping enregistré + appliqué (${campApplied(r)} lots)`, timeout: 2200 })
|
||||
Object.assign(newCamp, { name: '', keyword: '', address: '', latitude: null, longitude: null })
|
||||
loadCampings(); reload()
|
||||
} catch (e) { $q.notify({ type: 'negative', message: 'Échec: ' + e.message }) } finally { campSaving.value = false }
|
||||
}
|
||||
async function applyCampings () {
|
||||
campSaving.value = true
|
||||
try { const r = await addr.campingsApply(); $q.notify({ type: 'positive', message: `Appliqué : ${campApplied(r)} lots`, timeout: 2200 }); loadCampings(); reload() }
|
||||
catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { campSaving.value = false }
|
||||
}
|
||||
|
||||
// ── Dernier repli : centre du code postal / ville pour les unmatched restants ──
|
||||
const areaSaving = ref(false)
|
||||
async function applyArea () {
|
||||
areaSaving.value = true
|
||||
try {
|
||||
const r = await addr.conformityApplyArea()
|
||||
$q.notify({ type: 'positive', message: `${r.total || 0} placés au secteur (${r.by_postal || 0} par code postal, ${r.by_city || 0} par ville)`, timeout: 2600 })
|
||||
reload()
|
||||
} catch (e) { $q.notify({ type: 'negative', message: 'Échec: ' + e.message }) } finally { areaSaving.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => { loadStats(); loadList(); loadCampings() })
|
||||
</script>
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick, provide } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Notify } from 'quasar'
|
||||
import * as roster from 'src/api/roster'
|
||||
import { useDispatchStore } from 'src/stores/dispatch'
|
||||
import { useAuthStore } from 'src/stores/auth'
|
||||
import { TECH_COLORS, MAPBOX_TOKEN, BASE_URL } from 'src/config/erpnext'
|
||||
|
|
@ -50,6 +52,7 @@ import CreateOfferModal from 'src/modules/dispatch/components/CreateOfferModal.v
|
|||
import RecurrenceSelector from 'src/components/shared/RecurrenceSelector.vue'
|
||||
|
||||
const store = useDispatchStore()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const erpUrl = BASE_URL || window.location.origin
|
||||
|
||||
|
|
@ -277,6 +280,28 @@ function cancelUnassign () {
|
|||
confirmUnassignDialog.value = false
|
||||
}
|
||||
|
||||
// #58 — Désaffecter + AVISER LE CLIENT : le hub /roster/job/notify-reschedule désassigne côté serveur
|
||||
// (booking_status « À reporter », vide le créneau), retrouve le mobile du Customer et envoie un SMS avec
|
||||
// un lien /book pour rechoisir un créneau. On reflète ensuite le désassignement dans la vue locale.
|
||||
const notifyingClient = ref(false)
|
||||
async function unassignAndNotify (job) {
|
||||
if (!job || notifyingClient.value) return
|
||||
notifyingClient.value = true
|
||||
let r
|
||||
try { r = await roster.notifyReschedule({ job: job.name || job.id }) }
|
||||
catch (e) { Notify.create({ type: 'negative', message: 'Échec de la notification : ' + (e.message || e) }); notifyingClient.value = false; return }
|
||||
store.fullUnassign(job.id) // met à jour la vue (le serveur a déjà désassigné — réécriture idempotente)
|
||||
const j = store.jobs.find(x => x.id === job.id); if (j) { j.scheduledDate = null; j.startTime = null }
|
||||
if (selectedJob.value?.job?.id === job.id) selectedJob.value = null
|
||||
invalidateRoutes()
|
||||
Notify.create({ type: r && r.sms ? 'positive' : 'warning', timeout: 5000,
|
||||
message: r && r.sms ? ('Client avisé par SMS' + (r.phone ? ' (' + r.phone + ')' : '') + ' · job remis au pool « À reporter »')
|
||||
: ('Désaffecté · ' + ((r && (r.note || r.error)) || 'SMS non envoyé (aucun mobile au dossier)')) })
|
||||
notifyingClient.value = false
|
||||
pendingUnassignJob.value = null
|
||||
confirmUnassignDialog.value = false
|
||||
}
|
||||
|
||||
const {
|
||||
ctxMenu, techCtx, assistCtx, assistNoteModal,
|
||||
openCtxMenu, closeCtxMenu, openTechCtx, openAssistCtx,
|
||||
|
|
@ -285,7 +310,7 @@ const {
|
|||
} = useContextMenus({ store, rightPanel, invalidateRoutes, fullUnassign, openMoveModal, openEditModal })
|
||||
|
||||
const {
|
||||
bottomPanelOpen, bottomPanelH, unassignedGrouped, startBottomResize,
|
||||
bottomPanelOpen, bottomPanelH, unassignedGrouped, bottomSort, startBottomResize,
|
||||
bottomSelected, toggleBottomSelect, selectAllBottom, clearBottomSelect, batchAssignBottom,
|
||||
dispatchCriteria, dispatchCriteriaModal, saveDispatchCriteria, moveCriterion,
|
||||
btColWidths, btColW, startColResize,
|
||||
|
|
@ -1210,6 +1235,15 @@ onMounted(async () => {
|
|||
nextTick(() => scrollToCenter())
|
||||
if (boardScroll.value) boardScroll.value.addEventListener('scroll', onBoardScroll, { passive: true })
|
||||
window.addEventListener('resize', measureCalColW)
|
||||
// Deep-link depuis la Planification : ?tech=<id>&date=<YYYY-MM-DD> → focus jour + ressource.
|
||||
// 'T12:00:00' = midi local pour éviter le décalage de jour (new Date('YYYY-MM-DD') = UTC).
|
||||
nextTick(() => {
|
||||
try {
|
||||
const q = route.query
|
||||
if (q.date) goToDay(String(q.date) + 'T12:00:00')
|
||||
if (q.tech) { const tk = store.technicians.find(t => t.id === String(q.tech)); if (tk && selectedTechId.value !== tk.id) selectTechOnBoard(tk) }
|
||||
} catch (_) {}
|
||||
})
|
||||
})
|
||||
|
||||
// Re-measure column widths when switching views (day→week changes periodDays 1→7)
|
||||
|
|
@ -1600,6 +1634,7 @@ onUnmounted(() => {
|
|||
|
||||
<BottomPanel :open="bottomPanelOpen" :height="bottomPanelH"
|
||||
:groups="unassignedGrouped" :unscheduled-count="unscheduledJobs.length"
|
||||
:sort="bottomSort" @update:sort="v => bottomSort = v"
|
||||
:selected="bottomSelected" :drop-active="unassignDropActive"
|
||||
@update:open="v => bottomPanelOpen = v" @resize-start="startBottomResize"
|
||||
@toggle-select="toggleBottomSelect" @select-all="selectAllBottom" @clear-select="clearBottomSelect"
|
||||
|
|
@ -2106,6 +2141,7 @@ onUnmounted(() => {
|
|||
</div>
|
||||
<div class="sb-confirm-actions">
|
||||
<button class="sb-rp-btn" @click="cancelUnassign">Annuler</button>
|
||||
<button class="sb-rp-btn" :disabled="notifyingClient" @click="unassignAndNotify(pendingUnassignJob)" title="Désaffecter + envoyer au client un SMS avec un lien pour choisir un nouveau créneau">{{ notifyingClient ? 'Envoi…' : '📩 Désaffecter + aviser le client' }}</button>
|
||||
<button class="sb-rp-btn sb-confirm-danger" @click="confirmUnassign">Désaffecter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<div class="row items-center q-mb-sm q-gutter-xs">
|
||||
<div class="text-h6 text-weight-bold">Planification</div>
|
||||
<q-chip v-if="dirty" dense size="sm" color="orange" text-color="white" icon="circle">{{ dirtyCount }} non publié(s)</q-chip>
|
||||
<q-chip v-if="offShiftWeekCount" dense size="sm" color="orange-8" text-color="white" icon="warning">{{ offShiftWeekCount }} hors quart<q-tooltip class="bg-grey-9">{{ offShiftWeekCount }} job(s) assigné(s) cette période un jour où la ressource n'a AUCUN quart publié. Repère le ⚠ dans la grille → publier un quart ou réassigner.</q-tooltip></q-chip>
|
||||
<q-space />
|
||||
<q-btn-group flat>
|
||||
<q-btn dense flat icon="chevron_left" @click="navWeek(-1)"><q-tooltip>Semaine précédente</q-tooltip></q-btn>
|
||||
|
|
@ -153,7 +154,6 @@
|
|||
<div class="dow">{{ d.dow }}</div><div class="dnum">{{ d.dnum }}</div>
|
||||
<q-badge v-if="gapByDay[d.iso]" color="red" floating style="top:2px;right:2px">{{ gapByDay[d.iso] }}</q-badge>
|
||||
<div class="hol-toggle" :class="{ on: isHoliday(d.iso) }" @click.stop="toggleHoliday(d.iso)"><q-tooltip>Marquer férié</q-tooltip>F</div>
|
||||
<div class="hdr-ruler"><span v-for="tk in axisTicks" :key="tk.h" class="tick" :style="{ left: tk.left }">{{ tk.h }}</span></div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -184,7 +184,7 @@
|
|||
<template v-else-if="hasReg(t.id, d.iso) || onGarde(t.id, d.iso)">
|
||||
<div class="tl">
|
||||
<div v-for="(b, bi) in cellBands(t.id, d.iso)" :key="'b' + bi" class="tl-shift" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }"></div>
|
||||
<div v-for="(b, bi) in cellBlocks(t.id, d.iso)" :key="'j' + bi" class="tl-blk" :style="blockStyle(b, cellPct(t.id, d.iso))"></div>
|
||||
<div v-for="(b, bi) in cellBlocks(t.id, d.iso)" :key="'j' + bi" class="tl-blk tl-blk-click" :style="blockStyle(b, cellPct(t.id, d.iso))" @click.stop="openDayEditor(t, d)" @mousedown.stop><q-tooltip class="bg-grey-9" :delay="400" style="font-size:11px">Éditer la tournée du jour</q-tooltip></div>
|
||||
<!-- Aperçu d'occupation projetée pendant le drag : barre fantôme + delta -->
|
||||
<div v-if="isDropTarget(t.id, d.iso) && projPct(t.id, d.iso) != null" class="tl-proj" :style="{ width: Math.min(100, projPct(t.id, d.iso)) + '%', background: occColor(projPct(t.id, d.iso)) }"></div>
|
||||
<q-tooltip class="bg-grey-9" :offset="[0, 6]" max-width="320px">
|
||||
|
|
@ -201,6 +201,7 @@
|
|||
</q-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else-if="offShiftJobs(t.id, d.iso).length" class="offshift-warn" @click.stop="openTimeline(t)"><q-icon name="warning" size="13px" color="orange-8" />{{ offShiftJobs(t.id, d.iso).length }}<q-tooltip class="bg-grey-9">{{ offShiftJobs(t.id, d.iso).length }} job(s) assigné(s) ce jour SANS quart publié — publier un quart ou réassigner. Clic → timeline.</q-tooltip></span>
|
||||
<span v-else class="free">·</span>
|
||||
<div v-if="isDropTarget(t.id, d.iso)" class="drop-badge" :class="{ over: projPct(t.id, d.iso) >= 100 }">+{{ dropPreview.addH }}h<template v-if="projPct(t.id, d.iso) != null"> → {{ projPct(t.id, d.iso) }}%</template></div>
|
||||
</td>
|
||||
|
|
@ -537,17 +538,28 @@
|
|||
<q-btn flat dense round size="sm" icon="refresh" color="white" :loading="assignPanel.loading" @click="openAssignPanel" />
|
||||
<q-btn flat dense round size="sm" icon="close" color="white" @click="assignPanel.open = false" />
|
||||
</div>
|
||||
<div class="assign-sortbar" @mousedown.stop>
|
||||
<span>Trier :</span>
|
||||
<select v-model="assignSort" @mousedown.stop>
|
||||
<option value="group">Groupe (parent-enfant)</option>
|
||||
<option value="skill">Compétence</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="city">Ville</option>
|
||||
<option value="priority">Priorité</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="assign-body">
|
||||
<div v-if="assignPanel.loading" class="text-grey-6 q-pa-md text-center">Chargement…</div>
|
||||
<div v-else-if="!assignPanel.jobs.length" class="text-grey-6 q-pa-md text-center">Aucun job à assigner 🎉</div>
|
||||
<div v-for="grp in assignGroups" :key="grp.key" class="assign-grp" :class="{ 'grp-hl': groupSelected(grp) }">
|
||||
<div v-if="grp.jobs.length > 1" class="assign-grp-hdr" @click="toggleGroupSel(grp)"><q-icon name="account_tree" size="12px" /> Groupe ({{ grp.jobs.length }}) — tout sélectionner (terrain)</div>
|
||||
<div v-for="(j, idx) in grp.jobs" :key="j.name" class="assign-job" :class="{ blocked: j.status === 'On Hold', child: grp.jobs.length > 1 && idx > 0, sel: !!selectedJobs[j.name], dragging: draggingSet.has(j.name) }" draggable="true" @dragstart="onJobDragStart($event, j)" @dragend="onJobDragEnd">
|
||||
<div v-if="grp.label" class="assign-grp-lbl">{{ grp.label }} <span style="opacity:.6">({{ grp.jobs.length }})</span></div>
|
||||
<div v-if="assignSort === 'group' && grp.jobs.length > 1" class="assign-grp-hdr" @click="toggleGroupSel(grp)"><q-icon name="account_tree" size="12px" /> Groupe ({{ grp.jobs.length }}) — tout sélectionner (terrain)</div>
|
||||
<div v-for="(j, idx) in grp.jobs" :key="j.name" class="assign-job" :class="{ blocked: j.status === 'On Hold', child: assignSort === 'group' && grp.jobs.length > 1 && idx > 0, sel: !!selectedJobs[j.name], dragging: draggingSet.has(j.name) }" :style="{ borderLeft: '5px solid ' + panelJobColor(j) }" draggable="true" @dragstart="onJobDragStart($event, j)" @dragend="onJobDragEnd">
|
||||
<div class="row items-center no-wrap">
|
||||
<q-checkbox dense size="xs" :model-value="!!selectedJobs[j.name]" @update:model-value="selectedJobs[j.name] = $event" @click.stop @mousedown.stop class="q-mr-xs" />
|
||||
<q-icon :name="jobIsOnsite(j) ? 'home_repair_service' : 'cloud'" size="13px" :color="jobIsOnsite(j) ? 'teal' : 'grey-5'" class="q-mr-xs"><q-tooltip>{{ jobIsOnsite(j) ? 'Sur site (terrain)' : 'À distance / netadmin — pas pour un tech terrain' }}</q-tooltip></q-icon>
|
||||
<q-badge v-if="j.step_order" color="indigo" class="q-mr-xs">{{ j.step_order }}</q-badge>
|
||||
<span class="ellipsis text-weight-medium">{{ j.subject || j.service_type || j.name }}</span>
|
||||
<span class="ellipsis text-weight-medium">{{ j.subject || j.service_type || j.name }}<q-tooltip v-if="j.legacy_detail" max-width="380px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.legacy_detail }}</q-tooltip></span>
|
||||
<q-space />
|
||||
<q-icon v-if="j.status === 'On Hold'" name="lock" size="13px" color="orange"><q-tooltip>En attente de {{ j.depends_on || 'la tâche précédente' }}</q-tooltip></q-icon>
|
||||
</div>
|
||||
|
|
@ -571,18 +583,20 @@
|
|||
<q-icon name="timeline" color="indigo" size="22px" class="q-mr-sm" />
|
||||
<div class="text-subtitle1 text-weight-bold">Timeline — {{ timelineDlg.tech && timelineDlg.tech.name }}</div>
|
||||
<q-space />
|
||||
<q-btn flat dense no-caps size="sm" icon="open_in_new" label="Dispatch" color="indigo" @click="$router.push('/dispatch')" />
|
||||
<q-btn flat dense no-caps size="sm" icon="open_in_new" label="Dispatch" color="indigo" @click="gotoDispatch(timelineDlg.tech)"><q-tooltip>Ouvrir le tableau Dispatch sur cette ressource</q-tooltip></q-btn>
|
||||
<q-btn flat round dense icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pt-none" style="max-height:70vh;overflow:auto">
|
||||
<div v-if="!timelineDays.length" class="text-grey-6 q-pa-md text-center">Aucun job planifié cette semaine pour cette ressource.</div>
|
||||
<div v-for="day in timelineDays" :key="day.iso" class="tldlg-day">
|
||||
<div class="row items-center q-mb-xs">
|
||||
<div class="text-weight-medium" :class="{ 'text-deep-orange-7': day.weekend }">{{ day.label }}</div><q-space />
|
||||
<div class="text-weight-medium" :class="{ 'text-deep-orange-7': day.weekend }">{{ day.label }}</div>
|
||||
<q-badge v-if="day.offShift" color="orange-8" class="q-ml-sm"><q-icon name="warning" size="11px" class="q-mr-xs" />hors quart publié</q-badge>
|
||||
<q-space />
|
||||
<q-badge v-if="day.pct != null" text-color="white" :style="{ background: occColor(day.pct) }">{{ day.usedH }}h · {{ day.pct }}%</q-badge>
|
||||
<q-badge v-else color="grey-5" class="q-ml-xs">{{ day.usedH }}h</q-badge>
|
||||
</div>
|
||||
<div class="tldlg-bar">
|
||||
<div v-if="!day.offShift" class="tldlg-bar">
|
||||
<div v-for="(b, bi) in day.bands" :key="'b' + bi" class="tl-shift" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }"></div>
|
||||
<div v-for="(b, bi) in day.blocks" :key="'k' + bi" class="tl-blk" :style="blockStyle(b, day.pct)"></div>
|
||||
<span v-for="tk in axisTicks" :key="'t' + tk.h" class="tldlg-tick" :style="{ left: tk.left }">{{ tk.h }}</span>
|
||||
|
|
@ -643,6 +657,68 @@
|
|||
</div>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
|
||||
<!-- Éditeur de JOURNÉE (clic sur le progressbar) : timeline + réordonner par drag-drop + retirer un job -->
|
||||
<q-dialog v-model="dayEditor.open">
|
||||
<q-card style="min-width:560px;max-width:680px">
|
||||
<q-card-section class="row items-center q-pb-sm">
|
||||
<q-icon name="view_timeline" color="indigo" size="22px" class="q-mr-sm" />
|
||||
<div>
|
||||
<div class="text-subtitle1 text-weight-bold">{{ dayEditor.tech && dayEditor.tech.name }} — {{ dayEditor.day && (dayEditor.day.dow + ' ' + dayEditor.day.dnum) }}</div>
|
||||
<div class="text-caption text-grey-7" v-if="dayOcc()">{{ dayOcc().usedH }}h occupé / {{ dayOcc().bookableH }}h · {{ dayOcc().pct }}%</div>
|
||||
</div>
|
||||
<q-space />
|
||||
<q-btn flat dense no-caps size="sm" icon="open_in_new" label="Dispatch" color="indigo" @click="gotoDispatch(dayEditor.tech, dayEditor.day && dayEditor.day.iso)"><q-tooltip>Ouvrir le tableau Dispatch complet</q-tooltip></q-btn>
|
||||
<q-btn flat round dense icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pt-none">
|
||||
<!-- timeline visuelle (réutilise les blocs colorés par compétence) -->
|
||||
<div class="tldlg-bar" style="height:20px">
|
||||
<div v-for="(b, bi) in dayBands()" :key="'b' + bi" class="tl-shift" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }"></div>
|
||||
<div v-for="(g, gi) in dayTravelSegs()" :key="'tr' + gi" class="tl-travel" :style="pos(g.s, Math.min(g.e, 24))"><q-tooltip class="bg-grey-9" style="font-size:11px">🚗 déplacement</q-tooltip></div>
|
||||
<div v-for="(b, bi) in dayBlocks()" :key="'k' + bi" class="tl-blk" :style="blockStyle(b, dayOcc() && dayOcc().pct)"></div>
|
||||
<span v-for="tk in axisTicks" :key="'t' + tk.h" class="tldlg-tick" :style="{ left: tk.left }">{{ tk.h }}</span>
|
||||
</div>
|
||||
<!-- carte interactive : itinéraire ROUTIER réel + pins numérotés, navigable (zoom molette/boutons, déplacement) -->
|
||||
<div v-show="dayEditor.list.length" class="de-map-wrap">
|
||||
<div ref="dayMapEl" class="de-map-gl"></div>
|
||||
<div class="de-map-cap">🗺 Itinéraire routier · molette/boutons = zoom · glisser = déplacer<span v-if="dayNoCoord" class="text-deep-orange-7"> · ⚠ {{ dayNoCoord }} sans coords (absent de la carte)</span></div>
|
||||
</div>
|
||||
<div v-if="!dayEditor.list.length" class="text-grey-6 q-pa-md text-center">Aucun job ce jour.</div>
|
||||
<!-- liste éditable : flèches/glisser pour réordonner · durée en minutes · ✕ pour retirer -->
|
||||
<template v-for="(j, i) in dayEditor.list" :key="j.name">
|
||||
<!-- temps de transport estimé depuis le job précédent (l'espace entre 2 blocs) -->
|
||||
<div v-if="i > 0" class="de-travel">
|
||||
<template v-if="dayLeg(i)">{{ dayLeg(i).real ? '🚗' : '📏' }} {{ dayLeg(i).real ? '' : '~' }}{{ dayLeg(i).min }} min<template v-if="dayLeg(i).km != null"> · {{ dayLeg(i).km }} km</template><q-tooltip class="bg-grey-9" style="font-size:11px">{{ dayLeg(i).real ? 'Temps routier réel (routes Mapbox)' : 'Estimation à vol d’oiseau (coords approximatives ou Mapbox indisponible)' }}</q-tooltip></template>
|
||||
<template v-else>🚗 transport ? (adresse/coords manquantes)</template>
|
||||
</div>
|
||||
<div class="de-row" :class="{ 'de-drag': dayEditor.dragIdx === i }"
|
||||
draggable="true" @dragstart="dayDragStart(i, $event)" @dragover.prevent @drop="dayDropOn(i)" @dragend="dayDragEnd">
|
||||
<div class="column" style="gap:0">
|
||||
<q-btn flat dense round size="9px" icon="keyboard_arrow_up" :disable="i === 0" @click="moveDayJob(i, -1)" />
|
||||
<q-btn flat dense round size="9px" icon="keyboard_arrow_down" :disable="i === dayEditor.list.length - 1" @click="moveDayJob(i, 1)" />
|
||||
</div>
|
||||
<q-icon name="drag_indicator" size="16px" class="text-grey-5" style="cursor:grab" />
|
||||
<span class="de-ord">{{ i + 1 }}</span>
|
||||
<span class="de-dot" :style="{ background: j.skill ? getTagColor(j.skill) : prioColor(j.priority) }"></span>
|
||||
<div class="col" style="min-width:0;cursor:pointer" @click="j.showDetail = !j.showDetail; focusDayJob(j)"><q-tooltip v-if="j.detail && !j.showDetail" max-width="360px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.detail }}</q-tooltip>
|
||||
<div class="ellipsis text-weight-medium" style="font-size:13px">{{ j.subject }} <q-icon name="info_outline" size="12px" class="text-grey-5" /></div>
|
||||
<div class="ellipsis text-grey-6" style="font-size:11px">{{ fmtHM(packedDay[i].startMin) }}–{{ fmtHM(packedDay[i].endMin) }}<span v-if="j.locked" class="text-deep-orange-7"> · 🔒 RDV fixe</span><span v-if="j.customer"> · {{ j.customer }}</span></div>
|
||||
</div>
|
||||
<div class="de-dur"><input type="number" min="5" step="5" :value="jobMinutes(j)" @change="setJobMinutes(j, $event.target.value)" @click.stop @mousedown.stop /><span>min</span></div>
|
||||
<!-- sélecteur de priorité retiré (défaut « Moyenne » conservé en donnée) → gain de place ; priorité gérée au Dispatch -->
|
||||
<q-btn flat dense round size="sm" :icon="j.locked ? 'lock' : 'lock_open'" :color="j.locked ? 'deep-orange' : 'grey-5'" @click="j.locked = !j.locked"><q-tooltip>{{ j.locked ? 'Heure FIXE (RDV) — verrouillée, non replanifiée' : 'Heure flexible — replanifiée par la tournée' }}</q-tooltip></q-btn>
|
||||
<q-btn flat dense round size="sm" icon="close" color="negative" @click="removeFromDay(j)"><q-tooltip>Retirer du tech (retour au pool)</q-tooltip></q-btn>
|
||||
</div>
|
||||
<div v-if="j.showDetail" class="de-detail">{{ j.detail || 'Aucun détail importé pour ce ticket.' }}</div>
|
||||
</template>
|
||||
</q-card-section>
|
||||
<q-card-section v-if="dayEditor.list.length" class="row items-center q-pt-none">
|
||||
<span class="text-caption text-grey-6">Glisser/flèches = ordre (heures recalculées) · 🔒 = RDV fixe · clic = détails · total <b>{{ dayTotalH() }}h</b></span><q-space />
|
||||
<q-btn dense unelevated color="primary" :loading="dayEditor.saving" label="Enregistrer" @click="saveDayOrder" />
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
|
|
@ -665,17 +741,20 @@
|
|||
* 10. Chargement & solveur ................. loadBase/loadWeek/loadStats · doGenerate/doPublish
|
||||
* 11. Helpers date/temps/couleur .......... iso/hToNum/numToTime · occColor/todColor/getTagColor
|
||||
*/
|
||||
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
// Icônes de rôle monochromes outline (Material Symbols, style « une couleur » demandé) : échelle = installation.
|
||||
import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } from '@quasar/extras/material-symbols-outlined'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { onBeforeRouteLeave, useRouter } from 'vue-router'
|
||||
import { useQuasar } from 'quasar'
|
||||
import * as roster from 'src/api/roster'
|
||||
import { MAPBOX_TOKEN } from 'src/config/erpnext' // routage routier réel (API Mapbox Matrix), déjà utilisé par le Dispatch
|
||||
import { legacyDeptColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé avec le board Dispatch)
|
||||
import TechSelect from 'src/components/shared/TechSelect.vue'
|
||||
import SkillSelect from 'src/components/shared/SkillSelect.vue'
|
||||
import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags partagé (Dispatch) : condensé, création à la volée, couleurs
|
||||
|
||||
const $q = useQuasar()
|
||||
const router = useRouter()
|
||||
const DIRTY_MSG = 'Vous avez des modifications non publiées. Les abandonner ?'
|
||||
|
||||
const techs = ref([])
|
||||
|
|
@ -865,9 +944,29 @@ const selectedJobs = reactive({}) // jobName → true
|
|||
const dropPreview = reactive({ key: null, addH: 0 })
|
||||
const draggingSet = reactive(new Set()); let _dragGhost = null // jobs en cours de glissé (source estompée) + fantôme custom
|
||||
async function openAssignPanel () { assignPanel.open = true; assignPanel.loading = true; for (const k in selectedJobs) delete selectedJobs[k]; try { assignPanel.jobs = (await roster.unassignedJobs()).jobs || [] } catch (e) { err(e) } finally { assignPanel.loading = false } }
|
||||
const assignGroups = computed(() => { // regroupe par parent_job (ou nom propre), ordonné par step_order
|
||||
const g = {}; for (const j of assignPanel.jobs) { const k = j.parent_job || j.name; (g[k] = g[k] || []).push(j) }
|
||||
return Object.keys(g).map(k => ({ key: k, jobs: g[k].slice().sort((a, b) => (a.step_order || 0) - (b.step_order || 0)) }))
|
||||
const assignSort = ref('group') // group (parent-enfant) | skill | date | city | priority
|
||||
const ASSIGN_PRIO = { urgent: 0, high: 1, medium: 2, low: 3 }
|
||||
function jobCity (j) {
|
||||
const a = String(j.location_label || j.service_location || '')
|
||||
const parts = a.split(',').map(s => s.trim()).filter(Boolean)
|
||||
if (parts.length >= 2) return parts[parts.length - 1] // dernier segment d'adresse = ville
|
||||
const subj = String(j.subject || ''); if (subj.includes('|')) return subj.split('|')[0].trim() // sujets legacy « Ville | Nom »
|
||||
return parts[0] || 'Sans ville'
|
||||
}
|
||||
const assignGroups = computed(() => {
|
||||
const jobs = assignPanel.jobs
|
||||
if (assignSort.value === 'group') { // défaut : groupe parent-enfant (installation avant activation…), ordonné par step_order
|
||||
const g = {}; for (const j of jobs) { const k = j.parent_job || j.name; (g[k] = g[k] || []).push(j) }
|
||||
return Object.keys(g).map(k => ({ key: k, label: null, jobs: g[k].slice().sort((a, b) => (a.step_order || 0) - (b.step_order || 0)) }))
|
||||
}
|
||||
const keyOf = j => assignSort.value === 'skill' ? (j.required_skill || 'Sans compétence')
|
||||
: assignSort.value === 'city' ? jobCity(j)
|
||||
: assignSort.value === 'priority' ? (j.priority || 'low')
|
||||
: (j.scheduled_date || 'Sans date')
|
||||
const labelOf = k => assignSort.value === 'priority' ? (({ urgent: '🔴 Urgent', high: '🟠 Élevée', medium: '🔵 Moyenne', low: '⚪ Basse' })[k] || k) : k
|
||||
const g = {}; for (const j of jobs) { const k = keyOf(j); (g[k] = g[k] || []).push(j) }
|
||||
const keys = Object.keys(g).sort((a, b) => assignSort.value === 'priority' ? (ASSIGN_PRIO[a] ?? 9) - (ASSIGN_PRIO[b] ?? 9) : a.localeCompare(b))
|
||||
return keys.map(k => ({ key: k, label: labelOf(k), jobs: g[k] }))
|
||||
})
|
||||
// Terrain vs à distance : l'activation / config / netadmin ne va PAS à un tech sur site (heuristique skill + type/sujet).
|
||||
function jobIsOnsite (j) {
|
||||
|
|
@ -900,9 +999,14 @@ async function onCellDrop (ev, t, d) {
|
|||
dropCell.value = null; dropPreview.key = null
|
||||
const raw = (ev.dataTransfer && ev.dataTransfer.getData('text/plain')) || draggingJobName.value; draggingJobName.value = null
|
||||
const names = (raw || '').split(',').filter(Boolean); if (!names.length) return
|
||||
// Garde-fou : un job « On Hold » attend une tâche précédente → on REFUSE de l'assigner (≠ simple 🔒 visuel).
|
||||
const statusBy = Object.fromEntries(assignPanel.jobs.map(j => [j.name, j.status]))
|
||||
const blocked = names.filter(n => statusBy[n] === 'On Hold'); const assignable = names.filter(n => statusBy[n] !== 'On Hold')
|
||||
if (blocked.length) $q.notify({ type: 'warning', message: blocked.length + ' job(s) en attente d\'une tâche précédente — non assigné(s). Termine d\'abord l\'étape requise.', timeout: 4000 })
|
||||
if (!assignable.length) return
|
||||
let ok = 0
|
||||
for (const jn of names) { try { await roster.assignJob(jn, t.id, d.iso); ok++; delete selectedJobs[jn] } catch (e) { err(e) } } // SÉQUENTIEL
|
||||
assignPanel.jobs = assignPanel.jobs.filter(j => !names.includes(j.name))
|
||||
for (const jn of assignable) { try { await roster.assignJob(jn, t.id, d.iso); ok++; delete selectedJobs[jn] } catch (e) { err(e) } } // SÉQUENTIEL (frappe_pg)
|
||||
assignPanel.jobs = assignPanel.jobs.filter(j => !assignable.includes(j.name)) // les bloqués restent dans le panneau
|
||||
$q.notify({ type: 'positive', message: ok + ' job(s) assigné(s) à ' + t.name + ' · ' + d.dnum, timeout: 2800 }); await loadWeek()
|
||||
}
|
||||
let _panelDrag = null // déplacement du panneau via son en-tête
|
||||
|
|
@ -914,14 +1018,194 @@ function panelUp () { _panelDrag = null; document.removeEventListener('mousemove
|
|||
// Réutilise les helpers de cellule (cellBands/cellBlocks/cellJobs/cellPct) → 0 nouvel appel réseau.
|
||||
const timelineDlg = reactive({ open: false, tech: null })
|
||||
function openTimeline (t) { timelineDlg.tech = t; timelineDlg.open = true }
|
||||
// (Clic sur le progressbar → gotoDispatch : on ouvre le timeline ÉDITABLE du tableau Dispatch, drag-drop + suppression,
|
||||
// plutôt qu'un popup maison — réutilisation max + cohérence. Le réordonnancement/priorité se fait là-bas.)
|
||||
// Deep-link vers le tableau Dispatch focalisé sur la ressource + le jour cliqué (sinon 1er jour de la semaine).
|
||||
function gotoDispatch (t, dateIso) {
|
||||
const q = {}
|
||||
if (t) q.tech = t.id
|
||||
q.date = dateIso || (timelineDays.value[0] && timelineDays.value[0].iso) || start.value
|
||||
router.push({ path: '/dispatch', query: q })
|
||||
}
|
||||
// ── Éditeur de JOURNÉE (fenêtre contextuelle ciblée — clic sur le progressbar) ──
|
||||
// Garde le contexte de la grille derrière. Timeline + réordonnancement DRAG-DROP + retrait d'un job.
|
||||
const dayEditor = reactive({ open: false, tech: null, day: null, list: [], saving: false, dragIdx: null, travelMap: {}, routeReady: false })
|
||||
function openDayEditor (t, d) {
|
||||
dayEditor.tech = t; dayEditor.day = d
|
||||
// RDV confirmé (ou heure légacy précise) = heure FIXE → verrouillé ; sinon flexible (replanifiable par la tournée).
|
||||
dayEditor.list = cellJobs(t.id, d.iso).map(j => ({ ...j, locked: j.booking_status === 'Confirmé', showDetail: false }))
|
||||
dayEditor.dragIdx = null; dayEditor.travelMap = {}; dayEditor.routeReady = false; dayEditor.open = true
|
||||
loadDayRoute() // charge la matrice de temps routiers RÉELS (Mapbox) → packedDay les utilise dès l'arrivée (réactif)
|
||||
}
|
||||
// Matrice des temps de trajet ROUTIERS RÉELS entre tous les jobs du jour (Mapbox Matrix, 1 requête).
|
||||
// Indépendante de l'ordre → le réordonnancement réutilise la matrice SANS nouvelle requête (recalcul instantané).
|
||||
// Repli silencieux sur l'haversine si Mapbox indispo ou coords manquantes.
|
||||
async function loadDayRoute () {
|
||||
const key = (dayEditor.tech && dayEditor.tech.id) + '|' + (dayEditor.day && dayEditor.day.iso)
|
||||
const pts = dayEditor.list.filter(j => j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon)).slice(0, 25) // Matrix = 25 coords max
|
||||
if (pts.length < 2 || !MAPBOX_TOKEN) { dayEditor.travelMap = {}; dayEditor.routeReady = false; return }
|
||||
const coords = pts.map(j => `${(+j.lon).toFixed(6)},${(+j.lat).toFixed(6)}`).join(';')
|
||||
const url = `https://api.mapbox.com/directions-matrix/v1/mapbox/driving/${coords}?annotations=duration,distance&access_token=${MAPBOX_TOKEN}`
|
||||
try {
|
||||
const r = await fetch(url); if (!r.ok) throw new Error('matrix ' + r.status)
|
||||
const d = await r.json(); const dur = d.durations || [], dist = d.distances || []
|
||||
if (key !== ((dayEditor.tech && dayEditor.tech.id) + '|' + (dayEditor.day && dayEditor.day.iso))) return // l'éditeur a changé de cible entre-temps
|
||||
const map = {}
|
||||
for (let i = 0; i < pts.length; i++) for (let k = 0; k < pts.length; k++) {
|
||||
if (i === k) continue
|
||||
const sec = dur[i] && dur[i][k]; const m = dist[i] && dist[i][k]
|
||||
if (sec == null) continue
|
||||
map[pts[i].name + '>' + pts[k].name] = { min: Math.max(2, Math.round(sec / 60)), km: m != null ? Math.round(m / 100) / 10 : null, real: true }
|
||||
}
|
||||
dayEditor.travelMap = map; dayEditor.routeReady = true
|
||||
} catch (e) { dayEditor.travelMap = {}; dayEditor.routeReady = false } // repli haversine
|
||||
}
|
||||
const dayOcc = () => (dayEditor.tech && dayEditor.day) ? cellOcc(dayEditor.tech.id, dayEditor.day.iso) : null
|
||||
const dayBands = () => (dayEditor.tech && dayEditor.day) ? cellBands(dayEditor.tech.id, dayEditor.day.iso) : []
|
||||
// Blocs RECALCULÉS depuis la SÉQUENCE éditée (packedDay) → l'ordre + les durées + le transport se reflètent + plus d'overlap.
|
||||
const dayBlocks = () => packedDay.value.map(p => ({ s: p.startMin, e: p.endMin, skill: p.skill }))
|
||||
// Réordonnancement : flèches ↑↓ (fiable) + drag-drop basé sur le DROP (robuste, pas de splice live jittery)
|
||||
function moveDayJob (i, dir) { const j = i + dir; const l = dayEditor.list; if (j < 0 || j >= l.length) return; const [x] = l.splice(i, 1); l.splice(j, 0, x) }
|
||||
function dayDragStart (i, ev) { dayEditor.dragIdx = i; try { ev.dataTransfer.effectAllowed = 'move'; ev.dataTransfer.setData('text/plain', String(i)) } catch (e) {} }
|
||||
function dayDropOn (i) { const from = dayEditor.dragIdx; if (from == null || from === i) { dayEditor.dragIdx = null; return } const l = dayEditor.list; const [x] = l.splice(from, 1); l.splice(i, 0, x); dayEditor.dragIdx = null }
|
||||
function dayDragEnd () { dayEditor.dragIdx = null }
|
||||
// Durée éditable en MINUTES (pas de 5) — best practice de précision
|
||||
function jobMinutes (j) { return Math.round((Number(j.dur) || 0) * 60) }
|
||||
function setJobMinutes (j, min) { const m = Math.max(5, Math.round((Number(min) || 0) / 5) * 5); j.dur = Math.round(m / 60 * 100) / 100 }
|
||||
// Temps de transport estimé entre 2 jobs (haversine via coords Service Location) — provisoire, en attendant la géoloc live (Capacitor)
|
||||
function haversineKm (la1, lo1, la2, lo2) { if ([la1, lo1, la2, lo2].some(v => v == null)) return null; const R = 6371; const r = x => x * Math.PI / 180; const dLa = r(la2 - la1); const dLo = r(lo2 - lo1); const s = Math.sin(dLa / 2) ** 2 + Math.cos(r(la1)) * Math.cos(r(la2)) * Math.sin(dLo / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(s)) }
|
||||
function travelBetween (a, b) {
|
||||
if (!a || !b) return null
|
||||
const hit = dayEditor.travelMap && dayEditor.travelMap[a.name + '>' + b.name]
|
||||
if (hit) return hit // temps routier RÉEL (Mapbox Matrix)
|
||||
const km = haversineKm(a.lat, a.lon, b.lat, b.lon); if (km == null) return null
|
||||
return { km: Math.round(km * 10) / 10, min: Math.max(5, Math.round(km / 40 * 60) + 5), real: false } // repli : 40 km/h + 5 min tampon (vol d'oiseau)
|
||||
}
|
||||
function dayLeg (i) { return i > 0 ? travelBetween(dayEditor.list[i - 1], dayEditor.list[i]) : null } // trajet vers le job i depuis le précédent
|
||||
const fmtHM = (h) => { if (h == null) return '—'; const m = Math.round(h * 60); const hh = Math.floor(m / 60), mm = m % 60; return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') } // heure décimale → HH:MM (padded, pour start_time)
|
||||
function dayShiftStartH () { const t = dayEditor.tech, d = dayEditor.day; if (!t || !d) return 8; const w = winOf(t.id, d.iso, false); return w ? w.s : 8 }
|
||||
// PLANIFICATEUR DE TOURNÉE : recalcule les heures depuis l'ordre de la liste + durées + transport.
|
||||
// Job verrouillé (RDV fixe) → garde son heure ; flexible → enchaîné après le précédent (+ transport). Plus d'overlap.
|
||||
const packedDay = computed(() => {
|
||||
const list = dayEditor.list; const out = []; let cursor = dayShiftStartH()
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const j = list[i]; const dur = Number(j.dur) || 1
|
||||
const start = (j.locked && j.start_h != null) ? j.start_h : cursor
|
||||
const end = start + dur
|
||||
out.push({ ...j, startMin: start, endMin: end })
|
||||
const trH = (i < list.length - 1 ? (travelBetween(j, list[i + 1]) || {}).min || 0 : 0) / 60
|
||||
cursor = Math.max(cursor, end) + trH
|
||||
}
|
||||
return out
|
||||
})
|
||||
const hasLL = (j) => j && j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon)
|
||||
const dayNoCoord = computed(() => dayEditor.list.filter(j => !hasLL(j)).length)
|
||||
|
||||
// ── Carte INTERACTIVE de la journée (Mapbox GL) : itinéraire ROUTIER réel (API Directions) + pins
|
||||
// numérotés dans l'ordre de tournée. Navigable : zoom molette + boutons (NavigationControl), déplacement. ──
|
||||
const dayMapEl = ref(null)
|
||||
let _dayMap = null; let _dayMapRO = null; let _dirTimer = null
|
||||
function ensureMapbox () {
|
||||
return new Promise((resolve) => {
|
||||
if (!document.getElementById('mapbox-css')) { const l = document.createElement('link'); l.id = 'mapbox-css'; l.rel = 'stylesheet'; l.href = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'; document.head.appendChild(l) }
|
||||
if (window.mapboxgl) return resolve(window.mapboxgl)
|
||||
let s = document.getElementById('mapbox-js')
|
||||
if (!s) { s = document.createElement('script'); s.id = 'mapbox-js'; s.src = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'; document.head.appendChild(s) }
|
||||
s.addEventListener('load', () => resolve(window.mapboxgl))
|
||||
const iv = setInterval(() => { if (window.mapboxgl) { clearInterval(iv); resolve(window.mapboxgl) } }, 150)
|
||||
})
|
||||
}
|
||||
const _esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))
|
||||
function dayStops () { // arrêts géolocalisés, dans l'ordre de tournée (packedDay) + infos pour le popup
|
||||
return packedDay.value.filter(hasLL).map((j, i) => ({
|
||||
lon: +j.lon, lat: +j.lat, label: String(i + 1), color: j.skill ? getTagColor(j.skill) : '#1976d2',
|
||||
subject: j.subject || '', customer: j.customer || '', time: fmtHM(j.startMin) + '–' + fmtHM(j.endMin),
|
||||
}))
|
||||
}
|
||||
// Segments de DÉPLACEMENT (pointillés) = l'espace entre 2 jobs dans la barre timeline.
|
||||
const dayTravelSegs = () => { const p = packedDay.value; const out = []; for (let i = 0; i < p.length - 1; i++) { const s = p[i].endMin, e = p[i + 1].startMin; if (e - s > 0.02) out.push({ s, e }) } return out }
|
||||
// Centre la carte sur un job (clic sur la ligne de la liste).
|
||||
function focusDayJob (j) { if (_dayMap && hasLL(j)) _dayMap.easeTo({ center: [+j.lon, +j.lat], zoom: 14, duration: 500 }) }
|
||||
async function initDayMap () {
|
||||
if (!MAPBOX_TOKEN || !dayMapEl.value || _dayMap) return
|
||||
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !dayMapEl.value) return
|
||||
mapboxgl.accessToken = MAPBOX_TOKEN
|
||||
_dayMap = new mapboxgl.Map({ container: dayMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.6756, 45.1599], zoom: 9 })
|
||||
_dayMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right') // boutons zoom +/-
|
||||
_dayMapRO = new ResizeObserver(() => { if (_dayMap) _dayMap.resize() }); _dayMapRO.observe(dayMapEl.value)
|
||||
_dayMap.on('load', () => {
|
||||
_dayMap.resize()
|
||||
_dayMap.addSource('day-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
_dayMap.addLayer({ id: 'day-route-halo', type: 'line', source: 'day-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#3b5bdb', 'line-width': 9, 'line-opacity': 0.2 } })
|
||||
_dayMap.addLayer({ id: 'day-route', type: 'line', source: 'day-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#3b5bdb', 'line-width': 4, 'line-opacity': 0.85 } })
|
||||
_dayMap.addSource('day-stops', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
_dayMap.addLayer({ id: 'day-stops-c', type: 'circle', source: 'day-stops', paint: { 'circle-radius': 13, 'circle-color': ['get', 'color'], 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' } })
|
||||
_dayMap.addLayer({ id: 'day-stops-l', type: 'symbol', source: 'day-stops', layout: { 'text-field': ['get', 'label'], 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-size': 12, 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } })
|
||||
// Clic sur un pin → popup avec les détails du job ; curseur main au survol.
|
||||
_dayMap.on('mouseenter', 'day-stops-c', () => { _dayMap.getCanvas().style.cursor = 'pointer' })
|
||||
_dayMap.on('mouseleave', 'day-stops-c', () => { _dayMap.getCanvas().style.cursor = '' })
|
||||
_dayMap.on('click', 'day-stops-c', (e) => {
|
||||
const f = e.features[0]; const p = f.properties
|
||||
new window.mapboxgl.Popup({ offset: 14 }).setLngLat(f.geometry.coordinates)
|
||||
.setHTML(`<div style="font-size:12px;line-height:1.45"><b>${_esc(p.label)}. ${_esc(p.subject)}</b><br>🕒 ${_esc(p.time)}${p.customer ? '<br>👤 ' + _esc(p.customer) : ''}</div>`)
|
||||
.addTo(_dayMap)
|
||||
})
|
||||
refreshDayMap()
|
||||
})
|
||||
}
|
||||
function refreshDayMap () {
|
||||
if (!_dayMap || !_dayMap.isStyleLoaded()) { setTimeout(refreshDayMap, 200); return }
|
||||
const stops = dayStops()
|
||||
const sSrc = _dayMap.getSource('day-stops')
|
||||
if (sSrc) sSrc.setData({ type: 'FeatureCollection', features: stops.map(s => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [s.lon, s.lat] }, properties: { label: s.label, color: s.color } })) })
|
||||
if (stops.length === 1) _dayMap.easeTo({ center: [stops[0].lon, stops[0].lat], zoom: 13, duration: 400 })
|
||||
else if (stops.length > 1) {
|
||||
const b = new window.mapboxgl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat])
|
||||
stops.forEach(s => b.extend([s.lon, s.lat]))
|
||||
_dayMap.fitBounds(b, { padding: 45, maxZoom: 14, duration: 400 })
|
||||
}
|
||||
fetchDayRouteGeom(stops)
|
||||
}
|
||||
async function fetchDayRouteGeom (stops) { // itinéraire ROUTIER réel (Directions) → tracé sur la carte
|
||||
const rSrc = _dayMap && _dayMap.getSource('day-route'); if (!rSrc) return
|
||||
if (!stops || stops.length < 2) { rSrc.setData({ type: 'FeatureCollection', features: [] }); return }
|
||||
try {
|
||||
const pts = stops.slice(0, 25).map(s => `${s.lon.toFixed(6)},${s.lat.toFixed(6)}`).join(';')
|
||||
const url = `https://api.mapbox.com/directions/v5/mapbox/driving-traffic/${pts}?overview=full&geometries=geojson&access_token=${MAPBOX_TOKEN}`
|
||||
const r = await fetch(url); if (!r.ok) throw new Error('dir ' + r.status)
|
||||
const d = await r.json(); const geom = d.routes && d.routes[0] && d.routes[0].geometry
|
||||
const src = _dayMap && _dayMap.getSource('day-route')
|
||||
if (geom && src) src.setData({ type: 'Feature', geometry: geom, properties: {} })
|
||||
} catch (e) { /* repli : pas de tracé routier (les pins restent visibles) */ }
|
||||
}
|
||||
function destroyDayMap () {
|
||||
if (_dirTimer) { clearTimeout(_dirTimer); _dirTimer = null }
|
||||
if (_dayMapRO) { _dayMapRO.disconnect(); _dayMapRO = null }
|
||||
if (_dayMap) { try { _dayMap.remove() } catch (e) {} _dayMap = null }
|
||||
}
|
||||
// (ré)init à l'ouverture du dialogue (après l'anim) ; refresh débouncé au réordonnancement ; destruction à la fermeture.
|
||||
watch(() => dayEditor.open, (open) => { if (open) nextTick(() => setTimeout(initDayMap, 250)); else destroyDayMap() })
|
||||
watch(() => dayEditor.list.map(j => j.name).join(','), () => { if (_dayMap) { clearTimeout(_dirTimer); _dirTimer = setTimeout(refreshDayMap, 500) } })
|
||||
const dayTotalH = () => Math.round(dayEditor.list.reduce((s, j) => s + (Number(j.dur) || 0), 0) * 10) / 10
|
||||
async function removeFromDay (j) {
|
||||
try { await roster.unassignJobRoster(j.name); dayEditor.list = dayEditor.list.filter(x => x.name !== j.name); await loadWeek(); $q.notify({ type: 'info', message: 'Retiré du tech (retour au pool « à assigner »)', timeout: 2200 }) } catch (e) { err(e) }
|
||||
}
|
||||
async function saveDayOrder () {
|
||||
dayEditor.saving = true
|
||||
const packed = packedDay.value // heures recalculées par la tournée → on les persiste (start_time)
|
||||
const updates = dayEditor.list.map((j, i) => ({ job: j.name, route_order: i + 1, priority: j.priority, duration_h: Number(j.dur) || 1, start_time: fmtHM(packed[i].startMin) }))
|
||||
try { const r = await roster.reorderJobs(updates); dayEditor.open = false; await loadWeek(); $q.notify({ type: 'positive', message: 'Tournée enregistrée — ordre · heures · durées (' + (r.updated || 0) + ')', timeout: 2400 }) } catch (e) { err(e) } finally { dayEditor.saving = false }
|
||||
}
|
||||
const timelineDays = computed(() => {
|
||||
const t = timelineDlg.tech; if (!t) return []
|
||||
const out = []
|
||||
for (const d of dayList.value) {
|
||||
const jobs = cellJobs(t.id, d.iso); const shift = hasReg(t.id, d.iso) || onGarde(t.id, d.iso)
|
||||
const shift = hasReg(t.id, d.iso) || onGarde(t.id, d.iso)
|
||||
const jobs = shift ? cellJobs(t.id, d.iso) : rawCellJobs(t.id, d.iso) // hors quart : jobs bruts
|
||||
if (!jobs.length && !shift) continue // on saute les jours vides
|
||||
const o = cellOcc(t.id, d.iso)
|
||||
out.push({ iso: d.iso, label: d.dow + ' ' + d.dnum, weekend: d.weekend, bands: cellBands(t.id, d.iso), blocks: cellBlocks(t.id, d.iso), jobs, pct: cellPct(t.id, d.iso), usedH: o ? o.usedH : 0 })
|
||||
const usedH = shift ? (o ? o.usedH : 0) : Math.round(jobs.reduce((s, j) => s + (j.dur || 0), 0) * 10) / 10
|
||||
out.push({ iso: d.iso, label: d.dow + ' ' + d.dnum, weekend: d.weekend, bands: cellBands(t.id, d.iso), blocks: cellBlocks(t.id, d.iso), jobs, pct: shift ? cellPct(t.id, d.iso) : null, usedH, offShift: !shift && jobs.length > 0 })
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
|
@ -946,6 +1230,9 @@ function hashColor (label) { let h = 0; for (const c of String(label)) h = (h *
|
|||
const customTags = ref([]) // [{label,color}] créés à la volée (localStorage)
|
||||
function saveCustomTags () { localStorage.setItem('roster-skill-tags-v1', JSON.stringify(customTags.value)) }
|
||||
function getTagColor (label) { const ct = customTags.value.find(x => x.label === label); return (ct && ct.color) || hashColor(label) }
|
||||
// Couleur d'une carte job = COULEUR DE SA COMPÉTENCE (éditable via le gestionnaire de tags → cohérent + simple).
|
||||
// required_skill est renseigné côté hub (skill explicite, sinon déduit du type legacy). Repli : couleur du type.
|
||||
function panelJobColor (j) { return j.required_skill ? getTagColor(j.required_skill) : (legacyDeptColor(j.legacy_dept) || '#90a4ae') }
|
||||
const tagCatalog = computed(() => {
|
||||
const m = new Map()
|
||||
for (const ct of customTags.value) m.set(ct.label, { name: ct.label, label: ct.label, color: ct.color || hashColor(ct.label), category: 'Custom' })
|
||||
|
|
@ -1129,7 +1416,8 @@ function cellBands (techId, iso) {
|
|||
}
|
||||
// Barre de statut OPAQUE selon l'occupation : vert (peu) → orange (plein) → rouge (surbooké).
|
||||
function occColor (pct) { if (pct == null) return '#9e9e9e'; if (pct >= 100) return '#e53935'; const t = Math.max(0, Math.min(1, pct / 100)); return 'hsl(' + Math.round(122 - t * 90) + ',68%,44%)' }
|
||||
function blockStyle (blk, pct) { return { ...pos(blk.s, Math.min(blk.e, 24)), background: occColor(pct) } }
|
||||
// Bloc = 1 job, coloré par la COULEUR DE SA COMPÉTENCE (palette skills éditable). Repli : couleur d'occupation.
|
||||
function blockStyle (blk, pct) { return { ...pos(blk.s, Math.min(blk.e, 24)), background: blk && blk.skill ? getTagColor(blk.skill) : occColor(pct) } }
|
||||
// Fenêtre des shifts (garde=true → seulement les quarts de garde ; garde=false → réguliers)
|
||||
function winOf (techId, iso, garde) { let s = Infinity; let e = -Infinity; for (const a of cellsOf(techId, iso)) { const t = tplByName.value[a.shift]; if (!t || (!!t.on_call) !== garde) continue; const st = hToNum(t.start_time); const en = hToNum(t.end_time); if (st != null) s = Math.min(s, st); if (en != null) e = Math.max(e, en) } return isFinite(s) ? { s, e } : null }
|
||||
const occCells = computed(() => {
|
||||
|
|
@ -1148,6 +1436,9 @@ function hasReg (techId, iso) { return cellsOf(techId, iso).some(a => { const t
|
|||
function cellBlocks (techId, iso) { const o = cellOcc(techId, iso); return o ? o.blocks : [] }
|
||||
function cellPct (techId, iso) { const o = cellOcc(techId, iso); return o ? o.pct : null }
|
||||
function cellJobs (techId, iso) { const o = cellOcc(techId, iso); return o ? (o.jobs || []) : [] } // jobs du jour, déjà triés priorité→heure côté hub
|
||||
function rawCellJobs (techId, iso) { const o = occByTechDay.value[techId + '|' + iso]; return o ? (o.jobs || []) : [] } // jobs BRUTS (inclut les jours SANS quart publié)
|
||||
function offShiftJobs (techId, iso) { return (hasReg(techId, iso) || onGarde(techId, iso)) ? [] : rawCellJobs(techId, iso) } // jobs assignés un jour où le tech n'a AUCUN quart publié
|
||||
const offShiftWeekCount = computed(() => { let n = 0; for (const t of visibleTechs.value) for (const d of dayList.value) n += offShiftJobs(t.id, d.iso).length; return n }) // total jobs hors quart sur la période visible
|
||||
function prioColor (p) { return p === 'urgent' ? '#ef4444' : p === 'high' ? '#f59e0b' : p === 'medium' ? '#6366f1' : '#9e9e9e' }
|
||||
// Aperçu en survol de drop : occupation projetée si on dépose la sélection ici.
|
||||
function isDropTarget (techId, iso) { return dropPreview.key === techId + '|' + iso }
|
||||
|
|
@ -1556,8 +1847,11 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
|
|||
/* Panneau flottant « jobs à assigner » (déplaçable, glisser-déposer) */
|
||||
.assign-panel { position: fixed; z-index: 5000; width: 320px; max-height: 72vh; background: #fff; border: 1px solid #cfd8dc; border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,.24); display: flex; flex-direction: column; }
|
||||
.assign-hdr { display: flex; align-items: center; gap: 5px; padding: 6px 10px; background: #5e35b1; color: #fff; border-radius: 8px 8px 0 0; cursor: move; font-weight: 600; font-size: 13px; user-select: none; }
|
||||
.assign-sortbar { display: flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: 11px; color: #555; background: #f3f0fa; border-bottom: 1px solid #e0e0e0; }
|
||||
.assign-sortbar select { font-size: 11px; border: 1px solid #cfc4e8; border-radius: 5px; padding: 1px 4px; background: #fff; color: #333; flex: 1; }
|
||||
.assign-body { overflow: auto; padding: 5px; }
|
||||
.assign-grp { margin-bottom: 6px; border-radius: 7px; padding: 2px; }
|
||||
.assign-grp-lbl { font-size: 11px; font-weight: 700; color: #37474f; padding: 3px 6px 2px; border-bottom: 1px solid #eee; margin-bottom: 2px; position: sticky; top: 0; background: #fff; z-index: 1; }
|
||||
.assign-grp.grp-hl { background: #ede7f6; box-shadow: inset 0 0 0 1px #b39ddb; } /* groupe lié surligné dès qu'un membre est coché */
|
||||
.assign-grp-hdr { font-size: 10px; font-weight: 700; color: #5e35b1; padding: 2px 6px; cursor: pointer; display: flex; align-items: center; gap: 3px; }
|
||||
.assign-grp-hdr:hover { text-decoration: underline; }
|
||||
|
|
@ -1622,14 +1916,35 @@ tr.res-hidden .hide-eye { opacity: 1; }
|
|||
.cell-dirty-demo { display: inline-block; min-width: 18px; padding: 0 5px; border-radius: 4px; font-weight: 700; font-size: 11px; background: #1976d2; color: #fff; box-shadow: inset 0 0 0 2px #ff9800; }
|
||||
.ch-h { opacity: .7; font-weight: 400; font-size: 9px; margin-left: 1px; }
|
||||
.free { color: #ccc; }
|
||||
.offshift-warn { display: inline-flex; align-items: center; gap: 1px; font-size: 10px; font-weight: 700; color: #ef6c00; cursor: pointer; line-height: 1; } /* job assigné un jour sans quart publié */
|
||||
.hdr-ruler { position: relative; height: 11px; margin-top: 3px; }
|
||||
.hdr-ruler .tick { position: absolute; top: 2px; transform: translateX(-50%); font-size: 8px; color: #aab; line-height: 1; font-weight: 400; }
|
||||
.hdr-ruler .tick::before { content: ''; position: absolute; top: -3px; left: 50%; width: 1px; height: 2px; background: #d0d0d8; }
|
||||
.tl { position: relative; height: 11px; min-width: 64px; background: #f1f3f5; border-radius: 2px; margin: 2px 0; overflow: hidden; }
|
||||
.tl-click { cursor: pointer; } /* clic sur le progressbar → menu jobs (détail + réordonner) */
|
||||
.tl-click:hover { outline: 1px solid #1976d2; outline-offset: 1px; }
|
||||
/* Éditeur de journée (clic progressbar) — lignes draggables */
|
||||
.de-row { display: flex; align-items: center; gap: 8px; padding: 5px 4px; border-bottom: 1px solid #eee; background: #fff; cursor: default; }
|
||||
.de-row.de-drag { opacity: .5; background: #ede7f6; }
|
||||
.de-row:hover { background: #f7f5fc; }
|
||||
.de-ord { font-size: 12px; font-weight: 700; color: #607d8b; min-width: 16px; text-align: center; }
|
||||
.de-dot { width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto; }
|
||||
/* minimap du jour (territoire des arrêts) */
|
||||
.de-map-wrap { margin: 8px 0 4px; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0; }
|
||||
.de-map-gl { width: 100%; height: 240px; }
|
||||
.de-map-cap { font-size: 10px; color: #777; padding: 3px 6px; background: #fafafa; border-top: 1px solid #eee; }
|
||||
.de-prio { font-size: 11px; border: 1px solid #ccc; border-left-width: 4px; border-radius: 4px; padding: 2px 4px; background: #fff; }
|
||||
.de-dur { display: flex; align-items: center; gap: 2px; font-size: 10px; color: #888; }
|
||||
.de-dur input { width: 46px; font-size: 11px; text-align: right; border: 1px solid #cfc4e8; border-radius: 4px; padding: 2px 3px; }
|
||||
.de-travel { font-size: 10px; color: #8a6d3b; padding: 1px 0 1px 40px; opacity: .85; } /* espace entre 2 jobs = transport */
|
||||
.de-detail { font-size: 11px; line-height: 1.4; white-space: pre-wrap; color: #444; background: #f7f5fc; border-left: 3px solid #b39ddb; border-radius: 4px; margin: 0 4px 6px 40px; padding: 6px 8px; max-height: 160px; overflow: auto; }
|
||||
.tl-shift { position: absolute; top: 0; bottom: 0; background: #ccd2d8; border-radius: 2px; border: 1px solid rgba(55,65,120,.5); box-sizing: border-box; } /* fenêtre dispo (contour foncé pour la distinguer du fond) */
|
||||
.tl-shift.oncall { background: rgba(255,179,0,.14); border: 1px dashed #f9a825; } /* garde = sur appel hors heures (pointillé ambre) */
|
||||
.tl-absent { position: absolute; inset: 0; border-radius: 2px; box-sizing: border-box; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg, #cfcfcf 0, #cfcfcf 3px, #f0f0f0 3px, #f0f0f0 6px); } /* absent = hachuré gris */
|
||||
.tl-blk { position: absolute; top: 0; bottom: 0; border-radius: 1px; } /* occupé = barre de statut opaque */
|
||||
.tl-travel { position: absolute; top: 0; bottom: 0; background-image: repeating-linear-gradient(90deg, #78909c 0 3px, transparent 3px 7px); background-size: 100% 3px; background-repeat: no-repeat; background-position: 0 center; opacity: .85; } /* déplacement = pointillés */
|
||||
.tl-blk-click { cursor: pointer; } /* seuls les blocs de job ouvrent l'éditeur ; le reste de la cellule = édition d'horaire */
|
||||
.tl-blk-click:hover { outline: 1px solid rgba(25,118,210,.7); outline-offset: -1px; filter: brightness(1.08); }
|
||||
.tod-leg { display: inline-block; width: 46px; height: 9px; border-radius: 2px; vertical-align: middle; background: linear-gradient(to right, hsl(210,45%,91%), hsl(270,45%,83%)); }
|
||||
.occ-leg { display: inline-block; width: 46px; height: 9px; border-radius: 2px; vertical-align: middle; background: linear-gradient(to right, hsl(122,68%,44%), hsl(32,68%,44%)); }
|
||||
.leg-absent { display: inline-block; width: 24px; height: 9px; border-radius: 2px; vertical-align: middle; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg,#cfcfcf 0,#cfcfcf 3px,#f0f0f0 3px,#f0f0f0 6px); }
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ const routes = [
|
|||
{ path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') },
|
||||
{ path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') },
|
||||
{ path: 'planification', component: () => import('src/pages/PlanificationPage.vue') },
|
||||
{ path: 'conformite-adresses', component: () => import('src/pages/AddressConformityPage.vue') },
|
||||
{ path: 'rdv', component: () => import('src/pages/RendezVousPage.vue') },
|
||||
{ path: 'copilote', component: () => import('src/pages/CopilotePage.vue') },
|
||||
{ path: 'agent-flows', component: () => import('src/pages/AgentFlowsPage.vue') },
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@ export const useDispatchStore = defineStore('dispatch', () => {
|
|||
sourceIssue: j.source_issue || null,
|
||||
dependsOn: j.depends_on || null,
|
||||
jobType: j.job_type || null,
|
||||
legacyDept: j.legacy_dept || null, // département osTicket legacy → coloriage par type
|
||||
legacyTicketId: j.legacy_ticket_id || null, // n° ticket legacy (affiché dans le panneau détail)
|
||||
legacyActivationUrl: j.legacy_activation_url || null, // lien connect_ministra (activation STB TV)
|
||||
legacyDetail: j.legacy_detail || null, // description/contenu du ticket legacy (1er message du fil)
|
||||
parentJob: j.parent_job || null,
|
||||
stepOrder: j.step_order || 0,
|
||||
onOpenWebhook: j.on_open_webhook || null,
|
||||
|
|
|
|||
76
docs/ENGINEERING_PRACTICES.md
Normal file
76
docs/ENGINEERING_PRACTICES.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Best practices — « ne rien échapper » + automatiser au maximum
|
||||
|
||||
Cadre d'ingénierie pour la plateforme TARGO (ERPNext/PG · targo-hub Node · Ops Quasar · PWA tech ·
|
||||
portail Lovable · ponts legacy MariaDB/osTicket/Ministra · intégrations Stripe/Twilio/GenieACS/Forti).
|
||||
Objectif double : **fiabilité (rien ne tombe entre les craques)** + **automatisation closed-loop**.
|
||||
|
||||
> Principe directeur : **toute action inter-systèmes doit être (1) idempotente, (2) observable, (3) réconciliée.**
|
||||
> Toute automatisation doit être **closed-loop** : agir → vérifier le résultat → réessayer/alerter si échec.
|
||||
|
||||
---
|
||||
|
||||
## A. Intégrité des données — « rien ne tombe »
|
||||
1. **Une seule source de vérité par domaine.** Facturation = legacy jusqu'au cutover ; dispatch = ERPNext ; provisioning TV = Ministra. Jamais deux plumes sur la même donnée (cf. analyse Ministra : un 2ᵉ chemin = double facturation).
|
||||
2. **Idempotence partout** : clé naturelle externe sur chaque objet importé/synchronisé (déjà fait : `legacy_ticket_id`). Étendre aux webhooks Stripe (event.id), SMS, provisioning.
|
||||
3. **Pattern Outbox** pour les écritures « locale + appel externe » : écrire l'intention en base d'abord, un worker la pousse et marque `done`/`failed`. Évite « écrit en local mais l'API a échoué » (le risque exact du wizard Ministra).
|
||||
4. **Réconciliation périodique** : un job qui compare source ↔ ERPNext et signale la dérive (ex. tickets `assign_to=3301` côté legacy vs Dispatch Jobs créés ; abonnements Ministra vs `service`). Le pont crée — la réconciliation prouve qu'**aucun n'a été échappé**.
|
||||
5. **Soft-delete + audit trail** : ne jamais perdre une donnée ; tracer qui/quand/quoi (ERPNext le fait nativement ; l'exposer côté hub pour les actions du hub).
|
||||
|
||||
## B. Observabilité — « on SAIT quand ça casse »
|
||||
1. **Logs structurés** (JSON, pino) avec `correlation_id` par requête/job — finis les `console.log`. Un job legacy-sync = une ligne `{job, created, errors, ms}` traçable.
|
||||
2. **`/healthz` + `/readyz`** sur le hub (DB ok ? ERPNext ok ? legacy ok ?) → branchés sur **Uptime-Kuma** (déjà en place pour le réseau).
|
||||
3. **Heartbeat sur CHAQUE tâche planifiée** : le legacy-sync, le PPA cron, les pollers doivent « pinger » à chaque tick ; si un tick manque → alerte. (Un cron muet qui meurt = des tickets non importés sans que personne le sache.)
|
||||
4. **Alerting** : erreurs (Sentry/GlitchTip self-host) + métriques (Prometheus + Grafana, ou au minimum un endpoint `/metrics`). Alertes vers le canal ops existant (Twilio/email).
|
||||
5. **Bannir le `catch {}` muet** : tout catch logge + incrémente un compteur d'erreurs. (Plusieurs `catch (e) {}` actuels avalent des échecs silencieusement.)
|
||||
|
||||
## C. Fiabilité des intégrations
|
||||
1. **Retry + backoff exponentiel** (généraliser `retryWrite` à tous les appels externes Ministra/Twilio/Stripe/GenieACS), avec **plafond** et **dead-letter** (file des échecs définitifs à rejouer).
|
||||
2. **Webhooks idempotents + signés + rejouables** (Stripe a déjà event.id ; vérifier la signature, stocker l'event, rejeu manuel possible).
|
||||
3. **Circuit breaker** sur un service externe en panne (ne pas marteler Ministra/legacy en boucle → respecte aussi fail2ban legacy).
|
||||
4. **Timeouts explicites** sur tous les appels réseau (déjà fait sur la DB legacy).
|
||||
5. **Dégradation gracieuse** : si Ministra est down, le job reste « à activer » et alerte — jamais d'échec silencieux.
|
||||
|
||||
## D. Tests + CI/CD — « le scp manuel doit disparaître »
|
||||
1. **Pipeline Gitea Actions** par push : `lint` → `test` → `build` → deploy auto (staging puis prod sur tag). Remplace les `scp` à la main (source d'erreurs + pas de gate).
|
||||
2. **Tests** par couche : unitaires sur la logique pure (mapping pont, `jobColor`, `firstFitStart`, calculs facturation) ; intégration sur les endpoints hub (avec ERPNext/MariaDB de test) ; quelques E2E (Playwright) sur les parcours critiques (dispatch, booking, checkout).
|
||||
3. **Environnement de staging** iso-prod (« on est en lab » → formaliser un vrai staging séparé de la prod).
|
||||
4. **Migrations versionnées** (les Custom Fields via script idempotent → bon ; versionner toutes les migrations ERPNext + schéma).
|
||||
5. **Rollback en 1 commande** (garder les N derniers bundles ; le déploiement actuel écrase — conserver l'ancien pour rollback instantané).
|
||||
|
||||
## E. Sécurité & secrets
|
||||
1. **Gestion centralisée des secrets** (Vault/Infisical/ou au moins des `.env` chiffrés + rotation) — aujourd'hui dispersés (creds Ministra **en dur dans le PHP legacy**, tokens en `.env`).
|
||||
2. **Moindre privilège** : la DB legacy en SELECT-only (bien) ; appliquer partout. Les liens tech non authentifiés (`reply_ticket.php`, `connect_ministra.php`) → tokens à expiration à terme.
|
||||
3. **Audit d'accès** + 2FA sur les surfaces admin (Authentik déjà là — généraliser).
|
||||
4. **Sauvegardes testées + DR** : backup auto ERPNext PG + MariaDB legacy + restauration testée (un backup jamais restauré n'existe pas).
|
||||
|
||||
## F. Automatisation closed-loop — « le plus possible »
|
||||
1. **Événementiel > polling** quand possible : le pont legacy poll 15 min → ajouter un trigger côté legacy (webhook/INSERT trigger → ping hub) pour le quasi-temps-réel. Garder le poll comme filet de sécurité.
|
||||
2. **Auto-dispatch** : le solveur roster (déjà là) propose ; viser l'assignation auto des jobs simples + suggestion classée pour le reste (proximité quand la géo arrivera).
|
||||
3. **Réconciliation auto-corrective** (self-healing) : un job qui re-crée/re-aligne ce qui a dérivé, plutôt qu'un simple rapport.
|
||||
4. **Moteur de flux** : `agent-flows` existe déjà → en faire le squelette des automatisations métier (relance, escalade SLA, activation, avis client) plutôt que du code ad hoc dispersé.
|
||||
5. **SLA monitors** : alerte si un job reste non-assigné > X h, un ticket TV non-activé > X j, un paiement non réconcilié, un client hors quart (déjà le badge ⏰).
|
||||
6. **AI ciblée** (déjà : OCR, copilote, outage) → étendre : matching compétence↔type de cas (historique), triage/priorisation des tickets, détection d'anomalies de facturation.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap priorisée (incrémental, sans tout casser)
|
||||
|
||||
**Quick wins (jours) — fiabilité immédiate**
|
||||
- [ ] `/healthz` + heartbeat du legacy-sync → Uptime-Kuma + alerte si tick manqué.
|
||||
- [ ] Logs structurés + suppression des `catch {}` muets (au moins compter + logguer).
|
||||
- [ ] Endpoint `/dispatch/legacy-sync/reconcile` : compte legacy(3301,open) vs Dispatch Jobs → signale les manquants.
|
||||
- [ ] Conserver le bundle N-1 au déploiement (rollback instantané).
|
||||
|
||||
**Court terme (semaines) — arrêter l'érosion**
|
||||
- [ ] Pipeline Gitea Actions (lint+build+deploy) → fin du scp manuel + staging réel.
|
||||
- [ ] Premiers tests unitaires sur la logique pure (pont, jobColor, firstFitStart, facturation).
|
||||
- [ ] Retry+backoff+dead-letter généralisés (Ministra/Twilio/Stripe).
|
||||
- [ ] Sentry/GlitchTip self-host + alerting.
|
||||
|
||||
**Stratégique (mois) — automatiser & sécuriser**
|
||||
- [ ] Outbox + réconciliation auto-corrective sur les ponts.
|
||||
- [ ] Événementiel (triggers legacy → hub) + SLA monitors via agent-flows.
|
||||
- [ ] Gestion centralisée des secrets + rotation + DR testé.
|
||||
- [ ] Cutover progressif des sources de vérité (legacy → ERPNext), domaine par domaine.
|
||||
|
||||
> Règle d'or pour « ne rien échapper » : **si une action peut échouer silencieusement, elle DOIT être (a) réessayée, (b) mise en dead-letter, et (c) visible sur un tableau de bord.** Aucune exception.
|
||||
129
docs/architecture/VISION.md
Normal file
129
docs/architecture/VISION.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# Vision, modularisation & optimisation — Plateforme TARGO / Gigafibre
|
||||
|
||||
> Document stratégique. Complète `overview.md`, `app-design.md`, `data-model.md`, `module-interactions.md`
|
||||
> et `../ENGINEERING_PRACTICES.md`. Objectif : passer d'une croissance organique (god-files, modules plats)
|
||||
> à une architecture **modulaire par domaine** avec des **sources de vérité** fiables — pour livrer vite ET sûr.
|
||||
|
||||
## 0. Vision en une phrase
|
||||
Un **hub d'orchestration léger** (Node) au-dessus d'**ERPNext** (ERP/source de données), organisé en
|
||||
**domaines métier autonomes** (bounded contexts), chacun avec sa **source de vérité canonique**, sa
|
||||
**validation à la saisie** et ses **liens stables** — pendant qu'on **éteint progressivement le legacy**.
|
||||
|
||||
---
|
||||
|
||||
## 1. État actuel (audit chiffré, 2026-06-07)
|
||||
|
||||
| Composant | Volume | Plus gros fichiers (god-files) |
|
||||
|---|---|---|
|
||||
| **targo-hub** (Node) | 58 modules, ~23 000 lignes, 52 routes (lazy-require) | campaigns 2366 · payments 1376 · network-intel 1221 · contracts 1091 · roster 1040 · tech-mobile 961 · dispatch 852 |
|
||||
| **Ops SPA** (Vue3/Quasar) | ~45 000 lignes | ProjectWizard 2891 · DispatchPage 2162 · PlanificationPage 1863 · NetworkPage 1829 · ClientDetailPage 1715 |
|
||||
|
||||
**Forces** : hub mono-process simple (routage `path.startsWith` + `require` paresseux), ERPNext comme socle
|
||||
données, frappe_pg (PostgreSQL), intégrations riches (legacy MariaDB, Ministra, GenieACS, OLT SNMP, Stripe,
|
||||
Mailjet, Authentik, Karrio), base d'adresses RQA **locale** (5,24 M) déjà branchée.
|
||||
|
||||
**Dettes techniques** (priorisées plus bas) :
|
||||
1. **God-files** : 5 fichiers front >1700 lignes, 2 back >1300 — mélangent UI/état/règles métier/I/O.
|
||||
2. **Modules plats sans frontière de domaine** : 58 fichiers `lib/*.js` au même niveau ; couplage implicite.
|
||||
3. **Helpers réimplémentés** : `norm()` (accents), `cors()`, `haversine()`, `stripHtml()` dupliqués dans
|
||||
~6 modules (address-*, legacy-dispatch-sync, roster, serviceability, tech-absence-sms). *(cors/pool : consolidés ✅)*
|
||||
4. **Pas de tests** ni de **CI/CD** (déploiement = `scp` manuel + `docker restart`).
|
||||
5. **Observabilité inégale** : `catch {}` muets par endroits ; heartbeat/réconciliation présents seulement
|
||||
sur le pont legacy. `erp.create/update` ne *throw* pas → des erreurs ont déjà été comptées comme succès.
|
||||
|
||||
---
|
||||
|
||||
## 2. Principe directeur : Domaines + Source de vérité
|
||||
|
||||
Deux règles structurent toute la suite :
|
||||
|
||||
**(A) Découper par DOMAINE (bounded context), pas par fichier.** Chaque domaine expose une interface
|
||||
publique étroite (un `index` + des services) ; le reste est interne. Le routage du hub délègue au domaine.
|
||||
|
||||
**(B) Chaque entité a UNE source de vérité + validation à la saisie + lien stable.** On ne re-cherche pas
|
||||
avec un processus complexe à chaque fois : on **résout une fois**, on **persiste le lien canonique**, le
|
||||
downstream **lit le lien**. Modèle de référence : les **adresses** (cf. `reference/` + page Conformité) —
|
||||
`rqa_addresses` (RQA local) ← `aq_address_id`/`linked_address`/`address_validation_status` sur Service Location.
|
||||
|
||||
---
|
||||
|
||||
## 3. Carte des domaines cible
|
||||
|
||||
Regroupement des 58 modules hub + pages Ops en **9 domaines**. (Refactor par déplacement progressif, pas big-bang.)
|
||||
|
||||
| Domaine | Hub (lib/…) | Ops (pages/modules) | Source de vérité |
|
||||
|---|---|---|---|
|
||||
| **Identité & Accès** | auth, (Authentik) | usePermissions, MainLayout | Authentik + Capabilities |
|
||||
| **CRM / Clients** | store, **address-db/validate/conformity**, address-search | Clients, ClientDetail | Customer (ERPNext) · **Adresse = RQA** |
|
||||
| **Dispatch & Terrain** | dispatch, roster, tech-mobile, legacy-dispatch-sync, (geo/routing Mapbox) | Dispatch, Planification, RendezVous, module tech | Dispatch Job · Shift/Roster |
|
||||
| **Réseau & Infra** | network-intel, olt-snmp, devices, outage-monitor, oktopus, (genieacs) | Network | GenieACS (CPE) · OLT · device |
|
||||
| **Facturation & Paiements** | payments, contracts, acceptance, store/checkout | Rapports, ContratBLB | **Legacy billing (autoritaire jusqu'au cutover)** → ERPNext |
|
||||
| **Marketing & Campagnes** | campaigns, offers, ai (traduction) | module campaigns | Campaign · Gift (Giftbit) |
|
||||
| **IA & Agent** | ai, agent, voice-agent, flow-runtime, conversation | Copilote, AgentFlows | Agent flows |
|
||||
| **Intégrations & Legacy** | erp, legacy DB, Ministra, Karrio, Giftbit | — | Adaptateurs (anti-corruption layer) |
|
||||
| **Plateforme** | helpers, config, observabilité | composables partagés, components/shared | — |
|
||||
|
||||
**Cible d'arborescence hub** (itératif) : `lib/<domaine>/<module>.js` + `lib/<domaine>/index.js` (interface
|
||||
publique) ; `lib/util/` (norm, cors, geo, html) ; `server.js` route vers `require('./lib/<domaine>')`.
|
||||
|
||||
**Cible Ops** : `src/modules/<domaine>/` (pages + composants + composables + api du domaine), comme
|
||||
`modules/campaigns` et `modules/tech` le font déjà — étendre ce pattern à dispatch, roster, clients, network.
|
||||
|
||||
---
|
||||
|
||||
## 4. Optimisations priorisées
|
||||
|
||||
### P0 — Hygiène (sûr, rapide, vérifiable au build)
|
||||
- **Extraire les helpers partagés** dans `lib/util/` : `norm` (accents), `cors` ✅, `haversineKm`, `stripHtml`,
|
||||
`tzDate`. Remplacer les ~6 réimplémentations. *(cors + pool address déjà consolidés.)*
|
||||
- **Uniformiser le contrat handler** : `erp.create/update` renvoient `{ok}` → tout appelant DOIT vérifier
|
||||
`r.ok` (déjà corrigé dans le pont ; auditer payments/contracts/store).
|
||||
- **Supprimer le code mort** + `catch {}` muets → `log()`.
|
||||
- **Boundary I/O** : un seul client `pg` partagé pour les lectures locales (address-db.pool, réutilisé ✅).
|
||||
|
||||
### P1 — Décomposition des god-files (par domaine, sans changer le comportement)
|
||||
- Front : `ProjectWizard` (2891), `DispatchPage` (2162), `PlanificationPage` (1863), `ClientDetailPage` (1715)
|
||||
→ extraire des **composables** (`use*`) + **sous-composants** (détail/sections). PlanificationPage : sortir
|
||||
packedDay/route-planner, le panneau d'assignation, l'éditeur de journée en composables dédiés.
|
||||
- Back : `campaigns` (2366) et `payments` (1376) → sous-modules de domaine (envoi, matching, suivi / facturation, rapprochement).
|
||||
|
||||
### P2 — Fiabilité & vélocité
|
||||
- **Tests** (vitest) sur les modules à risque : paiements, pont legacy, conformité adresses, roster solver.
|
||||
- **CI/CD** : remplacer `scp + docker restart` manuel par un pipeline (Gitea Actions) build+déploiement+santé.
|
||||
- **Observabilité closed-loop** : généraliser le pattern `_lastRun`/`/status` + réconciliation (déjà sur le
|
||||
pont) aux jobs critiques ; clés Uptime-Kuma.
|
||||
|
||||
---
|
||||
|
||||
## 5. Source de vérité — généralisation du pattern « Adresses »
|
||||
|
||||
| Entité | Source de vérité | Lien stable | État |
|
||||
|---|---|---|---|
|
||||
| **Adresse** | `rqa_addresses` (RQA local) + `fiber_availability` | `aq_address_id` + `linked_address` | ✅ FAIT (16 561 liées ; page Conformité) |
|
||||
| **Client** | ERPNext Customer | `legacy_account_id` | partiel (matching legacy) |
|
||||
| **Appareil/CPE** | GenieACS (serial/MAC réels) | MAC ↔ Service Equipment | piège connu (TPLG vs serial réel) |
|
||||
| **Service TV** | Ministra (SID = id ligne `service` legacy) | `legacy_activation_url` | read-only (pas de 2e chemin d'écriture) |
|
||||
| **Facturation** | **Legacy (autoritaire)** jusqu'au cutover | — | scheduler ERPNext en pause |
|
||||
|
||||
Prochaine application directe : finir le matching **Client** (legacy_account_id) et **Device** (MAC↔GenieACS)
|
||||
sur le même modèle (résoudre une fois → persister → lire le lien).
|
||||
|
||||
---
|
||||
|
||||
## 6. Roadmap par phases
|
||||
|
||||
- **Phase 1 — Hygiène & fondations** : helpers partagés, code mort, `r.ok` partout, CI/CD minimal, tests des
|
||||
modules critiques. *(Risque faible, gros gain de fiabilité.)*
|
||||
- **Phase 2 — Modularisation** : déplacer hub `lib/*` → `lib/<domaine>/` ; décomposer les god-files front/back.
|
||||
- **Phase 3 — Sources de vérité** : généraliser (Client, Device, Service) + observabilité closed-loop partout.
|
||||
- **Phase 4 — Vision produit** : cutover facturation legacy→ERPNext ; app terrain Capacitor (GPS live des
|
||||
unités, remplace le relevé manuel sur la carte) ; portail client self-service (abonnement + RDV + paiement).
|
||||
|
||||
---
|
||||
|
||||
## 7. Métriques de succès
|
||||
- 0 fichier > 800 lignes (front et back).
|
||||
- 0 helper dupliqué (`norm`/`cors`/`geo`/`html` centralisés).
|
||||
- 100 % des entités clés avec source de vérité + validation à la saisie + lien persisté.
|
||||
- Déploiement par pipeline (0 `scp` manuel) ; tests verts sur les modules critiques.
|
||||
- Tout job inter-systèmes : idempotent + observable + réconcilié (cf. ENGINEERING_PRACTICES).
|
||||
|
|
@ -8,6 +8,7 @@ modes. Open the one that matches the feature you're changing.
|
|||
|---|---|
|
||||
| [dispatch.md](dispatch.md) | Ops dispatch board: drag-and-drop scheduling, tech assignment with skill tags, travel-time optimization, magic-link SMS issuance, live SSE updates |
|
||||
| [roster.md](roster.md) | Planification (Roster AI): grille hebdo ressources × jours, garde live, solveur OR-Tools, scoring priorité (maîtrise⊕vitesse⊕coût), panneau « jobs à assigner » (drag-drop + aperçu occupation), timeline ressource, dialogues d'impact, booking roster-aware |
|
||||
| [legacy-dispatch-bridge.md](legacy-dispatch-bridge.md) | Pont legacy→dispatch: tire régulièrement les tickets osTicket « Tech Targo » (staff 3301) de la MariaDB legacy → Dispatch Job ERPNext (idempotent via `legacy_ticket_id`), mapping client/Service Location/type/date, endpoints preview/run, scheduler opt-in |
|
||||
| [tech-mobile.md](tech-mobile.md) | Field tech app (three surfaces: SSR `/t/{jwt}`, transitional `apps/field/`, unified `/ops/#/j/*`). Native camera → Gemini scanner, equipment install/remove, JWT auth, offline queue |
|
||||
| [customer-portal.md](customer-portal.md) | Passwordless customer self-service at `portal.gigafibre.ca`: magic-link email (24h JWT), invoice + ticket view, Stripe-linked payment flows |
|
||||
| [billing-payments.md](billing-payments.md) | Stripe integration (Checkout, Billing Portal, webhook), subscription lifecycle, invoice generation, payment reconciliation, PPA (Plan de paiement automatique), Klarna BNPL |
|
||||
|
|
|
|||
112
docs/features/legacy-dispatch-bridge.md
Normal file
112
docs/features/legacy-dispatch-bridge.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# Pont legacy → Dispatch (osTicket → Dispatch Job) — Handoff dev
|
||||
|
||||
Tire **régulièrement** les tickets ouverts assignés au compte « Tech Targo » dans la
|
||||
DB legacy (osTicket/MariaDB `gestionclient`) et crée/maj un **Dispatch Job** ERPNext
|
||||
pour les répartir (grille Planification / tableau Dispatch).
|
||||
|
||||
## Pourquoi « Tech Targo » = staff id 3301
|
||||
Dans le legacy, le travail terrain à dispatcher est assigné au compte générique
|
||||
**« Tech Targo »** (`staff.id = 3301`, username `tech`) — c'est le `default_staff` des
|
||||
départements techniciens (Installation/Réparation/Fibre). Filtre du pont :
|
||||
`ticket.status='open' AND ticket.assign_to=3301`. (~70 tickets au démarrage.)
|
||||
Override possible via `LEGACY_TARGO_STAFF_ID`.
|
||||
|
||||
## Surfaces
|
||||
| Quoi | Où |
|
||||
|---|---|
|
||||
| Module | `services/targo-hub/lib/legacy-dispatch-sync.js` |
|
||||
| Routage + scheduler | `services/targo-hub/server.js` (`/dispatch/legacy-sync`, `startSync()` au boot) |
|
||||
| Champ idempotence | Custom Field `Dispatch Job.legacy_ticket_id` (`dispatch-app/frappe-setup/setup_dispatch_custom_fields.py`) |
|
||||
| Conso côté UI | Pool « à assigner » du tableau Dispatch + panneau « Jobs à assigner » de la Planification |
|
||||
|
||||
## Mapping ticket legacy → Dispatch Job
|
||||
| Dispatch Job | Source legacy | Notes |
|
||||
|---|---|---|
|
||||
| `legacy_ticket_id` | `ticket.id` | **clé d'idempotence** (lookup avant create) ; affiché dans le panneau détail |
|
||||
| `legacy_dept` | `ticket_dept.name` | département granulaire → **coloriage des cartes « comme legacy »** |
|
||||
| `ticket_id` | `'LEG-' + ticket.id` | nom lisible du DJ |
|
||||
| `subject` | `ticket.subject` | + adresse ajoutée si pas de Service Location |
|
||||
| `customer` | `Customer` où `legacy_account_id = ticket.account_id` | 61/70 matchés ; sinon laissé vide |
|
||||
| `service_location` + `latitude`/`longitude` | `Service Location` du customer (ville qui matche) | → pin carte |
|
||||
| `job_type` | `ticket.dept_id` → {Installation, Réparation, Retrait, Autre} | valeurs valides du Select |
|
||||
| `scheduled_date` | `ticket.due_date` (epoch) | converti **America/Toronto** (anti-décalage UTC) |
|
||||
| `start_time` | `ticket.due_time` | `HH:MM` tel quel · `am`→08:00 · `pm`→13:00 · `day`→aucune |
|
||||
| `priority` | `ticket.priority` (1/2/≥3) | → low / medium / high |
|
||||
| `duration_h` | défaut par type | Install 2h · Répar 1.5h · autre 1h (le legacy n'en a pas) |
|
||||
| `status` | — | toujours `open` (pool ; PAS auto-assigné à un tech précis) |
|
||||
|
||||
## Comportement
|
||||
- **Idempotent** : 1 ticket legacy = 1 Dispatch Job (clé `legacy_ticket_id`). Re-run ⇒ 0 doublon.
|
||||
- **Ne clobbe PAS le répartiteur** : un DJ déjà assigné/déplacé n'est plus touché ; maj de `scheduled_date`
|
||||
seulement tant qu'il est encore `open` + non assigné.
|
||||
- **SÉQUENTIEL** (frappe_pg ne supporte pas la concurrence) — pas de `Promise.all`.
|
||||
|
||||
## Coloriage des cartes (comme legacy)
|
||||
`jobColor()` (`apps/ops/src/composables/useHelpers.js` → `legacyDeptColor`) colore par `legacyDept` :
|
||||
Installation(Fibre)/Monteur/Fusionneur → **vert** `#46992f` · Réparation(Fibre) → **or** `#f1c84b` ·
|
||||
Télé (install/réparation) → **rose** `#ec5fb0` · Téléphonie → **vert pâle** `#8fce93` ·
|
||||
Désinstallation → **rouge foncé** `#c0392b`. (Tech en pause → rouge vif, priorité.) Le store
|
||||
(`_mapJob`) expose `legacyDept`/`legacyTicketId` ; le pool « non-assigné » est déjà trié par `scheduledDate`.
|
||||
|
||||
## Endpoints
|
||||
- `GET /dispatch/legacy-sync/preview` — **dry-run, 0 écriture** : ce qui serait créé + matching client/SL + non-matchés.
|
||||
- `POST /dispatch/legacy-sync/run` — exécute la synchro (création/maj). Retourne `{tickets, created, updated, skipped, errors, unmatched_customer}`.
|
||||
|
||||
## Récurrence
|
||||
`startSync()` (server.js, au boot) — **opt-in** via env :
|
||||
```
|
||||
LEGACY_DISPATCH_SYNC=on # active la récurrence (sinon preview/run manuels seulement)
|
||||
LEGACY_DISPATCH_SYNC_MIN=15 # période en minutes (défaut 15)
|
||||
```
|
||||
Posé dans `/opt/targo-hub/.env`. ⚠️ Après modif de `.env`, **recréer** le conteneur
|
||||
(`cd /opt/targo-hub && docker compose up -d`) — `docker restart` ne relit pas l'env_file.
|
||||
1er passage différé de 90 s après le boot, puis toutes les `MIN` minutes.
|
||||
|
||||
## État (mise en service 2026-06-06)
|
||||
70 tickets importés (0 erreur, 9 clients non matchés = comptes post-migration + 2 tickets internes
|
||||
« FORMATION EN HAUTEUR »). Récurrence active (15 min).
|
||||
|
||||
## Coordonnées GPS & routage routier réel (2026-06-06 f)
|
||||
Le pont importe des **coordonnées fiables** par job (pour le routage routier réel dans l'éditeur de
|
||||
tournée). Cascade de sources, de la plus précise à la plus large :
|
||||
1. **`delivery` legacy** (point de service exact, via `ticket.delivery_id → delivery.latitude/longitude`)
|
||||
— JOIN ajouté à `fetchTargoTickets`. Source de référence ; on préfère aussi l'**adresse de service**
|
||||
(`delivery.address1/city/zip`) à l'adresse de facturation du compte.
|
||||
2. **Service Location ERPNext** (coords du client matché) — repli.
|
||||
3. **Géocodage RQA via recherche TRIGRAM** (`address-search.searchAddressesRpc` → RPC Postgres `search_addresses`,
|
||||
`pg_trgm`) — **la même recherche que l'autocomplete de disponibilité fibre** (phase 1 = numéro civique + mots
|
||||
de rue sur odonyme normal/court/**long**[avec générique]/municipalité/CP ; phase 2 = trigram complet ; priorise
|
||||
les CP J0L/J0S = territoire). Bien plus robuste que l'ancien ilike (qui manquait « René-Vinet », générique absent
|
||||
de `odonyme_recompose_normal`). **Garde-fou anti-faux-positif** (la phase 2 trigram dérive quand le civique est
|
||||
absent du RQA, ex. « 2245 René-Vinet » → « Rue Grenet, Montréal ») : on n'accepte un résultat que si le **civique
|
||||
concorde** + au moins **un token de nom de rue** correspond + (**territoire J0L/J0S** OU CP/ville legacy concordants).
|
||||
4. **Géocodage Mapbox** (`MAPBOX_TOKEN`, clé publique) — couvre ce que le RQA n'a pas (rues neuves, civiques absents).
|
||||
Contraint au Québec (`country=ca` + proximity + bornes `coord()`).
|
||||
|
||||
Validation `coord()` : bornes Québec (lat 44→63, lon −80→−57) → rejette 0/0 et placeholders. Backfill
|
||||
**+ UPGRADE** : sur un job existant, on remplit les coords absentes ET on **écrase** des coords Service
|
||||
Location moins précises par les coords `delivery` (point exact) — jamais l'inverse. Caches géocodage au
|
||||
niveau module (1 appel par adresse / vie du hub ; échecs mémorisés). Couverture : **~109/125 tickets (87 %)**
|
||||
— `coord_src` (run courant) : delivery 26 · SL 38 · RQA-trigram 8 · Mapbox 37 · aucune 16. Le faible compte
|
||||
RQA = **haute précision** (l'ancien ilike comptait 17 mais avec des faux positifs hors-rue/hors-ville) ; les
|
||||
« aucune » = adresses réellement absentes (campings « Lac des Pins », villes mal orthographiées « Franlkin »).
|
||||
|
||||
**Routage routier réel (Ops → Planification → éditeur de journée)** : `loadDayRoute()` appelle l'**API
|
||||
Mapbox Matrix** une fois à l'ouverture (toutes les durées routières d'un coup) → `travelBetween()` retourne
|
||||
le temps RÉEL ; le réordonnancement réutilise la matrice **sans nouvelle requête**. Repli haversine
|
||||
(40 km/h) si Mapbox indispo. Indicateur 🚗 (réel) vs 📏 (vol d'oiseau).
|
||||
|
||||
## Robustesse (2026-06-06 f)
|
||||
- **Comptabilité honnête** : `erp.create/update` ne *throw* pas (renvoient `{ok:false,error}`) → le pont
|
||||
vérifie `r.ok` (sinon `errors++` + `error_samples` dans le résumé). Avant : creates échoués comptés réussis.
|
||||
- **Verrou de sérialisation** sur `sync()` : tick récurrent + runs manuels ne se chevauchent JAMAIS
|
||||
(frappe_pg sans concurrence → sinon « socket hang up » + écritures perdues dans un rollback).
|
||||
- **Subject tronqué à 140** (champ `Data` Frappe) : les jobs sans Service Location ajoutaient l'adresse au
|
||||
sujet → `CharacterLengthExceededError`. Détail complet conservé dans `legacy_detail`/coords.
|
||||
- Env : `MAPBOX_TOKEN` ajouté à `/opt/targo-hub/.env` (clé publique `pk.`) → recréer le conteneur.
|
||||
|
||||
## TODO / idées
|
||||
- Matcher les clients non matchés (créer le Customer / enrichir `legacy_account_id`) → réduit les 15 « aucune coord ».
|
||||
- Géoloc live du tech (Capacitor `transistorsoft/capacitor-background-geolocation`) → 1er point de la tournée.
|
||||
- Filtrer les départements non-terrain (ToDo, Support Informatique, Conception…) si bruit.
|
||||
- Écrire en retour le tech assigné / la date vers le legacy (bidirectionnel) — non fait (lecture seule legacy).
|
||||
|
|
@ -76,11 +76,21 @@ réutilisé par `assign-job` **et** `backfill-start-times`) → `bookingSlots` /
|
|||
- **Déploiement** : front via `apps/ops/deploy.sh` (`DEPLOY_BASE=/ops/ quasar build` + tar/scp → `/opt/ops-app/`) ;
|
||||
hub via `scp lib/roster.js → /opt/targo-hub/lib/` + `docker restart targo-hub`. Custom fields via le script frappe-setup.
|
||||
|
||||
## Fait récemment
|
||||
|
||||
- ✅ **On Hold** : le dépôt d'un job en attente d'un prérequis est REFUSÉ (notify), plus seulement 🔒 visuel (`onCellDrop`).
|
||||
- ✅ **Alerte hors quart** : un job assigné un jour où le tech n'a aucun quart publié → marqueur ⚠ dans la cellule libre
|
||||
(`offShiftJobs`) + badge « hors quart publié » dans la timeline. (Le backfill ignore ces jobs : pas de bande à remplir.)
|
||||
- ✅ **Deep-link Dispatch** : Planification → `gotoDispatch(tech)` ouvre `/dispatch?tech=&date=` ; DispatchPage lit `route.query`
|
||||
(`goToDay(date+'T12:00:00')` anti-décalage tz + `selectTechOnBoard`).
|
||||
- ✅ **#58 Aviser le client** : bouton « Désaffecter + aviser le client » dans le dialogue d'unassign Dispatch →
|
||||
`roster.notifyReschedule` (désassigne serveur + SMS lien /book au mobile du Customer).
|
||||
|
||||
## TODO / dette
|
||||
|
||||
- Bloquer réellement (≠ 🔒 visuel) le dépôt d'un job « On Hold » avant son prérequis.
|
||||
- Brancher la **proximité** (lat/lng Service Location ↔ base/secteur du tech) dans `techProximity`.
|
||||
- Deep-link Dispatch filtré tech/date (DispatchPage ne lit pas encore `route.query`).
|
||||
- Brancher la **proximité** (lat/lng Service Location ↔ base/secteur du tech) dans `techProximity` (hook neutre en place).
|
||||
- Apprentissage IA compétence ↔ type de cas (historique des jobs).
|
||||
- `#57` Hold côté client sur `/book` (Lovable) : bloquer la plage à la sélection.
|
||||
- Compteur global « N jobs hors quart cette semaine » dans la barre d'outils (complément du marqueur ⚠).
|
||||
- **Refactor** : `PlanificationPage.vue` (~1,5k lignes) gagnerait à extraire des composables
|
||||
(`useGarde`, `useOccupancy`, `useAssignPanel`, `useSkillEditor`) — non fait (risque sur fichier prod).
|
||||
|
|
|
|||
19
scripts/migration/camping_dispatch_backfill.sql
Normal file
19
scripts/migration/camping_dispatch_backfill.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
-- camping_dispatch_backfill.sql — applique la géoloc FIXE du camping aux Dispatch Jobs DÉJÀ dispatchés.
|
||||
-- Le pont (legacy-dispatch-sync) ne re-traite que les tickets encore « ouverts + assign_to=3301 » ; les jobs
|
||||
-- déjà assignés/fermés gardent leurs vieilles coords (résidence). Ce backfill corrige tous les jobs issus du
|
||||
-- pont dont le SUJET désigne un camping (mots-clés de LIEU sûrs ; pour 'dauphinais', exige aussi « camping »).
|
||||
-- Idempotent (ne touche que ceux dont la coord diffère). Match via camping_registry.
|
||||
\timing on
|
||||
BEGIN;
|
||||
WITH applied AS (
|
||||
UPDATE "tabDispatch Job" dj SET latitude = c.latitude, longitude = c.longitude, modified = NOW()
|
||||
FROM camping_registry c
|
||||
WHERE dj.legacy_ticket_id <> '' AND c.active
|
||||
AND lower(unaccent(coalesce(dj.subject, ''))) LIKE '%' || c.keyword || '%'
|
||||
AND ( c.keyword IN ('lac des pins','lac de pins','sandysun','sandy sun','frontiere','ensoleill')
|
||||
OR lower(unaccent(coalesce(dj.subject, ''))) LIKE '%camping%' )
|
||||
AND (dj.latitude IS NULL OR abs(coalesce(dj.latitude,0) - c.latitude) > 1e-4 OR abs(coalesce(dj.longitude,0) - c.longitude) > 1e-4)
|
||||
RETURNING c.name AS camping
|
||||
)
|
||||
SELECT camping, count(*) AS jobs FROM applied GROUP BY camping ORDER BY 2 DESC;
|
||||
COMMIT;
|
||||
54
scripts/migration/camping_registry.sql
Normal file
54
scripts/migration/camping_registry.sql
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
-- camping_registry.sql — registre des CAMPINGS + géoloc de remplacement FIXE sur tous leurs lots.
|
||||
--
|
||||
-- Problème : pour un lot de camping, l'adresse du Service Location est souvent l'adresse de RÉSIDENCE du
|
||||
-- client (ex. « 428 Rue … ») et la rue interne du camping (ex. « 2 rue Canard, Lac des Pins ») n'existe pas
|
||||
-- dans le RQA → géoloc fausse. Solution : une coordonnée FIXE = l'adresse principale du camping (le tech
|
||||
-- navigue jusqu'au camping, puis trouve le terrain sur place). Data-driven (table) → réutilisable + éditable.
|
||||
--
|
||||
-- Idempotent : CREATE IF NOT EXISTS + seed WHERE NOT EXISTS + apply re-jouable. Match par VILLE normalisée.
|
||||
\timing on
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS camping_registry (
|
||||
id serial PRIMARY KEY,
|
||||
keyword text NOT NULL UNIQUE, -- motif (minuscule, sans accent) cherché dans la ville du Service Location
|
||||
name text NOT NULL, -- nom du camping
|
||||
address text, -- adresse principale (référence affichée)
|
||||
latitude double precision NOT NULL,
|
||||
longitude double precision NOT NULL,
|
||||
active boolean DEFAULT true,
|
||||
created_at timestamp DEFAULT now()
|
||||
);
|
||||
|
||||
-- Seed (coords = adresse principale, relevées dans le legacy delivery). Plusieurs motifs → même camping/coords.
|
||||
INSERT INTO camping_registry (keyword, name, address, latitude, longitude)
|
||||
SELECT * FROM (VALUES
|
||||
('lac des pins', 'Camping Lac des Pins', '3625 Route 201, Saint-Antoine-Abbé J0S 1N0', 45.062428, -73.911331),
|
||||
('lac de pins', 'Camping Lac des Pins', '3625 Route 201, Saint-Antoine-Abbé J0S 1N0', 45.062428, -73.911331),
|
||||
('dauphinais', 'Camping Le Domaine Dauphinais', '199 Route 219 Sud, Hemmingford J0L 1H0', 45.023808, -73.608696),
|
||||
('sandysun', 'Camping du Lac SandySun', '1935A Chemin Grimshaw, Franklin J0S 1E0', 45.048569, -73.916870),
|
||||
('sandy sun', 'Camping du Lac SandySun', '1935A Chemin Grimshaw, Franklin J0S 1E0', 45.048569, -73.916870),
|
||||
('frontiere', 'Camping Frontière', '474 Chemin Covey Hill, Havelock J0S 2C0', 45.018734, -73.761551),
|
||||
('ensoleill', 'Camping Domaine Ensoleillé', '524 Rang St-Paul, Saint-Rémi J0L 2L0', 45.267612, -73.600517)
|
||||
) v(keyword,name,address,latitude,longitude)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM camping_registry c WHERE c.keyword = v.keyword);
|
||||
|
||||
-- Application : force la géoloc du camping sur tous les lots (match ville normalisée). Conserve address_line
|
||||
-- (le n° de terrain/rue interne reste visible) ; linked_address = le camping ; statut = validated (coord de service OK).
|
||||
WITH applied AS (
|
||||
UPDATE "tabService Location" sl SET
|
||||
latitude = c.latitude,
|
||||
longitude = c.longitude,
|
||||
linked_address = c.name || ' — ' || COALESCE(c.address, ''),
|
||||
aq_address_id = NULL,
|
||||
address_validation_status = 'validated',
|
||||
address_validated_at = NOW(),
|
||||
modified = NOW()
|
||||
FROM camping_registry c
|
||||
WHERE c.active
|
||||
AND lower(unaccent(coalesce(sl.city, ''))) LIKE '%' || c.keyword || '%'
|
||||
RETURNING c.name AS camping
|
||||
)
|
||||
SELECT camping, count(*) AS lots_corriges FROM applied GROUP BY camping ORDER BY 2 DESC;
|
||||
|
||||
COMMIT;
|
||||
51
scripts/migration/normalize_service_locations.sql
Normal file
51
scripts/migration/normalize_service_locations.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
-- normalize_service_locations.sql — « rendre conformes » les adresses de service ERPNext via la base
|
||||
-- LOCALE rqa_addresses (Adresses Québec), sans détruire l'adresse originale (table de translation embarquée).
|
||||
--
|
||||
-- Pour chaque Service Location : on cherche la correspondance RQA par CODE POSTAL + NUMÉRO (clé sélective,
|
||||
-- indexée), on choisit la meilleure rue par similarité désaccentuée, puis on remplit :
|
||||
-- aq_address_id = rqa_addresses.id (clé de translation locale) [⚠ id LOCAL, pas l'uuid AQ officiel]
|
||||
-- linked_address = adresse canonique conforme (address_full)
|
||||
-- address_validation_status = validated (sim≥0.20) | review (0<sim<0.20) | unmatched (aucun match)
|
||||
-- latitude/longitude RAFFINÉES seulement si validated ; address_line/city/postal_code INCHANGÉS (origine préservée)
|
||||
-- Idempotent : ne traite que les lignes encore 'pending' (re-run sûr). Les 2 tables sont colocalisées
|
||||
-- dans la db ERPNext _eb65bdc0c4b1b2d6 → tout se fait en SQL (pas d'aller-retour applicatif).
|
||||
\timing on
|
||||
BEGIN;
|
||||
|
||||
-- Passe 1 : CODE POSTAL + NUMÉRO (forte) — couvre ~97 %.
|
||||
UPDATE "tabService Location" sl SET
|
||||
aq_address_id = m.id,
|
||||
linked_address = m.address_full,
|
||||
latitude = CASE WHEN m.sim >= 0.20 THEN m.lat ELSE sl.latitude END,
|
||||
longitude = CASE WHEN m.sim >= 0.20 THEN m.lon ELSE sl.longitude END,
|
||||
address_validation_status = CASE WHEN m.sim >= 0.20 THEN 'validated' ELSE 'review' END,
|
||||
address_validated_at = NOW(),
|
||||
modified = NOW()
|
||||
FROM (
|
||||
SELECT s.name, m.id, m.address_full, m.lat, m.lon, m.sim
|
||||
FROM "tabService Location" s
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT a.id::text AS id, a.address_full, a.latitude AS lat, a.longitude AS lon,
|
||||
similarity(a.search_text, lower(unaccent(s.address_line))) AS sim
|
||||
FROM rqa_addresses a
|
||||
WHERE a.code_postal = replace(upper(coalesce(s.postal_code,'')), ' ', '')
|
||||
AND a.numero = (regexp_match(s.address_line, '^\s*(\d+)'))[1]
|
||||
ORDER BY similarity(a.search_text, lower(unaccent(s.address_line))) DESC
|
||||
LIMIT 1
|
||||
) m
|
||||
WHERE s.address_validation_status = 'pending'
|
||||
AND s.address_line NOT IN ('', 'N/A', 'xxx')
|
||||
) m
|
||||
WHERE m.name = sl.name;
|
||||
|
||||
-- Passe 2 : reste non résolu → 'unmatched' (pas de correspondance code postal+numéro).
|
||||
UPDATE "tabService Location"
|
||||
SET address_validation_status = 'unmatched', address_validated_at = NOW(), modified = NOW()
|
||||
WHERE address_validation_status = 'pending';
|
||||
|
||||
-- Rapport
|
||||
SELECT address_validation_status AS statut, count(*) AS n,
|
||||
count(NULLIF(aq_address_id,'')) AS avec_lien_aq
|
||||
FROM "tabService Location" GROUP BY 1 ORDER BY 2 DESC;
|
||||
|
||||
COMMIT;
|
||||
43
scripts/migration/normalize_service_locations_pass2.sql
Normal file
43
scripts/migration/normalize_service_locations_pass2.sql
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
-- normalize_service_locations_pass2.sql — RÉCUPÉRATION par NUMÉRO + VILLE (sans contrainte de code postal),
|
||||
-- pour les Service Locations restées 'review'/'unmatched' après la passe 1 (postal+numéro). Beaucoup ont un
|
||||
-- code postal erroné/manquant mais une ville valide → on retrouve la rue par similarité.
|
||||
--
|
||||
-- N'UPGRADE qu'avec une similarité ≥0.30 (barre + stricte qu'en passe 1 car pas de contrainte postale) →
|
||||
-- met aq_address_id + linked_address + coords RQA réelles + statut 'validated'. Laisse le reste tel quel
|
||||
-- (boîtes postales C.P./PO Box, adresses hors-QC, surnoms de camping). address_line/city/postal INCHANGÉS.
|
||||
\timing on
|
||||
BEGIN;
|
||||
|
||||
UPDATE "tabService Location" sl SET
|
||||
aq_address_id = m.id,
|
||||
linked_address = m.address_full,
|
||||
latitude = m.lat,
|
||||
longitude = m.lon,
|
||||
address_validation_status = 'validated',
|
||||
address_validated_at = NOW(),
|
||||
modified = NOW()
|
||||
FROM (
|
||||
SELECT s.name, m.id, m.address_full, m.lat, m.lon, m.sim
|
||||
FROM "tabService Location" s
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT a.id::text AS id, a.address_full, a.latitude AS lat, a.longitude AS lon,
|
||||
similarity(a.search_text, lower(unaccent(s.address_line))) AS sim
|
||||
FROM rqa_addresses a
|
||||
WHERE a.numero = (regexp_match(s.address_line, '^\s*(\d+)'))[1]
|
||||
AND a.ville % regexp_replace(regexp_replace(lower(unaccent(coalesce(s.city,''))), '^ste[ .-]', 'sainte-'), '^st[ .-]', 'saint-')
|
||||
ORDER BY similarity(a.search_text, lower(unaccent(s.address_line))) DESC
|
||||
LIMIT 1
|
||||
) m
|
||||
WHERE s.address_validation_status IN ('review', 'unmatched')
|
||||
AND s.address_line ~ '^[0-9]'
|
||||
AND s.city NOT IN ('', 'N/A', 'Ville', 'x')
|
||||
AND m.sim >= 0.30
|
||||
) m
|
||||
WHERE m.name = sl.name;
|
||||
|
||||
SELECT address_validation_status AS statut, count(*) AS n,
|
||||
count(NULLIF(aq_address_id,'')) AS avec_lien,
|
||||
count(*) FILTER (WHERE latitude IS NOT NULL AND latitude<>0) AS avec_gps
|
||||
FROM "tabService Location" GROUP BY 1 ORDER BY 2 DESC;
|
||||
|
||||
COMMIT;
|
||||
179
services/targo-hub/lib/address-conformity.js
Normal file
179
services/targo-hub/lib/address-conformity.js
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
'use strict'
|
||||
/**
|
||||
* address-conformity.js — back-end de la page Ops « Conformité des adresses ».
|
||||
*
|
||||
* Source de vérité : Service Location ERPNext (tabService Location) avec son lien AQ
|
||||
* (aq_address_id → rqa_addresses, linked_address canonique, address_validation_status). Cette page
|
||||
* permet de RÉSOUDRE une fois pour toutes les adresses non conformes (review/unmatched) :
|
||||
* - Approuver : la proposition AQ devient officielle (statut validated, coords RQA).
|
||||
* - Corriger : chercher la bonne adresse AQ (recherche locale) et la lier.
|
||||
* - GPS manuel : poser lat/long (ex. relevé sur map.targointernet.com qui a la géoloc des unités).
|
||||
* - Rejeter : pas d'adresse AQ (boîte postale, hors-QC…) → statut 'no_address'.
|
||||
*
|
||||
* Tout est LOCAL (Postgres ERPNext). Routes sous /address/conformity/* (cf. server.js).
|
||||
*/
|
||||
const { json, parseBody, log, cors } = require('./helpers')
|
||||
const { searchRaw, pool } = require('./address-db') // réutilise le pool LOCAL partagé (rqa_addresses + fiber)
|
||||
const { norm } = require('./util/text')
|
||||
|
||||
// Application de la géoloc de remplacement des campings sur leurs lots (match ville normalisée). Réutilisée
|
||||
// par /campings/apply ET appelée après l'ajout/édition d'un camping. Retourne le décompte par camping.
|
||||
async function applyCampings (p) {
|
||||
const r = await p.query(`WITH applied AS (
|
||||
UPDATE "tabService Location" sl SET latitude=c.latitude, longitude=c.longitude,
|
||||
linked_address=c.name||' — '||COALESCE(c.address,''), aq_address_id=NULL,
|
||||
address_validation_status='validated', address_validated_at=NOW(), modified=NOW()
|
||||
FROM camping_registry c
|
||||
WHERE c.active AND lower(unaccent(coalesce(sl.city,''))) LIKE '%'||c.keyword||'%'
|
||||
RETURNING c.name AS camping)
|
||||
SELECT camping, count(*)::int n FROM applied GROUP BY camping ORDER BY 2 DESC`)
|
||||
return r.rows
|
||||
}
|
||||
|
||||
// Dernier repli : pour les 'unmatched' qu'on n'a pas pu géocoder, placer au CENTRE (centroïde rqa_addresses)
|
||||
// du code postal (préféré, plus précis) sinon de la ville → le job apparaît au moins dans le bon secteur.
|
||||
// Statut 'area' (approximatif). Les hors-QC/junk (pas dans rqa_addresses) restent 'unmatched'.
|
||||
async function applyAreaFallback (p) {
|
||||
const r = await p.query(`
|
||||
WITH um AS (
|
||||
SELECT name, replace(upper(coalesce(postal_code,'')),' ','') cp, lower(unaccent(coalesce(city,''))) city,
|
||||
postal_code cp_raw, city city_raw
|
||||
FROM "tabService Location" WHERE address_validation_status='unmatched'
|
||||
),
|
||||
pc AS (SELECT replace(upper(code_postal),' ','') cp, avg(latitude) lat, avg(longitude) lon FROM rqa_addresses
|
||||
WHERE replace(upper(code_postal),' ','') IN (SELECT cp FROM um WHERE cp ~ '^[A-Z][0-9][A-Z][0-9]') GROUP BY 1),
|
||||
cc AS (SELECT lower(unaccent(ville)) city, avg(latitude) lat, avg(longitude) lon FROM rqa_addresses
|
||||
WHERE lower(unaccent(ville)) IN (SELECT city FROM um WHERE city NOT IN ('','n/a','ville','x','-')) GROUP BY 1),
|
||||
upd AS (
|
||||
UPDATE "tabService Location" sl SET
|
||||
latitude=COALESCE(pc.lat,cc.lat), longitude=COALESCE(pc.lon,cc.lon),
|
||||
linked_address='≈ centre ' || COALESCE(NULLIF(CASE WHEN pc.lat IS NOT NULL THEN um.cp_raw END,''), um.city_raw),
|
||||
address_validation_status='area', address_validated_at=NOW(), modified=NOW()
|
||||
FROM um LEFT JOIN pc ON pc.cp=um.cp LEFT JOIN cc ON cc.city=um.city
|
||||
WHERE sl.name=um.name AND (pc.lat IS NOT NULL OR cc.lat IS NOT NULL)
|
||||
RETURNING (pc.lat IS NOT NULL) AS by_postal)
|
||||
SELECT count(*)::int total, count(*) FILTER (WHERE by_postal)::int by_postal,
|
||||
count(*) FILTER (WHERE NOT by_postal)::int by_city FROM upd`)
|
||||
return r.rows[0]
|
||||
}
|
||||
|
||||
// Type d'adresse (pour trier la file) : camping (sobriquet de lot), civique à corriger, non-adresse, review standard.
|
||||
const TYPE_SQL = `CASE
|
||||
WHEN sl.city ILIKE '%camping%' OR sl.address_line ILIKE '%camping%' THEN 'camping'
|
||||
WHEN sl.address_validation_status='unmatched' AND sl.address_line ~ '^[0-9]' AND sl.city NOT IN ('','N/A','Ville','x','-') THEN 'civique'
|
||||
WHEN sl.address_validation_status='unmatched' THEN 'non_adresse'
|
||||
ELSE 'review' END`
|
||||
|
||||
async function handle (req, res, method, path) {
|
||||
try {
|
||||
if (method === 'OPTIONS') { cors(res); res.writeHead(204); res.end(); return }
|
||||
|
||||
// Compteurs par statut + par type (file de travail).
|
||||
if (path === '/address/conformity/stats' && method === 'GET') {
|
||||
const p = pool()
|
||||
const byStatus = (await p.query('SELECT address_validation_status s, count(*) n FROM "tabService Location" GROUP BY 1 ORDER BY 2 DESC')).rows
|
||||
const byType = (await p.query(`SELECT ${TYPE_SQL} t, count(*) n FROM "tabService Location" sl WHERE address_validation_status IN ('review','unmatched') GROUP BY 1 ORDER BY 2 DESC`)).rows
|
||||
cors(res); return json(res, 200, { ok: true, by_status: byStatus, by_type: byType })
|
||||
}
|
||||
|
||||
// Liste paginée des adresses à traiter (review/unmatched), filtrable par type + recherche texte.
|
||||
if (path === '/address/conformity/list' && method === 'GET') {
|
||||
const u = new URL(req.url, 'http://localhost')
|
||||
const type = u.searchParams.get('type') || ''
|
||||
const q = (u.searchParams.get('q') || '').trim()
|
||||
const limit = Math.min(parseInt(u.searchParams.get('limit')) || 50, 200)
|
||||
const offset = Math.max(parseInt(u.searchParams.get('offset')) || 0, 0)
|
||||
const where = [`sl.address_validation_status IN ('review','unmatched')`]
|
||||
const params = []
|
||||
if (type) { params.push(type); where.push(`${TYPE_SQL} = $${params.length}`) }
|
||||
if (q) { params.push('%' + q + '%'); where.push(`(sl.address_line ILIKE $${params.length} OR sl.city ILIKE $${params.length} OR sl.customer ILIKE $${params.length} OR sl.name ILIKE $${params.length})`) }
|
||||
const whereSql = where.join(' AND ')
|
||||
const p = pool()
|
||||
const total = (await p.query(`SELECT count(*) n FROM "tabService Location" sl WHERE ${whereSql}`, params)).rows[0].n
|
||||
params.push(limit); params.push(offset)
|
||||
const rows = (await p.query(
|
||||
`SELECT sl.name, sl.customer, sl.address_line, sl.city, sl.postal_code,
|
||||
sl.address_validation_status AS status, sl.aq_address_id, sl.linked_address,
|
||||
sl.latitude, sl.longitude, ${TYPE_SQL} AS type
|
||||
FROM "tabService Location" sl WHERE ${whereSql}
|
||||
ORDER BY ${TYPE_SQL}, sl.city, sl.address_line
|
||||
LIMIT $${params.length - 1} OFFSET $${params.length}`, params)).rows
|
||||
cors(res); return json(res, 200, { ok: true, total: Number(total), limit, offset, rows })
|
||||
}
|
||||
|
||||
// Candidats AQ pour l'action « Corriger » (recherche locale rqa_addresses + fibre).
|
||||
if (path === '/address/conformity/candidates' && method === 'GET') {
|
||||
const u = new URL(req.url, 'http://localhost')
|
||||
const q = (u.searchParams.get('q') || '').trim()
|
||||
let results = []
|
||||
try { results = await searchRaw(q, 8) } catch (e) { log('conformity/candidates:', e.message) }
|
||||
cors(res); return json(res, 200, { ok: true, results })
|
||||
}
|
||||
|
||||
// Résoudre une adresse : approve | correct | gps | reject. Écrit le lien/coords/statut (source de vérité).
|
||||
if (path === '/address/conformity/resolve' && method === 'POST') {
|
||||
const b = await parseBody(req)
|
||||
const name = b.name
|
||||
if (!name) { cors(res); return json(res, 400, { ok: false, error: 'name requis' }) }
|
||||
const p = pool()
|
||||
const set = ['address_validated_at = NOW()', 'modified = NOW()']
|
||||
const params = []
|
||||
const add = (frag, val) => { params.push(val); set.push(`${frag} = $${params.length}`) }
|
||||
if (b.action === 'approve') {
|
||||
// garde la proposition existante (aq_address_id/linked_address) + coords déjà raffinées → validated
|
||||
set.push(`address_validation_status = 'validated'`)
|
||||
} else if (b.action === 'correct') {
|
||||
if (b.aq_address_id != null) add('aq_address_id', String(b.aq_address_id))
|
||||
if (b.linked_address != null) add('linked_address', b.linked_address)
|
||||
if (b.latitude != null) add('latitude', Number(b.latitude))
|
||||
if (b.longitude != null) add('longitude', Number(b.longitude))
|
||||
set.push(`address_validation_status = 'validated'`)
|
||||
} else if (b.action === 'gps') {
|
||||
if (b.latitude == null || b.longitude == null) { cors(res); return json(res, 400, { ok: false, error: 'latitude/longitude requis' }) }
|
||||
add('latitude', Number(b.latitude)); add('longitude', Number(b.longitude))
|
||||
set.push(`address_validation_status = 'validated'`) // position confirmée manuellement
|
||||
} else if (b.action === 'reject') {
|
||||
set.push(`address_validation_status = 'no_address'`) // pas d'adresse civique (boîte postale, hors-QC…)
|
||||
} else { cors(res); return json(res, 400, { ok: false, error: 'action inconnue' }) }
|
||||
params.push(name)
|
||||
const r = await p.query(`UPDATE "tabService Location" SET ${set.join(', ')} WHERE name = $${params.length}`, params)
|
||||
cors(res); return json(res, 200, { ok: true, updated: r.rowCount, name, action: b.action })
|
||||
}
|
||||
|
||||
// ── Registre des campings (géoloc de remplacement fixe) ──
|
||||
if (path === '/address/conformity/campings' && method === 'GET') {
|
||||
const p = pool()
|
||||
const campings = (await p.query('SELECT id, keyword, name, address, latitude, longitude, active FROM camping_registry ORDER BY name, keyword')).rows
|
||||
const counts = (await p.query(`SELECT c.name, count(sl.name)::int n FROM camping_registry c
|
||||
LEFT JOIN "tabService Location" sl ON c.active AND lower(unaccent(coalesce(sl.city,''))) LIKE '%'||c.keyword||'%'
|
||||
GROUP BY c.name`)).rows
|
||||
const lots = {}; for (const x of counts) lots[x.name] = x.n
|
||||
cors(res); return json(res, 200, { ok: true, campings, lots })
|
||||
}
|
||||
if (path === '/address/conformity/campings' && method === 'POST') {
|
||||
const b = await parseBody(req)
|
||||
if (!b.keyword || !b.name || b.latitude == null || b.longitude == null) { cors(res); return json(res, 400, { ok: false, error: 'keyword/name/latitude/longitude requis' }) }
|
||||
const p = pool()
|
||||
await p.query(`INSERT INTO camping_registry (keyword,name,address,latitude,longitude,active)
|
||||
VALUES ($1,$2,$3,$4,$5,true)
|
||||
ON CONFLICT (keyword) DO UPDATE SET name=$2, address=$3, latitude=$4, longitude=$5, active=true`,
|
||||
[norm(b.keyword), b.name, b.address || '', Number(b.latitude), Number(b.longitude)])
|
||||
const applied = await applyCampings(p) // applique direct le nouveau/maj camping
|
||||
cors(res); return json(res, 200, { ok: true, applied })
|
||||
}
|
||||
if (path === '/address/conformity/campings/apply' && method === 'POST') {
|
||||
cors(res); return json(res, 200, { ok: true, applied: await applyCampings(pool()) })
|
||||
}
|
||||
// Repli centroïde (centre du CP/ville) pour les unmatched restants
|
||||
if (path === '/address/conformity/apply-area' && method === 'POST') {
|
||||
cors(res); return json(res, 200, { ok: true, ...(await applyAreaFallback(pool())) })
|
||||
}
|
||||
|
||||
cors(res); return json(res, 404, { ok: false, error: 'route conformité inconnue' })
|
||||
} catch (e) {
|
||||
log('address-conformity error:', e.message)
|
||||
cors(res); return json(res, 500, { ok: false, error: String(e.message || e) })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handle }
|
||||
124
services/targo-hub/lib/address-db.js
Normal file
124
services/targo-hub/lib/address-db.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
'use strict'
|
||||
/**
|
||||
* address-db.js — recherche d'adresses LOCALE (Postgres ERPNext), remplace le Supabase cloud externe.
|
||||
*
|
||||
* Source : `rqa_addresses` (5,24M adresses du Québec, search_text désaccentué, index trigram GIN)
|
||||
* LEFT JOIN `fiber_availability` (couverture fibre Targo, joint par address_id) — toutes deux DÉJÀ
|
||||
* locales dans la db ERPNext `_eb65bdc0c4b1b2d6`. Le hub est sur le réseau erpnext_erpnext + a `pg`.
|
||||
*
|
||||
* Deux phases (comme l'autocomplete de dispo) :
|
||||
* Phase 1 — civique présent : filtre `numero` (btree) + mots de rue (LIKE), tri fibre→J0L/J0S→similarité. ~20 ms.
|
||||
* Phase 2 — sans civique / phase 1 vide : `word_similarity` (`<%`, indexable GIN), tri idem. ~700 ms.
|
||||
* (NB : le `%`/similarity() plein sur 5,24M = 24-76 s → on utilise `<%` qui est sélectif et indexé.)
|
||||
*
|
||||
* Retourne une forme compatible avec l'ancien Supabase (adresse_formatee, numero_municipal,
|
||||
* odonyme_recompose_normal, nom_municipalite, latitude, longitude, fiber_available, zone_tarifaire,
|
||||
* max_speed, similarity_score) → consommateurs hub inchangés (pont, onboarding, checkout, site).
|
||||
*/
|
||||
const { Pool } = require('pg')
|
||||
const { log } = require('./helpers')
|
||||
|
||||
const { norm } = require('./util/text') // helper texte partagé (Phase 1 : dé-duplication)
|
||||
|
||||
let _pool
|
||||
function pool () {
|
||||
if (!_pool) {
|
||||
_pool = new Pool({
|
||||
host: process.env.ADDR_DB_HOST || 'erpnext-db-1',
|
||||
port: +(process.env.ADDR_DB_PORT || 5432),
|
||||
user: process.env.ADDR_DB_USER || 'postgres',
|
||||
password: process.env.ADDR_DB_PASS || '123',
|
||||
database: process.env.ADDR_DB_NAME || '_eb65bdc0c4b1b2d6',
|
||||
max: 4, idleTimeoutMillis: 30000, connectionTimeoutMillis: 6000,
|
||||
})
|
||||
_pool.on('error', (e) => log('address-db pool error:', e.message))
|
||||
}
|
||||
return _pool
|
||||
}
|
||||
|
||||
const COLS = `a.id, a.address_full, a.numero, a.rue, a.ville, a.code_postal, a.longitude, a.latitude,
|
||||
(f.id IS NOT NULL) AS fiber_available, COALESCE(f.zone_tarifaire,0) AS zone_tarifaire, COALESCE(f.max_speed,0) AS max_speed`
|
||||
const ORDER = `(f.id IS NOT NULL) DESC, (a.code_postal LIKE 'J0L%' OR a.code_postal LIKE 'J0S%') DESC, sim DESC`
|
||||
|
||||
function mapRow (r) {
|
||||
return {
|
||||
identifiant_unique_adresse: String(r.id),
|
||||
adresse_formatee: r.address_full || [r.numero, r.rue].filter(Boolean).join(' ') + (r.ville ? ', ' + r.ville : '') + (r.code_postal ? ' ' + r.code_postal : ''),
|
||||
numero_municipal: r.numero,
|
||||
numero_unite: null,
|
||||
code_postal: r.code_postal,
|
||||
odonyme_recompose_normal: r.rue,
|
||||
nom_municipalite: r.ville,
|
||||
latitude: r.latitude,
|
||||
longitude: r.longitude,
|
||||
fiber_available: !!r.fiber_available,
|
||||
zone_tarifaire: r.zone_tarifaire,
|
||||
max_speed: r.max_speed,
|
||||
similarity_score: r.sim != null ? +r.sim : null,
|
||||
}
|
||||
}
|
||||
|
||||
// Forme BRUTE (colonnes de la fonction search_addresses : id, address_full, numero, rue, ville,
|
||||
// code_postal, longitude, latitude, fiber_available, zone_tarifaire, max_speed, similarity_score)
|
||||
// → consommée telle quelle par l'autocomplete du site web (endpoint /rpc/search_addresses).
|
||||
function toRaw (r) {
|
||||
return {
|
||||
id: r.id, address_full: r.address_full, numero: r.numero, rue: r.rue, ville: r.ville,
|
||||
code_postal: r.code_postal, longitude: r.longitude, latitude: r.latitude,
|
||||
fiber_available: !!r.fiber_available, zone_tarifaire: r.zone_tarifaire, max_speed: r.max_speed,
|
||||
similarity_score: r.sim != null ? +r.sim : null,
|
||||
}
|
||||
}
|
||||
|
||||
// Recherche principale (lignes brutes DB). term = texte tapé ("2338 rue rené-vinet" ou "rené-vinet ...").
|
||||
async function searchRows (term, limit = 8) {
|
||||
const clean = norm(term)
|
||||
if (clean.length < 3) return []
|
||||
const lim = Math.min(Math.max(parseInt(limit) || 8, 1), 25)
|
||||
const civic = (clean.match(/^\s*(\d+)/) || [])[1] || null
|
||||
const streetPart = clean.replace(/^\s*\d+\s*/, '').trim()
|
||||
const words = streetPart ? streetPart.split(' ').filter(w => w.length >= 2) : []
|
||||
const p = pool()
|
||||
|
||||
// ── Phase 1 : civique présent (rapide, btree numero) ──
|
||||
if (civic) {
|
||||
const sql = `SELECT ${COLS}, similarity(a.search_text, $3) AS sim
|
||||
FROM rqa_addresses a LEFT JOIN fiber_availability f ON f.address_id = a.id
|
||||
WHERE a.numero = $1
|
||||
AND (array_length($2::text[], 1) IS NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM unnest($2::text[]) w WHERE a.search_text NOT LIKE '%' || w || '%'))
|
||||
ORDER BY ${ORDER}, a.numero
|
||||
LIMIT $4`
|
||||
const r = await p.query(sql, [civic, words, clean, lim])
|
||||
if (r.rows.length) return r.rows
|
||||
}
|
||||
|
||||
// ── Phase 2 : word_similarity (sans civique, ou phase 1 vide) ──
|
||||
// `<%` (indexable GIN) au lieu de `%`/similarity() plein (24-76 s sur 5,24M). Seuil + statement_timeout
|
||||
// posés en SET LOCAL dans une transaction (déterministe + auto-reset au COMMIT ; borne le pire cas).
|
||||
const sql2 = `SELECT ${COLS}, word_similarity($1, a.search_text) AS sim
|
||||
FROM rqa_addresses a LEFT JOIN fiber_availability f ON f.address_id = a.id
|
||||
WHERE $1 <% a.search_text
|
||||
ORDER BY ${ORDER}
|
||||
LIMIT $2`
|
||||
const client = await p.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query('SET LOCAL pg_trgm.word_similarity_threshold = 0.6') // ~700 ms ; 0.5 plus large/lent
|
||||
await client.query('SET LOCAL statement_timeout = 4000') // garde-fou anti-hang (sécurité)
|
||||
const r2 = await client.query(sql2, [clean, lim])
|
||||
await client.query('COMMIT')
|
||||
return r2.rows
|
||||
} catch (e) {
|
||||
try { await client.query('ROLLBACK') } catch (_) {}
|
||||
log('searchRows phase2:', e.message)
|
||||
return []
|
||||
} finally { client.release() }
|
||||
}
|
||||
|
||||
// Forme MAPPÉE (compat Supabase historique) → consommée par les services hub (pont, onboarding, checkout).
|
||||
async function searchLocal (term, limit = 8) { return (await searchRows(term, limit)).map(mapRow) }
|
||||
// Forme BRUTE (colonnes de la fonction) → consommée par l'autocomplete du site web.
|
||||
async function searchRaw (term, limit = 8) { return (await searchRows(term, limit)).map(toRaw) }
|
||||
|
||||
module.exports = { searchLocal, searchRaw, pool, norm }
|
||||
|
|
@ -1,51 +1,24 @@
|
|||
'use strict'
|
||||
const { httpRequest } = require('./helpers')
|
||||
|
||||
const SUPABASE_URL = 'https://rddrjzptzhypltuzmere.supabase.co'
|
||||
const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InJkZHJqenB0emh5cGx0dXptZXJlIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA4MTY4NTYsImV4cCI6MjA4NjM5Mjg1Nn0.EluFlKBze8BYM6AFx88G7kt21EvR18EI3uw1zgCXVzs'
|
||||
|
||||
function wordsToIlike (str) {
|
||||
const words = str.split(/\s+/).filter(w => w.length >= 2)
|
||||
if (!words.length) return ''
|
||||
return '*' + words.map(w => encodeURIComponent(w)).join('*') + '*'
|
||||
}
|
||||
/**
|
||||
* address-search.js — façade de recherche d'adresses.
|
||||
*
|
||||
* Depuis 2026-06 la source est 100% LOCALE (Postgres ERPNext : rqa_addresses + fiber_availability,
|
||||
* via ./address-db) — PLUS aucune dépendance au Supabase cloud externe. Les noms d'export restent
|
||||
* stables pour les consommateurs existants : address-validate (/address/*), checkout (/api/address-search),
|
||||
* legacy-dispatch-sync (géocodage du pont).
|
||||
*/
|
||||
const { searchLocal } = require('./address-db')
|
||||
|
||||
// Recherche d'adresses — forme historique (adresse_formatee, numero_municipal, numero_unite,
|
||||
// odonyme_recompose_normal, nom_municipalite, code_postal, latitude, longitude,
|
||||
// identifiant_unique_adresse, fiber_available, zone_tarifaire, max_speed, similarity_score).
|
||||
async function searchAddresses (term, limit = 8) {
|
||||
const clean = term.trim()
|
||||
if (clean.length < 3) return []
|
||||
|
||||
const numMatch = clean.match(/^\s*(\d+)\s*(.*)/)
|
||||
const headers = { apikey: SUPABASE_KEY, Authorization: 'Bearer ' + SUPABASE_KEY }
|
||||
const select = 'adresse_formatee,numero_municipal,numero_unite,code_postal,odonyme_recompose_normal,nom_municipalite,latitude,longitude,identifiant_unique_adresse'
|
||||
const base = `${SUPABASE_URL}/rest/v1/addresses?select=${select}&limit=${limit}`
|
||||
|
||||
let results = []
|
||||
|
||||
if (numMatch) {
|
||||
const num = numMatch[1]
|
||||
const street = numMatch[2].trim()
|
||||
let url = `${base}&numero_municipal=eq.${num}`
|
||||
if (street) url += `&odonyme_recompose_normal=ilike.${wordsToIlike(street)}`
|
||||
url += '&order=nom_municipalite'
|
||||
const res = await httpRequest(url, '', { headers })
|
||||
results = Array.isArray(res.data) ? res.data : []
|
||||
|
||||
if (!results.length && num.length >= 2) {
|
||||
let url2 = `${base}&numero_municipal=like.${num}*`
|
||||
if (street) url2 += `&odonyme_recompose_normal=ilike.${wordsToIlike(street)}`
|
||||
url2 += '&order=nom_municipalite'
|
||||
const res2 = await httpRequest(url2, '', { headers })
|
||||
results = Array.isArray(res2.data) ? res2.data : []
|
||||
}
|
||||
} else {
|
||||
const pattern = wordsToIlike(clean)
|
||||
if (!pattern) return []
|
||||
const url = `${base}&odonyme_recompose_normal=ilike.${pattern}&order=nom_municipalite`
|
||||
const res = await httpRequest(url, '', { headers })
|
||||
results = Array.isArray(res.data) ? res.data : []
|
||||
try { return await searchLocal(term, limit) } catch (e) { return [] }
|
||||
}
|
||||
|
||||
return results.map(a => ({ ...a, fiber_available: false }))
|
||||
// Alias historique (anciennement la RPC trigram Supabase) → désormais la même recherche locale.
|
||||
async function searchAddressesRpc (term, limit = 8) {
|
||||
return searchLocal(term, limit)
|
||||
}
|
||||
|
||||
module.exports = { searchAddresses, wordsToIlike }
|
||||
module.exports = { searchAddresses, searchAddressesRpc }
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@
|
|||
// ./address-search.js — already used by the customer onboarding wizard.
|
||||
// We layer a confidence score + canonical formatting on top.
|
||||
|
||||
const { json, parseBody, log } = require('./helpers')
|
||||
const { json, parseBody, log, cors } = require('./helpers')
|
||||
const { searchAddresses } = require('./address-search')
|
||||
const { searchLocal, searchRaw } = require('./address-db')
|
||||
|
||||
// Normalize for fuzzy comparison: lowercase, strip diacritics, collapse
|
||||
// whitespace, drop punctuation. Used to score how close a typed address
|
||||
|
|
@ -65,6 +66,38 @@ function scoreMatch (typed, rqaRow) {
|
|||
}
|
||||
|
||||
async function handle (req, res, method, path) {
|
||||
// POST /rpc/search_addresses — COMPAT Supabase RPC (forme attendue par l'autocomplete du site web :
|
||||
// body { search_term, result_limit } → tableau direct de lignes { id, address_full, numero, rue, ville,
|
||||
// code_postal, longitude, latitude, fiber_available, zone_tarifaire, max_speed, similarity_score }).
|
||||
// Sert depuis la base LOCALE → on débranche le Supabase cloud (juste basculer VITE_API_BASE côté site).
|
||||
if (path === '/rpc/search_addresses') {
|
||||
if (method === 'OPTIONS') { cors(res); res.writeHead(204); res.end(); return }
|
||||
if (method === 'POST') {
|
||||
const body = await parseBody(req)
|
||||
const term = (body.search_term || body.q || '').toString().trim()
|
||||
const limit = body.result_limit || body.limit || 8
|
||||
let rows = []
|
||||
try { rows = await searchRaw(term, limit) } catch (e) { log('rpc/search_addresses:', e.message) }
|
||||
cors(res)
|
||||
return json(res, 200, rows)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /address/search?q=&limit= — autocomplete PUBLIC (site web) : adresses + disponibilité fibre,
|
||||
// depuis la base LOCALE (rqa_addresses + fiber_availability). Remplace l'appel direct au Supabase.
|
||||
if (path.startsWith('/address/search')) {
|
||||
if (method === 'OPTIONS') { cors(res); res.writeHead(204); res.end(); return }
|
||||
if (method === 'GET') {
|
||||
const u = new URL(req.url, 'http://localhost')
|
||||
const q = (u.searchParams.get('q') || '').trim()
|
||||
const limit = u.searchParams.get('limit') || 8
|
||||
let results = []
|
||||
try { results = await searchLocal(q, limit) } catch (e) { log('address/search error:', e.message) }
|
||||
cors(res)
|
||||
return json(res, 200, { ok: true, query: q, count: results.length, results })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /address/validate — score-rank RQA results for a free-text address.
|
||||
if (path === '/address/validate' && method === 'POST') {
|
||||
const body = await parseBody(req)
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ async function create (doctype, body) {
|
|||
const r = await erpFetch(`/api/resource/${encDocType(doctype)}`, {
|
||||
method: 'POST', body: JSON.stringify(body),
|
||||
})
|
||||
if (r.status >= 400) return { ok: false, error: errorMessage(r), status: r.status }
|
||||
if (r.status >= 400) { const error = errorMessage(r); log(`erp.create ${doctype} échec [${r.status}]: ${String(error).slice(0, 200)}`); return { ok: false, error, status: r.status } }
|
||||
return { ok: true, data: r.data?.data, name: r.data?.data?.name }
|
||||
}
|
||||
|
||||
|
|
@ -118,13 +118,13 @@ async function update (doctype, name, body) {
|
|||
const r = await erpFetch(`/api/resource/${encDocType(doctype)}/${encName(name)}`, {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
})
|
||||
if (r.status >= 400) return { ok: false, error: errorMessage(r), status: r.status }
|
||||
if (r.status >= 400) { const error = errorMessage(r); log(`erp.update ${doctype}/${name} échec [${r.status}]: ${String(error).slice(0, 200)}`); return { ok: false, error, status: r.status } }
|
||||
return { ok: true, data: r.data?.data }
|
||||
}
|
||||
|
||||
async function remove (doctype, name) {
|
||||
const r = await erpFetch(`/api/resource/${encDocType(doctype)}/${encName(name)}`, { method: 'DELETE' })
|
||||
if (r.status >= 400) return { ok: false, error: errorMessage(r), status: r.status }
|
||||
if (r.status >= 400) { const error = errorMessage(r); log(`erp.remove ${doctype}/${name} échec [${r.status}]: ${String(error).slice(0, 200)}`); return { ok: false, error, status: r.status } }
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -133,8 +133,16 @@ function deepGetValue (obj, path) {
|
|||
return node?._value !== undefined ? node._value : null
|
||||
}
|
||||
|
||||
// CORS partagé pour les endpoints PUBLICS (données publiques, lecture seule + écritures internes).
|
||||
// Pose les en-têtes via setHeader (fusionnés par writeHead de json()). methods optionnel.
|
||||
function cors (res, methods = 'GET, POST, OPTIONS') {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', methods)
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
log, json, parseBody, httpRequest,
|
||||
log, json, parseBody, httpRequest, cors,
|
||||
erpFetch, erpRequest, lookupCustomerByPhone, createCommunication,
|
||||
nbiRequest, deepGetValue,
|
||||
}
|
||||
|
|
|
|||
431
services/targo-hub/lib/legacy-dispatch-sync.js
Normal file
431
services/targo-hub/lib/legacy-dispatch-sync.js
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
'use strict'
|
||||
/**
|
||||
* legacy-dispatch-sync.js — PONT legacy (osTicket/MariaDB) → Dispatch Job (ERPNext).
|
||||
*
|
||||
* Tire RÉGULIÈREMENT les tickets ouverts assignés au compte « Tech Targo »
|
||||
* (staff id 3301 dans la DB legacy `gestionclient`) et crée/maj un Dispatch Job
|
||||
* dans ERPNext pour les répartir sur la grille Planification / le tableau Dispatch.
|
||||
*
|
||||
* Pourquoi 3301 : dans le legacy, le travail terrain à dispatcher est assigné au
|
||||
* compte générique « Tech Targo » (default_staff des dépts Installation/Réparation/
|
||||
* Fibre). C'est exactement « les tickets assignés à tech targo ».
|
||||
*
|
||||
* IDEMPOTENT : chaque ticket legacy porte un `legacy_ticket_id` sur le Dispatch Job.
|
||||
* On cherche avant de créer → jamais de doublon. On NE clobbe PAS le travail du
|
||||
* répartiteur : un job déjà assigné/déplacé n'est plus touché (maj de date seulement
|
||||
* tant qu'il est encore `open` + non assigné).
|
||||
*
|
||||
* Routes : GET /dispatch/legacy-sync/preview (dry-run, 0 écriture) · POST /dispatch/legacy-sync/run
|
||||
* Récurrence : startSync() (setInterval, cf. server.js), désactivable via LEGACY_DISPATCH_SYNC=off.
|
||||
*
|
||||
* Pré-requis : champ Custom Field `legacy_ticket_id` sur Dispatch Job
|
||||
* (dispatch-app/frappe-setup/setup_dispatch_custom_fields.py).
|
||||
*/
|
||||
const erp = require('./erp')
|
||||
const cfg = require('./config')
|
||||
const { log, json, httpRequest } = require('./helpers')
|
||||
const { searchAddressesRpc } = require('./address-search') // recherche trigram RQA (RPC pg_trgm) — celle de l'autocomplete de dispo
|
||||
const addrdb = require('./address-db') // pool PG local (camping_registry)
|
||||
|
||||
// Campings : l'adresse de service est un terrain de camping (≠ résidence du client). On force la géoloc
|
||||
// FIXE du camping (registre camping_registry). Détection robuste : le texte doit contenir « camping » OU
|
||||
// un mot-clé de LIEU spécifique (évite les faux positifs de patronyme, ex. « Daniel Dauphinais »).
|
||||
const CAMP_PLACE_KW = ['lac des pins', 'lac de pins', 'sandysun', 'sandy sun', 'frontiere', 'ensoleill']
|
||||
let _campings = null; let _campingsAt = 0
|
||||
async function getCampings () {
|
||||
if (_campings && (Date.now() - _campingsAt) < 600000) return _campings // cache 10 min
|
||||
try { const r = await addrdb.pool().query('SELECT keyword, name, latitude, longitude FROM camping_registry WHERE active'); _campings = r.rows; _campingsAt = Date.now() } catch (e) { _campings = _campings || [] }
|
||||
return _campings
|
||||
}
|
||||
// fields en ORDRE DE PRIORITÉ (sujet d'abord = label de service explicite, puis ville/adresse de delivery).
|
||||
// Le 1er champ qui contient un signal camping décide → évite qu'une ville de delivery (résidence) écrase le sujet.
|
||||
function campingFor (campings, fields) {
|
||||
for (const f of (Array.isArray(fields) ? fields : [fields])) {
|
||||
const t = norm(f || '')
|
||||
if (!(t.includes('camping') || CAMP_PLACE_KW.some(k => t.includes(k)))) continue
|
||||
for (const c of campings) if (c.keyword && t.includes(c.keyword)) return c
|
||||
}
|
||||
return null
|
||||
}
|
||||
let mysql
|
||||
try { mysql = require('mysql2/promise') } catch { /* dépendance optionnelle */ }
|
||||
|
||||
const TARGO_TECH_STAFF_ID = Number(process.env.LEGACY_TARGO_STAFF_ID) || 3301 // compte « Tech Targo » (pool de dispatch)
|
||||
|
||||
// Parseurs/mapping PURS extraits dans util/legacy-parse (testables en isolation) :
|
||||
// DEPT_JOBTYPE/DUR + jobType/prio/tzDate/startTime/coord. + norm (util/text). (Phase 1 : logique pure séparée des I/O.)
|
||||
const { DEPT_JOBTYPE, DUR, jobType, prio, tzDate, startTime, coord } = require('./util/legacy-parse')
|
||||
const { norm } = require('./util/text')
|
||||
|
||||
// Géocodage de repli via RQA (Répertoire des adresses du Québec) — source autoritaire, fiable en
|
||||
// rural (vs Mapbox qui peut dévier de plusieurs km). Cache au niveau MODULE (persiste entre les ticks)
|
||||
// → chaque adresse n'est géocodée qu'une fois par cycle de vie du hub ; les échecs sont mémorisés
|
||||
// (valeur null) pour ne PAS marteler RQA à chaque cycle. N'accepte qu'une correspondance fiable (≥0.7).
|
||||
// Géocodage RQA via la RECHERCHE TRIGRAM (RPC `search_addresses`, pg_trgm) — celle de l'autocomplete de
|
||||
// dispo. Trouve les rues que l'ilike manquait (générique géré par la colonne `long` + trigram phase 2).
|
||||
// GARDE-FOU de zone : le civique doit concorder ET le CP OU la ville doit confirmer la région → rejette
|
||||
// les faux positifs trigram hors-territoire (ex. « Rue Grenet, Montréal » quand un civique René-Vinet
|
||||
// absent du RQA déclenche la phase 2). Cache module (1 appel/adresse/vie ; échecs mémorisés).
|
||||
const _geoCache = new Map()
|
||||
async function geocodeRQA (addressLine, postalCode, city) {
|
||||
const key = norm([addressLine, postalCode, city].filter(Boolean).join('|'))
|
||||
if (!key || !addressLine) return null
|
||||
if (_geoCache.has(key)) return _geoCache.get(key)
|
||||
let res = null
|
||||
try {
|
||||
const rows = await searchAddressesRpc(addressLine, 8)
|
||||
if (rows && rows.length) {
|
||||
const civic = (String(addressLine).match(/^\s*(\d+)/) || [])[1] || null
|
||||
const fsa = String(postalCode || '').replace(/\s+/g, '').toUpperCase().slice(0, 3)
|
||||
const cityN = norm(city)
|
||||
const GEN = ['rue', 'rang', 'chemin', 'ch', 'route', 'rte', 'avenue', 'av', 'ave', 'boul', 'boulevard', 'bd', 'montee', 'cote', 'place', 'pl', 'allee', 'terrasse', 'croissant', 'des', 'de', 'du', 'la', 'le', 'aux']
|
||||
const streetToks = norm(addressLine).replace(/^\s*\d+\s*/, '').split(/[\s-]+/).filter(w => w.length >= 3 && !GEN.includes(w)) // tokens significatifs du nom de rue
|
||||
const streetOk = (r) => { if (!streetToks.length) return true; const hay = norm((r.odonyme_recompose_normal || '') + ' ' + (r.adresse_formatee || '')); return streetToks.some(w => hay.includes(w)) }
|
||||
const pick = rows.find(r => {
|
||||
if (!coord(r.latitude, r.longitude)) return false
|
||||
if (civic && String(r.numero_municipal || '') !== civic) return false // mauvais numéro civique → rejet
|
||||
if (!streetOk(r)) return false // bon civique mais mauvaise rue (faux positif trigram) → rejet
|
||||
const rFsa = String(r.code_postal || '').replace(/\s+/g, '').toUpperCase().slice(0, 3)
|
||||
// En TERRITOIRE Targo (J0L/J0S, déjà priorisé par la RPC + filtrage mots-de-rue en phase 1) → on fait
|
||||
// confiance au classement RPC (= l'autocomplete client). Civique + rue concordent déjà.
|
||||
if (rFsa === 'J0L' || rFsa === 'J0S') return true
|
||||
// Hors territoire → exiger une concordance EXPLICITE avec l'enregistrement legacy (CP OU ville),
|
||||
// sinon rejet (ex. faux positif trigram « Rue Grenet, Montréal H4L »).
|
||||
const rCity = norm(r.nom_municipalite)
|
||||
const postalOk = !!(fsa && rFsa && rFsa === fsa)
|
||||
const cityOk = !!(cityN && rCity && (rCity.includes(cityN) || cityN.includes(rCity) || rCity.split('-')[0] === cityN.split('-')[0]))
|
||||
return postalOk || cityOk
|
||||
})
|
||||
if (pick) res = coord(pick.latitude, pick.longitude)
|
||||
}
|
||||
} catch (e) { log('geocodeRQA error:', e.message) } // RQA indispo → pas de coords (échec mémorisé)
|
||||
_geoCache.set(key, res)
|
||||
return res
|
||||
}
|
||||
|
||||
// Repli Mapbox (token public déjà utilisé par le Dispatch) pour les rues TROP RÉCENTES pour le RQA
|
||||
// (nouveaux développements absents du répertoire). Moins précis en rural que le RQA mais « une coord
|
||||
// vaut mieux que zéro » pour le routage. Contraint au Québec (country=ca + proximity Montérégie +
|
||||
// bornes coord()). Cache module. Désactivé si MAPBOX_TOKEN absent de l'env.
|
||||
const MAPBOX_TOKEN = process.env.MAPBOX_TOKEN || ''
|
||||
const _mbCache = new Map()
|
||||
async function geocodeMapbox (addressLine, city, postalCode) {
|
||||
if (!MAPBOX_TOKEN || !addressLine) return null
|
||||
const key = norm([addressLine, city, postalCode].filter(Boolean).join('|'))
|
||||
if (_mbCache.has(key)) return _mbCache.get(key)
|
||||
let res = null
|
||||
try {
|
||||
const q = [addressLine, city, 'Québec'].filter(Boolean).join(', ')
|
||||
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(q)}.json` +
|
||||
`?country=ca&proximity=-73.5,45.2&limit=1&types=address&language=fr&access_token=${MAPBOX_TOKEN}`
|
||||
const r = await httpRequest(url, '', { timeout: 12000 })
|
||||
const f = r && r.data && Array.isArray(r.data.features) && r.data.features[0]
|
||||
if (f && Array.isArray(f.center) && (f.relevance == null || f.relevance >= 0.6)) {
|
||||
const c = coord(f.center[1], f.center[0]) // Mapbox = [lon, lat]
|
||||
if (c) res = c
|
||||
}
|
||||
} catch (e) { log('geocodeMapbox error:', e.message) }
|
||||
_mbCache.set(key, res)
|
||||
return res
|
||||
}
|
||||
|
||||
let _pool
|
||||
function pool () {
|
||||
if (!mysql) return null
|
||||
if (!_pool) {
|
||||
_pool = mysql.createPool({
|
||||
host: cfg.LEGACY_DB_HOST, user: cfg.LEGACY_DB_USER, password: cfg.LEGACY_DB_PASS, database: cfg.LEGACY_DB_NAME,
|
||||
connectionLimit: 2, waitForConnections: true, connectTimeout: 8000,
|
||||
})
|
||||
}
|
||||
return _pool
|
||||
}
|
||||
|
||||
// Lien d'activation STB/Ministra : DÉJÀ posté dans le fil du ticket par le wizard legacy à la vente.
|
||||
// On le ré-extrait tel quel (zéro reconstruction). Sous-requête = le ticket_msg le plus récent qui le contient.
|
||||
const ACTIVATION_RE = /https?:\/\/[^\s"'<>]*connect_ministra\.php[^\s"'<>]*/i
|
||||
function extractActivationUrl (msg) { if (!msg) return ''; const m = String(msg).match(ACTIVATION_RE); return m ? m[0] : '' }
|
||||
// Détail du ticket = 1er message du fil legacy (HTML osTicket) → texte lisible, tronqué, pour l'afficher dans Ops.
|
||||
function stripHtml (html, max = 1500) {
|
||||
if (!html) return ''
|
||||
let s = String(html)
|
||||
.replace(/<\s*br\s*\/?\s*>/gi, '\n').replace(/<\/\s*(p|div|li|tr)\s*>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /gi, ' ').replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>')
|
||||
.replace(/�*39;|'|'/gi, "'").replace(/"/gi, '"')
|
||||
.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim()
|
||||
if (s.length > max) s = s.slice(0, max) + '…'
|
||||
return s
|
||||
}
|
||||
|
||||
async function fetchTargoTickets () {
|
||||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||||
const [rows] = await p.query(
|
||||
`SELECT t.id, t.subject, t.dept_id, dd.name AS dept, t.due_date, t.due_time, t.priority, t.bon_id, t.account_id, t.delivery_id,
|
||||
t.date_create, t.last_update,
|
||||
a.first_name, a.last_name, a.company, a.address1, a.address2, a.city, a.state, a.zip,
|
||||
dv.latitude AS dv_lat, dv.longitude AS dv_lon, dv.address1 AS dv_addr, dv.city AS dv_city, dv.zip AS dv_zip,
|
||||
(SELECT mm.msg FROM ticket_msg mm
|
||||
WHERE mm.ticket_id = t.id AND mm.msg LIKE '%connect_ministra%'
|
||||
ORDER BY mm.id DESC LIMIT 1) AS activation_msg,
|
||||
(SELECT mm3.msg FROM ticket_msg mm3
|
||||
WHERE mm3.ticket_id = t.id ORDER BY mm3.id ASC LIMIT 1) AS first_msg
|
||||
FROM ticket t
|
||||
LEFT JOIN ticket_dept dd ON dd.id = t.dept_id
|
||||
LEFT JOIN account a ON a.id = t.account_id
|
||||
LEFT JOIN delivery dv ON dv.id = COALESCE(
|
||||
NULLIF(t.delivery_id, 0),
|
||||
(SELECT d2.id FROM delivery d2 WHERE d2.account_id = t.account_id AND d2.latitude IS NOT NULL AND d2.latitude <> 0 AND ABS(d2.latitude) > 1 ORDER BY d2.id DESC LIMIT 1),
|
||||
(SELECT d3.id FROM delivery d3 WHERE d3.account_id = t.account_id ORDER BY d3.id DESC LIMIT 1)
|
||||
)
|
||||
WHERE t.status = 'open' AND t.assign_to = ?
|
||||
ORDER BY t.due_date DESC`,
|
||||
[TARGO_TECH_STAFF_ID],
|
||||
)
|
||||
return rows || []
|
||||
}
|
||||
|
||||
// caches par run (vidés à chaque cycle) pour éviter les requêtes répétées
|
||||
let _custCache = new Map()
|
||||
let _slCache = new Map()
|
||||
function resetCaches () { _custCache = new Map(); _slCache = new Map() }
|
||||
|
||||
async function resolveCustomer (accountId) {
|
||||
if (!accountId) return null
|
||||
const k = String(accountId)
|
||||
if (_custCache.has(k)) return _custCache.get(k)
|
||||
const r = await erp.list('Customer', { filters: [['legacy_account_id', '=', k]], fields: ['name', 'customer_name'], limit: 1 })
|
||||
const c = (r && r[0]) || null
|
||||
_custCache.set(k, c)
|
||||
return c
|
||||
}
|
||||
async function resolveServiceLocation (custName, city) {
|
||||
if (!custName) return null
|
||||
let list = _slCache.get(custName)
|
||||
if (!list) {
|
||||
list = (await erp.list('Service Location', { filters: [['customer', '=', custName]], fields: ['name', 'address_line', 'city', 'latitude', 'longitude'], limit: 10 })) || []
|
||||
_slCache.set(custName, list)
|
||||
}
|
||||
if (!list.length) return null
|
||||
if (city) { const hit = list.find(l => norm(l.city) === norm(city)); if (hit) return hit } // préfère la ville qui matche
|
||||
return list[0]
|
||||
}
|
||||
|
||||
// Construit le payload Dispatch Job à partir d'un ticket legacy (+ infos de matching).
|
||||
async function buildJob (t) {
|
||||
const cust = await resolveCustomer(t.account_id)
|
||||
const sl = cust ? await resolveServiceLocation(cust.name, t.city) : null
|
||||
const jt = jobType(t.dept_id)
|
||||
const cname = cust ? cust.customer_name : ([t.first_name, t.last_name].filter(Boolean).join(' ') || t.company || '')
|
||||
// Coords : la table legacy `delivery` (point de service réel, via ticket.delivery_id) est la
|
||||
// source la plus fiable (lat/long par adresse). On préfère donc l'adresse de service à l'adresse
|
||||
// de facturation du compte, et les coords delivery aux coords Service Location ERPNext (placeholders).
|
||||
const dc = coord(t.dv_lat, t.dv_lon)
|
||||
const svcAddr = [t.dv_addr, t.dv_city, t.dv_zip].filter(Boolean).join(', ')
|
||||
const billAddr = [t.address1, t.address2, t.city, t.state, t.zip].filter(Boolean).join(', ')
|
||||
const addr = svcAddr || billAddr
|
||||
let subject = (t.subject || '').trim() || ([t.dept, cname].filter(Boolean).join(' — '))
|
||||
if (!sl && addr) subject = (subject + ' · ' + addr) // pas de Service Location → on garde l'adresse visible dans le sujet
|
||||
subject = subject.slice(0, 140) // Subject = champ Data Frappe (max 140 car.) ; le détail complet est dans legacy_detail/coords
|
||||
|
||||
const payload = {
|
||||
ticket_id: 'LEG-' + t.id,
|
||||
subject,
|
||||
job_type: jt,
|
||||
duration_h: DUR[jt] || 1,
|
||||
priority: prio(t.priority),
|
||||
status: 'open',
|
||||
order_source: 'Manual',
|
||||
legacy_ticket_id: String(t.id),
|
||||
legacy_dept: t.dept || '', // département legacy granulaire → coloriage « comme legacy » (Installation Fibre / Réparation Fibre / Télé / Téléphonie…)
|
||||
}
|
||||
const actUrl = extractActivationUrl(t.activation_msg); if (actUrl) payload.legacy_activation_url = actUrl // lien connect_ministra (déjà dans le fil)
|
||||
// En-tête de dates (ouverture + dernière MàJ) pour juger l'ancienneté → décider de fermer ; puis la description.
|
||||
const dateHdr = '🗓 Ouvert ' + (tzDate(t.date_create) || '?') + (t.last_update ? ' · MàJ ' + tzDate(t.last_update) : '')
|
||||
const detail = [dateHdr, stripHtml(t.first_msg)].filter(Boolean).join('\n\n')
|
||||
if (detail) payload.legacy_detail = detail // description + dates → visible dans Ops (mouseover panneau + détail Dispatch)
|
||||
const sd = tzDate(t.due_date); if (sd) payload.scheduled_date = sd
|
||||
const st = startTime(t.due_time); if (st) payload.start_time = st
|
||||
if (cust) payload.customer = cust.name
|
||||
let coordSrc = null
|
||||
// CAMPING (priorité max) : l'adresse de service est un terrain de camping → géoloc FIXE du camping,
|
||||
// pas la résidence du client. Signal = sujet/ville/adresse de service du ticket.
|
||||
const camp = campingFor(await getCampings(), [t.subject, t.dv_city, t.dv_addr])
|
||||
if (camp) { payload.latitude = camp.latitude; payload.longitude = camp.longitude; coordSrc = 'camping' }
|
||||
if (!coordSrc && dc) { payload.latitude = dc.lat; payload.longitude = dc.lon; coordSrc = 'delivery' } // point de service legacy
|
||||
if (sl) {
|
||||
payload.service_location = sl.name
|
||||
if (!coordSrc) { const sc = coord(sl.latitude, sl.longitude); if (sc) { payload.latitude = sc.lat; payload.longitude = sc.lon; coordSrc = 'service_location' } } // repli si pas de delivery
|
||||
}
|
||||
if (!coordSrc && addr) { // replis géocodage sur l'adresse de service (sinon facturation) : RQA (autoritaire) puis Mapbox (couverture)
|
||||
const useSvc = !!svcAddr
|
||||
const line = useSvc ? t.dv_addr : t.address1
|
||||
const zip = useSvc ? t.dv_zip : t.zip
|
||||
const ci = useSvc ? t.dv_city : t.city
|
||||
const g = await geocodeRQA(line, zip, ci)
|
||||
if (g) { payload.latitude = g.lat; payload.longitude = g.lon; coordSrc = 'rqa_geocode' }
|
||||
else { const mb = await geocodeMapbox(line, ci, zip); if (mb) { payload.latitude = mb.lat; payload.longitude = mb.lon; coordSrc = 'mapbox_geocode' } }
|
||||
}
|
||||
return { legacy_id: String(t.id), payload, matched: { customer: !!cust, service_location: !!sl, customer_name: cname, coords: !!coordSrc, coord_src: coordSrc, delivery_id: t.delivery_id || null }, dept: t.dept, addr }
|
||||
}
|
||||
|
||||
async function findExisting (legacyId) {
|
||||
const r = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'status', 'assigned_tech', 'scheduled_date', 'subject', 'legacy_dept', 'legacy_activation_url', 'legacy_detail', 'latitude', 'longitude', 'service_location'], limit: 1 })
|
||||
return (r && r[0]) || null
|
||||
}
|
||||
|
||||
// VERROU de sérialisation : frappe_pg ne supporte pas la concurrence. Le tick récurrent ET les runs
|
||||
// manuels (preview/run) passent tous par `sync()` → on les met en FILE pour qu'ils ne se chevauchent
|
||||
// JAMAIS (sinon « socket hang up » + écritures perdues dans un rollback). Chaque appel attend le précédent.
|
||||
let _syncLock = Promise.resolve()
|
||||
function sync (opts = {}) {
|
||||
const run = _syncLock.then(() => syncImpl(opts), () => syncImpl(opts))
|
||||
_syncLock = run.then(() => {}, () => {}) // le suivant attend, quel que soit le résultat
|
||||
return run
|
||||
}
|
||||
|
||||
// Cœur : parcourt les tickets, crée/maj les Dispatch Jobs. SÉQUENTIEL (frappe_pg ne supporte pas la concurrence).
|
||||
async function syncImpl ({ dryRun = false } = {}) {
|
||||
resetCaches()
|
||||
const tickets = await fetchTargoTickets()
|
||||
let created = 0, updated = 0, skipped = 0, errors = 0, unmatched = 0, coordsFilled = 0, noCoords = 0
|
||||
const coordTally = {} // observabilité : répartition des sources de coords (delivery/service_location/rqa_geocode/none)
|
||||
const errSamples = [] // observabilité : échantillon des erreurs create/update (« ne rien échapper »)
|
||||
const details = []
|
||||
for (const t of tickets) {
|
||||
try {
|
||||
const b = await buildJob(t)
|
||||
if (!b.matched.customer) unmatched++
|
||||
coordTally[b.matched.coord_src || 'none'] = (coordTally[b.matched.coord_src || 'none'] || 0) + 1
|
||||
if (!b.matched.coords) noCoords++ // ni delivery ni Service Location ni RQA → routage indisponible (à diagnostiquer)
|
||||
const ex = await findExisting(b.legacy_id)
|
||||
if (ex) {
|
||||
// Déjà importé. Backfill du département (métadonnée couleur, sans risque) + maj date SEULEMENT
|
||||
// s'il est encore au pool (open + non assigné) → on ne clobbe jamais le travail du répartiteur.
|
||||
const patch = {}
|
||||
if (!ex.legacy_dept && b.payload.legacy_dept) patch.legacy_dept = b.payload.legacy_dept
|
||||
if (!ex.legacy_activation_url && b.payload.legacy_activation_url) patch.legacy_activation_url = b.payload.legacy_activation_url // backfill lien activation (sans risque)
|
||||
if (b.payload.legacy_detail && ex.legacy_detail !== b.payload.legacy_detail) patch.legacy_detail = b.payload.legacy_detail // (re)backfill description + dates (idempotent : ne réécrit que si différent)
|
||||
// Coords (localisation, sans risque pour l'ordonnancement) : on remplit si absentes/0 côté ERPNext,
|
||||
// ET on UPGRADE vers les coords `delivery` (point de service exact) si elles diffèrent des coords
|
||||
// existantes (souvent issues du Service Location, moins précises). delivery écrase ; SL/RQA non.
|
||||
const hasCoord = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 0.0001
|
||||
const exHas = hasCoord(ex.latitude) && hasCoord(ex.longitude)
|
||||
// delivery (point exact) ET camping (géoloc fixe du camping vs résidence) ÉCRASENT des coords existantes différentes ; SL/RQA/Mapbox non.
|
||||
const isUpgrade = (b.matched.coord_src === 'delivery' || b.matched.coord_src === 'camping') && exHas &&
|
||||
(Math.abs(parseFloat(ex.latitude) - b.payload.latitude) > 1e-5 || Math.abs(parseFloat(ex.longitude) - b.payload.longitude) > 1e-5)
|
||||
if (b.payload.latitude != null && (!exHas || isUpgrade)) { patch.latitude = b.payload.latitude; patch.longitude = b.payload.longitude; coordsFilled++ }
|
||||
if (!ex.service_location && b.payload.service_location) patch.service_location = b.payload.service_location // backfill lien Service Location
|
||||
if (ex.status === 'open' && !ex.assigned_tech && b.payload.scheduled_date && b.payload.scheduled_date !== ex.scheduled_date) patch.scheduled_date = b.payload.scheduled_date
|
||||
// Rafraîchit le sujet (qui inclut l'adresse de SERVICE) pour les jobs encore au pool (open + non assigné),
|
||||
// sans surprendre un tech sur un job déjà dispatché. Corrige les sujets anciens basés sur la facturation.
|
||||
if (ex.status === 'open' && !ex.assigned_tech && b.payload.subject && ex.subject !== b.payload.subject) patch.subject = b.payload.subject
|
||||
if (!dryRun && Object.keys(patch).length) {
|
||||
const r = await erp.update('Dispatch Job', ex.name, patch)
|
||||
if (r && r.ok) { updated++; details.push({ legacy_id: b.legacy_id, action: 'update', job: ex.name, patch }) }
|
||||
else { errors++; const msg = (r && r.error) || 'update failed'; errSamples.push({ legacy_id: b.legacy_id, action: 'update', error: String(msg).slice(0, 200) }); details.push({ legacy_id: b.legacy_id, action: 'update-failed', job: ex.name, error: msg }) }
|
||||
} else skipped++
|
||||
} else if (dryRun) {
|
||||
created++; details.push({ legacy_id: b.legacy_id, action: 'would-create', subject: b.payload.subject, job_type: b.payload.job_type, dept: b.dept, scheduled_date: b.payload.scheduled_date || null, start_time: b.payload.start_time || null, customer: b.matched.customer_name, customer_matched: b.matched.customer, sl_matched: b.matched.service_location, coords: b.matched.coords, coord_src: b.matched.coord_src, delivery_id: b.matched.delivery_id, addr: b.addr })
|
||||
} else {
|
||||
const r = await erp.create('Dispatch Job', b.payload)
|
||||
if (r && r.ok) { created++; details.push({ legacy_id: b.legacy_id, action: 'created', job: r.name, subject: b.payload.subject, customer_matched: b.matched.customer }) }
|
||||
else { errors++; const msg = (r && r.error) || 'create failed'; errSamples.push({ legacy_id: b.legacy_id, action: 'create', error: String(msg).slice(0, 200) }); details.push({ legacy_id: b.legacy_id, action: 'create-failed', error: msg }) }
|
||||
}
|
||||
} catch (e) {
|
||||
errors++; details.push({ legacy_id: String(t.id), error: String((e && e.message) || e) })
|
||||
}
|
||||
}
|
||||
let closedResolved = 0
|
||||
if (!dryRun) { try { const cr = await closeResolved(); closedResolved = cr.closed } catch (e) { log('closeResolved error:', e.message) } } // retire les DJ dont le ticket legacy est fermé
|
||||
const summary = { ok: true, dryRun, tech_staff_id: TARGO_TECH_STAFF_ID, tickets: tickets.length, created, updated, skipped, errors, unmatched_customer: unmatched, coords_filled: coordsFilled, no_coords: noCoords, coord_src: coordTally, error_samples: errSamples.slice(0, 6), closed: closedResolved }
|
||||
if (!dryRun) { _lastRun = { at: new Date().toISOString(), ...summary }; log(`legacy-dispatch-sync: ${JSON.stringify(summary)}`) } // heartbeat
|
||||
return { ...summary, details }
|
||||
}
|
||||
|
||||
// Réconciliation : prouve qu'AUCUN ticket n'est échappé. Compare legacy(assign_to=3301, open) ↔ Dispatch Jobs (legacy_ticket_id).
|
||||
async function reconcile () {
|
||||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||||
const [rows] = await p.query('SELECT id FROM ticket WHERE status = ? AND assign_to = ?', ['open', TARGO_TECH_STAFF_ID])
|
||||
const legacyIds = new Set((rows || []).map(r => String(r.id)))
|
||||
// Dispatch Jobs issus du pont (legacy_ticket_id renseigné)
|
||||
const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', '']], fields: ['name', 'legacy_ticket_id', 'status', 'assigned_tech'], limit: 5000 })
|
||||
const erpIds = new Set((djs || []).map(j => String(j.legacy_ticket_id)))
|
||||
const missing = [...legacyIds].filter(id => !erpIds.has(id)) // legacy ouvert mais PAS dans ERPNext = échappé → à corriger
|
||||
// orphelins = DJ encore "open"/non assigné dont le ticket legacy n'est plus ouvert(3301) (fermé/réassigné côté legacy)
|
||||
const stillOpen = (djs || []).filter(j => j.status === 'open' && !j.assigned_tech)
|
||||
const orphan = stillOpen.filter(j => !legacyIds.has(String(j.legacy_ticket_id))).map(j => ({ job: j.name, legacy_ticket_id: j.legacy_ticket_id }))
|
||||
return { ok: true, legacy_open_3301: legacyIds.size, erpnext_bridged: erpIds.size, missing_count: missing.length, missing, orphan_count: orphan.length, orphan, last_sync: _lastRun }
|
||||
}
|
||||
|
||||
// Auto-fermeture : un Dispatch Job issu du pont dont le ticket legacy est passé `closed` → on le marque « Completed »
|
||||
// (sort du pool / des listes ouvertes). NE touche PAS « In Progress » (tech en action). SÉQUENTIEL.
|
||||
async function closeResolved () {
|
||||
const p = pool(); if (!p) return { checked: 0, closed: 0 }
|
||||
const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['status', 'in', ['open', 'assigned', 'On Hold']]], fields: ['name', 'legacy_ticket_id', 'status'], limit: 5000 })
|
||||
if (!djs.length) return { checked: 0, closed: 0 }
|
||||
const ids = [...new Set(djs.map(j => parseInt(j.legacy_ticket_id)).filter(Boolean))]
|
||||
const [rows] = await p.query('SELECT id, status FROM ticket WHERE id IN (?)', [ids])
|
||||
const st = {}; for (const r of rows) st[String(r.id)] = r.status
|
||||
let closed = 0; const details = []
|
||||
for (const j of djs) {
|
||||
if (st[j.legacy_ticket_id] === 'closed') { // fermé côté legacy → on retire d'ERPNext
|
||||
try { await erp.update('Dispatch Job', j.name, { status: 'Completed' }); closed++; details.push({ job: j.name, legacy_ticket_id: j.legacy_ticket_id }) } catch (e) {}
|
||||
}
|
||||
}
|
||||
return { checked: djs.length, closed, details }
|
||||
}
|
||||
|
||||
// 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')
|
||||
const id = String(legacyId || '').replace(/[^0-9]/g, ''); if (!id) return { ok: false, error: 'id invalide' }
|
||||
const [trows] = await p.query('SELECT subject, status FROM ticket WHERE id = ? LIMIT 1', [id])
|
||||
const [rows] = await p.query(
|
||||
`SELECT mm.id, mm.date_orig, mm.staff_id, s.first_name, s.last_name, s.username, mm.msg
|
||||
FROM ticket_msg mm LEFT JOIN staff s ON s.id = mm.staff_id
|
||||
WHERE mm.ticket_id = ? ORDER BY mm.id ASC LIMIT 200`, [id])
|
||||
const messages = (rows || []).map(r => ({
|
||||
at: r.date_orig ? new Date(Number(r.date_orig) * 1000).toISOString() : null,
|
||||
author: [r.first_name, r.last_name].filter(Boolean).join(' ') || r.username || (r.staff_id ? ('Staff ' + r.staff_id) : 'Système / client'),
|
||||
text: stripHtml(r.msg, 6000),
|
||||
})).filter(m => m.text)
|
||||
return { ok: true, ticket: id, subject: (trows && trows[0] && trows[0].subject) || '', status: (trows && trows[0] && trows[0].status) || '', count: messages.length, messages }
|
||||
}
|
||||
|
||||
// ── Récurrence (setInterval) ──
|
||||
let _timer = null
|
||||
let _lastRun = null // heartbeat : dernier passage réussi (pour /status + Uptime-Kuma)
|
||||
function startSync () {
|
||||
// OPT-IN : la récurrence ne démarre QUE si LEGACY_DISPATCH_SYNC ∈ {on,1,true}.
|
||||
// (Évite toute écriture automatique surprise au boot ; preview/run manuels restent dispo via les routes.)
|
||||
if (!/^(on|1|true)$/i.test(String(process.env.LEGACY_DISPATCH_SYNC || ''))) { log('legacy-dispatch-sync: récurrence désactivée (poser LEGACY_DISPATCH_SYNC=on pour activer)'); return }
|
||||
if (!mysql) { log('legacy-dispatch-sync: mysql2 absent → pont inactif'); return }
|
||||
const minutes = Number(process.env.LEGACY_DISPATCH_SYNC_MIN) || 15
|
||||
const tick = () => sync({ dryRun: false }).catch(e => log('legacy-dispatch-sync tick error:', e.message))
|
||||
// 1er passage différé (laisse le boot se stabiliser), puis toutes les `minutes`.
|
||||
setTimeout(tick, 90 * 1000)
|
||||
_timer = setInterval(tick, minutes * 60 * 1000)
|
||||
log(`legacy-dispatch-sync: pont actif (toutes les ${minutes} min, staff ${TARGO_TECH_STAFF_ID})`)
|
||||
}
|
||||
function stopSync () { if (_timer) { clearInterval(_timer); _timer = null } }
|
||||
|
||||
async function handle (req, res, method, path) {
|
||||
try {
|
||||
if (path === '/dispatch/legacy-sync/preview' && method === 'GET') return json(res, 200, await sync({ dryRun: true }))
|
||||
if (path === '/dispatch/legacy-sync/run' && method === 'POST') return json(res, 200, await sync({ dryRun: false }))
|
||||
if (path === '/dispatch/legacy-sync/reconcile' && method === 'GET') return json(res, 200, await reconcile())
|
||||
if (path === '/dispatch/legacy-sync/close-resolved' && method === 'POST') return json(res, 200, await closeResolved())
|
||||
if (path === '/dispatch/legacy-sync/ticket-thread' && method === 'GET') { const id = new URL(req.url, 'http://localhost').searchParams.get('id'); return json(res, 200, await ticketThread(id)) }
|
||||
if (path === '/dispatch/legacy-sync/status' && method === 'GET') { // heartbeat pour Uptime-Kuma (keyword "stale":false)
|
||||
const ageMin = _lastRun ? Math.round((Date.now() - Date.parse(_lastRun.at)) / 60000) : null
|
||||
const max = (Number(process.env.LEGACY_DISPATCH_SYNC_MIN) || 15) * 3 // toléré = 3 ticks
|
||||
return json(res, 200, { ok: true, enabled: /^(on|1|true)$/i.test(String(process.env.LEGACY_DISPATCH_SYNC || '')), last_sync: _lastRun, age_min: ageMin, stale: ageMin == null || ageMin > max })
|
||||
}
|
||||
return json(res, 404, { error: 'route inconnue' })
|
||||
} catch (e) {
|
||||
return json(res, 500, { error: String((e && e.message) || e) })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handle, sync, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType } // parseurs purs exposés pour les tests
|
||||
|
|
@ -277,6 +277,20 @@ function skillForJob (job) {
|
|||
const map = getBookingPolicy().skill_by_type || {}
|
||||
return String(map[job.service_type] || '').trim()
|
||||
}
|
||||
// Repli : déduit une COMPÉTENCE (parmi les skills réels des techs) depuis le département/type legacy.
|
||||
// Sert à colorer les tickets par la couleur de leur compétence (éditable via le gestionnaire de tags).
|
||||
function deptToSkill (txt) {
|
||||
const d = String(txt || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
if (!d) return ''
|
||||
if (/teleph/.test(d)) return 'telephone'
|
||||
if (/tele|televis/.test(d)) return 'tv'
|
||||
if (/fusion|episs/.test(d)) return 'épissure'
|
||||
if (/monteur|aerien/.test(d)) return 'monteur'
|
||||
if (/netadmin|net admin/.test(d)) return 'netadmin'
|
||||
if (/repar|desinstall/.test(d)) return 'réparation'
|
||||
if (/install|fibre/.test(d)) return 'installation'
|
||||
return ''
|
||||
}
|
||||
// Enrichit des jobs avec une adresse LISIBLE (le champ service_location est un code « LOC-… »).
|
||||
// Batch : 1 seule requête sur Service Location pour tous les codes distincts.
|
||||
async function attachLocations (jobs) {
|
||||
|
|
@ -511,7 +525,7 @@ async function occupancyByTechDay (start, days) {
|
|||
const dates = rangeDates(start, days)
|
||||
const jobs = await erp.list('Dispatch Job', {
|
||||
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress']]],
|
||||
fields: ['name', 'subject', 'customer_name', 'service_type', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority'], limit: 5000,
|
||||
fields: ['name', 'subject', 'customer_name', 'service_type', 'job_type', 'legacy_dept', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'route_order', 'latitude', 'longitude', 'booking_status', 'legacy_detail', 'legacy_ticket_id'], limit: 5000,
|
||||
})
|
||||
const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 } // ordre d'affichage
|
||||
const m = {}
|
||||
|
|
@ -521,11 +535,13 @@ async function occupancyByTechDay (start, days) {
|
|||
const o = m[k] || (m[k] = { h: 0, blocks: [], jobs: [] })
|
||||
const dur = Number(j.duration_h) || 0
|
||||
o.h += dur
|
||||
const skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject) // compétence → couleur du bloc (palette skills)
|
||||
const s = j.start_time ? timeToH(j.start_time) : null
|
||||
if (s != null) o.blocks.push({ s, e: s + dur })
|
||||
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, priority: j.priority || 'low' })
|
||||
if (s != null) o.blocks.push({ s, e: s + dur, skill, job: j.name }) // 1 bloc = 1 job, coloré par sa compétence
|
||||
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', legacy_id: j.legacy_ticket_id || '', detail: (j.legacy_detail || '').slice(0, 400) })
|
||||
}
|
||||
for (const k in m) m[k].jobs.sort((a, b) => (PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3) || ((a.start_h ?? 99) - (b.start_h ?? 99))) // priorité puis heure
|
||||
// ordre = route_order manuel s'il existe, sinon priorité puis heure
|
||||
for (const k in m) m[k].jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || (PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3) || ((a.start_h ?? 99) - (b.start_h ?? 99)))
|
||||
return m
|
||||
}
|
||||
|
||||
|
|
@ -734,9 +750,9 @@ async function handle (req, res, method, path, url) {
|
|||
}
|
||||
// Jobs À ASSIGNER (non assignés) avec leur groupe/dépendances (parent_job, depends_on, step_order, chaîne On Hold).
|
||||
if (path === '/roster/unassigned-jobs' && method === 'GET') {
|
||||
const rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'subject', 'customer_name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech'], orderBy: 'modified desc', limit: 400 })
|
||||
const rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'subject', 'customer_name', 'service_location', 'service_type', 'job_type', 'legacy_dept', 'legacy_detail', 'legacy_ticket_id', 'legacy_activation_url', 'priority', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech'], orderBy: 'modified desc', limit: 400 })
|
||||
const jobs = (rows || []).filter(j => !j.assigned_tech) // non assignés
|
||||
for (const j of jobs) j.required_skill = skillForJob(j)
|
||||
for (const j of jobs) j.required_skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject) // skill explicite, sinon déduit du type → couleur
|
||||
await attachLocations(jobs)
|
||||
return json(res, 200, { jobs })
|
||||
}
|
||||
|
|
@ -757,6 +773,30 @@ async function handle (req, res, method, path, url) {
|
|||
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
|
||||
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, tech: b.tech, start_time: placed, duration_h: dur })
|
||||
}
|
||||
// Réordonner / re-prioriser les jobs d'un tech×jour (depuis le menu de la cellule).
|
||||
// body.updates = [{ job, route_order, priority? }] — SÉQUENTIEL (frappe_pg).
|
||||
if (path === '/roster/reorder-jobs' && method === 'POST') {
|
||||
const b = await parseBody(req); const ups = b.updates || []
|
||||
let ok = 0; let errors = 0
|
||||
for (const u of ups) {
|
||||
if (!u.job) continue
|
||||
const patch = {}
|
||||
if (u.route_order != null) patch.route_order = Number(u.route_order) || 0
|
||||
if (u.priority) patch.priority = u.priority
|
||||
if (u.duration_h != null && Number(u.duration_h) > 0) patch.duration_h = Number(u.duration_h) // durée éditée dans le timeline
|
||||
if (u.start_time) patch.start_time = (String(u.start_time).length === 5 ? u.start_time + ':00' : u.start_time) // heure recalculée par le planificateur de tournée
|
||||
if (!Object.keys(patch).length) continue
|
||||
const r = await retryWrite(() => erp.update('Dispatch Job', u.job, patch))
|
||||
if (r.ok) ok++; else errors++
|
||||
}
|
||||
return json(res, 200, { ok: true, updated: ok, errors })
|
||||
}
|
||||
// Retirer un job d'un tech (depuis l'éditeur de journée) → retour au pool (non assigné).
|
||||
if (path === '/roster/unassign-job' && method === 'POST') {
|
||||
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
|
||||
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { assigned_tech: null, status: 'open', start_time: null }))
|
||||
return json(res, r.ok ? 200 : 500, { ...r, job: b.job })
|
||||
}
|
||||
// Backfill : pose un start_time (premier trou libre) sur les jobs DÉJÀ assignés mais SANS heure
|
||||
// → leurs blocs d'occupation apparaissent enfin sur la grille. Idempotent (ne touche que start_time vide).
|
||||
if (path === '/roster/backfill-start-times' && method === 'POST') {
|
||||
|
|
|
|||
36
services/targo-hub/lib/util/legacy-parse.js
Normal file
36
services/targo-hub/lib/util/legacy-parse.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
'use strict'
|
||||
/**
|
||||
* util/legacy-parse.js — parseurs/mapping PURS du pont legacy (osTicket → Dispatch Job).
|
||||
* Aucune dépendance I/O (pg/mysql/erp) → testable en isolation (Phase 1 : logique pure séparée des I/O).
|
||||
*/
|
||||
|
||||
// dept_id legacy → job_type Dispatch Job (valeurs valides : Installation/Réparation/Retrait/Dépannage/Autre)
|
||||
const DEPT_JOBTYPE = {
|
||||
27: 'Installation', 12: 'Installation', 7: 'Installation', // Installation Fibre / Installation / Monteur
|
||||
26: 'Réparation', 10: 'Réparation', 33: 'Réparation', // Réparation Fibre / Réparation / Fusionneur
|
||||
15: 'Retrait', // Désinstallation
|
||||
}
|
||||
const DUR = { Installation: 2, 'Réparation': 1.5, Retrait: 1, 'Dépannage': 1, Autre: 1 } // durée par défaut (le legacy n'en a pas)
|
||||
const jobType = (deptId) => DEPT_JOBTYPE[deptId] || 'Autre'
|
||||
const prio = (p) => { p = Number(p) || 0; return p >= 3 ? 'high' : p === 2 ? 'medium' : 'low' }
|
||||
// due_date legacy = epoch à minuit LOCAL → date America/Toronto (évite le décalage UTC)
|
||||
const tzDate = (unix) => (unix ? new Date(Number(unix) * 1000).toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) : null)
|
||||
function startTime (dueTime) {
|
||||
if (!dueTime) return null
|
||||
const m = String(dueTime).match(/^(\d{1,2}):(\d{2})/)
|
||||
if (m) return m[1].padStart(2, '0') + ':' + m[2] + ':00'
|
||||
const t = String(dueTime).toLowerCase()
|
||||
if (t === 'am') return '08:00:00'
|
||||
if (t === 'pm') return '13:00:00'
|
||||
return null // 'day' / inconnu → pas d'heure précise
|
||||
}
|
||||
// Coords legacy = chaînes ("-73.5599440" / "45.2528570"). Parse + valide les bornes Québec
|
||||
// (lat 44→63, lon -80→-57) pour rejeter 0/0, placeholders et valeurs aberrantes → routage fiable.
|
||||
function coord (lat, lon) {
|
||||
const la = parseFloat(lat), lo = parseFloat(lon)
|
||||
if (!isFinite(la) || !isFinite(lo)) return null
|
||||
if (la < 44 || la > 63 || lo < -80 || lo > -57) return null
|
||||
return { lat: la, lon: lo }
|
||||
}
|
||||
|
||||
module.exports = { DEPT_JOBTYPE, DUR, jobType, prio, tzDate, startTime, coord }
|
||||
17
services/targo-hub/lib/util/text.js
Normal file
17
services/targo-hub/lib/util/text.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
'use strict'
|
||||
/**
|
||||
* util/text.js — helpers texte PARTAGÉS (Phase 1 modularisation : dé-duplication).
|
||||
* Remplace les ré-implémentations locales de `norm` (address-db, legacy-dispatch-sync, …).
|
||||
*/
|
||||
|
||||
// Normalisation pour comparaison/recherche : minuscules + sans accents (NFD) + espaces compactés + trim.
|
||||
// (Surensemble des variantes locales — le compactage d'espaces est inoffensif et plus robuste.)
|
||||
const norm = (s) => (s || '')
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
module.exports = { norm }
|
||||
|
|
@ -4,7 +4,11 @@
|
|||
"description": "SSE relay + unified message hub for Targo/Gigafibre",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
"start": "node server.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
"dependencies": {
|
||||
"mjml": "^5.2.2",
|
||||
|
|
|
|||
|
|
@ -98,7 +98,8 @@ const server = http.createServer(async (req, res) => {
|
|||
if (path.startsWith('/auth/')) return auth.handle(req, res, method, path, url)
|
||||
if (path.startsWith('/conversations')) return conversation.handle(req, res, method, path, url)
|
||||
if (path.startsWith('/traccar')) return traccar.handle(req, res, method, path)
|
||||
if (path.startsWith('/address/')) return require('./lib/address-validate').handle(req, res, method, path)
|
||||
if (path.startsWith('/address/conformity')) return require('./lib/address-conformity').handle(req, res, method, path)
|
||||
if (path.startsWith('/address/') || path.startsWith('/rpc/')) return require('./lib/address-validate').handle(req, res, method, path)
|
||||
if (path.startsWith('/magic-link')) return require('./lib/magic-link').handle(req, res, method, path)
|
||||
if (path.startsWith('/portal/')) return require('./lib/portal-auth').handle(req, res, method, path)
|
||||
// Lightweight tech mobile page: /t/{token}[/action]
|
||||
|
|
@ -116,6 +117,7 @@ const server = http.createServer(async (req, res) => {
|
|||
// iCal feed: /dispatch/calendar/TECH-001.ics?token=xxx (token auth, no SSO)
|
||||
const icalMatch = path.match(/^\/dispatch\/calendar\/(.+)\.ics$/)
|
||||
if (icalMatch && method === 'GET') return ical.handleCalendar(req, res, icalMatch[1], url.searchParams)
|
||||
if (path.startsWith('/dispatch/legacy-sync')) return require('./lib/legacy-dispatch-sync').handle(req, res, method, path)
|
||||
if (path.startsWith('/dispatch')) return dispatch.handle(req, res, method, path)
|
||||
if (path.startsWith('/admin/pollers')) return require('./lib/poller-control').handle(req, res, method, path)
|
||||
// Legacy-MariaDB analytical reports — must be checked BEFORE the ERPNext
|
||||
|
|
@ -224,4 +226,7 @@ server.listen(cfg.PORT, '0.0.0.0', () => {
|
|||
// Start PPA (pre-authorized payment) cron scheduler
|
||||
try { require('./lib/payments').startPPACron() }
|
||||
catch (e) { log('PPA cron failed to start:', e.message) }
|
||||
// Pont legacy (osTicket) → Dispatch Job : tire les tickets « Tech Targo » à dispatcher
|
||||
try { require('./lib/legacy-dispatch-sync').startSync() }
|
||||
catch (e) { log('legacy-dispatch-sync failed to start:', e.message) }
|
||||
})
|
||||
|
|
|
|||
42
services/targo-hub/test/util.test.js
Normal file
42
services/targo-hub/test/util.test.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Tests des helpers PURS (Phase 1 : fondation de tests, zéro dépendance I/O).
|
||||
// Couvre la logique critique du pipeline d'adresses/pont legacy que je ne veux plus voir régresser.
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { norm } from '../lib/util/text.js'
|
||||
import { coord, prio, startTime, jobType, tzDate } from '../lib/util/legacy-parse.js'
|
||||
|
||||
describe('norm', () => {
|
||||
it('minuscule + sans accents', () => expect(norm('RUE René-Vinet')).toBe('rue rene-vinet'))
|
||||
it('compacte les espaces + trim', () => expect(norm(' Sainte Clotilde ')).toBe('sainte clotilde'))
|
||||
it('ville accentuée', () => expect(norm('Sainte-Clotilde-de-Châteauguay')).toBe('sainte-clotilde-de-chateauguay'))
|
||||
it('null/undefined → chaîne vide', () => { expect(norm(null)).toBe(''); expect(norm(undefined)).toBe('') })
|
||||
})
|
||||
|
||||
describe('coord (bornes Québec)', () => {
|
||||
it('coord QC valide (chaînes legacy)', () => expect(coord('45.2528570', '-73.5599440')).toEqual({ lat: 45.252857, lon: -73.559944 }))
|
||||
it('0/0 rejeté', () => expect(coord(0, 0)).toBeNull())
|
||||
it('hors bornes (Toronto lat 43.65) rejeté', () => expect(coord(43.65, -79.38)).toBeNull())
|
||||
it('longitude hors bornes rejetée', () => expect(coord(45.5, -50)).toBeNull())
|
||||
it('non-numérique rejeté', () => { expect(coord('abc', 'x')).toBeNull(); expect(coord(null, null)).toBeNull() })
|
||||
})
|
||||
|
||||
describe('prio (priorité legacy → Dispatch)', () => {
|
||||
it('≥3 → high', () => { expect(prio(3)).toBe('high'); expect(prio('5')).toBe('high') })
|
||||
it('2 → medium', () => expect(prio(2)).toBe('medium'))
|
||||
it('1/0/null → low', () => { expect(prio(1)).toBe('low'); expect(prio(0)).toBe('low'); expect(prio(null)).toBe('low') })
|
||||
})
|
||||
|
||||
describe('startTime (due_time legacy → HH:MM:SS)', () => {
|
||||
it('heure explicite', () => { expect(startTime('14:30')).toBe('14:30:00'); expect(startTime('9:05')).toBe('09:05:00') })
|
||||
it('am/pm', () => { expect(startTime('am')).toBe('08:00:00'); expect(startTime('pm')).toBe('13:00:00') })
|
||||
it('day / vide → null', () => { expect(startTime('day')).toBeNull(); expect(startTime('')).toBeNull() })
|
||||
})
|
||||
|
||||
describe('jobType (dept_id → job_type)', () => {
|
||||
it('mappings connus', () => { expect(jobType(27)).toBe('Installation'); expect(jobType(26)).toBe('Réparation'); expect(jobType(15)).toBe('Retrait') })
|
||||
it('inconnu → Autre', () => expect(jobType(999)).toBe('Autre'))
|
||||
})
|
||||
|
||||
describe('tzDate (epoch → date America/Toronto)', () => {
|
||||
it('null → null', () => expect(tzDate(null)).toBeNull())
|
||||
it('epoch → YYYY-MM-DD', () => expect(tzDate(1749182400)).toMatch(/^\d{4}-\d{2}-\d{2}$/))
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user