From aa6cc3e732bac2384e2efdd001e98ce6144fa140 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 2 Jul 2026 09:18:33 -0400 Subject: [PATCH] =?UTF-8?q?feat(dispatch):=20niveau=20requis=20persistant?= =?UTF-8?q?=20+=20lasso=20freeform=20+=20routes=20r=C3=A9elles=20Mapbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Niveau requis PAR JOB persistant : store hub durable (job-levels.json) + endpoint POST /roster/job-level + buildUnassigned enrichit required_level ; pastille « niv » écrit via l'API (survit reload/session). NB : l'API Custom Field ERPNext v16 échoue (IndexError) sur ce doctype custom → store hub (migratable en champ ERPNext plus tard). - Lasso FREEFORM : tracé libre (souris + tactile) → polygone SVG + point-in-polygon sur les pins/amas projetés (remplace le rectangle) + expansion des amas (getClusterLeaves). - D v2 : carte des tournées = routes ROUTIÈRES réelles (Mapbox Directions par tech, domicile→arrêts) + distance/temps RÉELS dans la légende (cache par signature ; fallback segments droits + estimation haversine). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/ops/src/api/roster.js | 1 + apps/ops/src/pages/PlanificationPage.vue | 104 +++++++++++++++++------ services/targo-hub/lib/roster.js | 17 +++- 3 files changed, 96 insertions(+), 26 deletions(-) diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js index 9c3e510..abbd15b 100644 --- a/apps/ops/src/api/roster.js +++ b/apps/ops/src/api/roster.js @@ -52,6 +52,7 @@ export const updateTemplate = (name, patch) => jput('/roster/template/' + encode export const askAssistant = (message, history) => jpost('/roster/assistant', { message, history }) export const getPolicy = () => jget('/roster/policy') export const savePolicy = (policy) => jpost('/roster/policy', policy) +export const setJobLevel = (name, level) => jpost('/roster/job-level', { name, level }) // niveau requis persistant par job export async function deleteShiftTemplate (name) { const r = await fetch(HUB + '/roster/template/' + encodeURIComponent(name), { method: 'DELETE' }) if (!r.ok) throw new Error('Suppression modèle: ' + r.status) diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index b3d1834..49c7e81 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -878,7 +878,7 @@
{{ satView ? 'Vue carte' : 'Vue satellite' }} - {{ assignLasso ? 'Lasso ACTIF — glisse un rectangle pour sélectionner un groupe (reclique pour quitter)' : 'Lasso : sélectionner un groupe de jobs en glissant un rectangle' }} + {{ assignLasso ? 'Lasso ACTIF — trace une zone à main levée pour sélectionner les jobs dedans (reclique pour quitter)' : 'Lasso : encercler un groupe de jobs (tracé libre)' }}
📍 Pins par compétence · amas = total de jobs (clic = zoom) · lasso = sélectionner une zone · ⚠ {{ assignNoCoord }} sans coords
@@ -918,7 +918,7 @@
{{ j.required_skill }} - niv {{ j._reqLevel || 1 }}Niveau min requis pour ce job (mode « juste ce qu'il faut » → réserve les experts)Niveau {{ n }}{{ n===1 ? ' — de base' : (n===5 ? ' — expert' : '') }} + niv {{ j.required_level || 1 }}Niveau min requis pour ce job (persistant · mode « juste ce qu'il faut » → réserve les experts)Niveau {{ n }}{{ n===1 ? ' — de base' : (n===5 ? ' — expert' : '') }} {{ j.customer_name || j.location_label || j.service_location || '' }} · après {{ j.depends_on }} · ≈ {{ j.est_min ? fmtMin(j.est_min) : (j.duration_h || 1) + 'h' }}Estimé (auto) : {{ j.est_labels.join(' + ') }} · 📅 {{ fmtDueLabel(j.scheduled_date) }}Replanifier (aujourd'hui / demain / date)Aujourd'huiDemain
@@ -1015,7 +1015,7 @@
- {{ r.techName }} · {{ r.stops.length }} arrêt(s) · 🚗 ≈{{ r.km }} km + {{ r.techName }} · {{ r.stops.length }} arrêt(s) · 🚗 {{ realRoutes[r.techId].km }} km / {{ realRoutes[r.techId].mins }} min · 🚗 ≈{{ r.km }} km Aucun job géolocalisé ce jour (utilise « Localiser » sur le pool pour les placer sur la carte).
@@ -2761,37 +2761,59 @@ function toggleLasso () { assignLasso.value = !assignLasso.value if (_assignMap) { assignLasso.value ? _assignMap.dragPan.disable() : _assignMap.dragPan.enable(); _assignMap.getCanvas().style.cursor = assignLasso.value ? 'crosshair' : '' } } -function setupBoxSelect (map) { +function setupBoxSelect (map) { // LASSO FREEFORM : tracé libre (souris ou doigt) → polygone de sélection const canvas = map.getCanvasContainer() - let startPt = null, boxEl = null - const pos = (e) => { const r = canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top } } + let pts = null, svg = null, poly = null + const pos = (e) => { const r = canvas.getBoundingClientRect(); const t = (e.touches && e.touches[0]) || e; return { x: t.clientX - r.left, y: t.clientY - r.top } } const onMove = (e) => { - const cur = pos(e) - if (!boxEl) { boxEl = document.createElement('div'); boxEl.className = 'lasso-box'; boxEl.style.cssText = 'position:absolute;top:0;left:0;background:rgba(99,102,241,.18);border:2px dashed #6366f1;border-radius:3px;pointer-events:none;z-index:10'; canvas.appendChild(boxEl) } // styles INLINE : le CSS scoped ne touche pas un élément créé en JS - boxEl.style.transform = `translate(${Math.min(startPt.x, cur.x)}px, ${Math.min(startPt.y, cur.y)}px)` - boxEl.style.width = Math.abs(cur.x - startPt.x) + 'px'; boxEl.style.height = Math.abs(cur.y - startPt.y) + 'px' + if (!pts) return; if (e.cancelable) e.preventDefault(); pts.push(pos(e)) + if (!svg) { + svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + svg.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:10' + poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline') + poly.setAttribute('fill', 'rgba(99,102,241,.15)'); poly.setAttribute('stroke', '#6366f1'); poly.setAttribute('stroke-width', '2'); poly.setAttribute('stroke-dasharray', '5 3') + svg.appendChild(poly); canvas.appendChild(svg) + } + poly.setAttribute('points', pts.map(q => q.x + ',' + q.y).join(' ')) } - const onUp = (e) => { + const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp) - const cur = pos(e); if (boxEl) { boxEl.remove(); boxEl = null } - const bbox = [[Math.min(startPt.x, cur.x), Math.min(startPt.y, cur.y)], [Math.max(startPt.x, cur.x), Math.max(startPt.y, cur.y)]] - startPt = null; selectInBox(map, bbox) + document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onUp) + if (svg) { svg.remove(); svg = null; poly = null } + const path = pts; pts = null + if (path && path.length >= 3) selectInLasso(map, path) } - canvas.addEventListener('mousedown', (e) => { - if (!assignLasso.value || e.button !== 0) return - e.preventDefault(); e.stopPropagation(); startPt = pos(e) + const onDown = (e) => { + if (!assignLasso.value || (e.button != null && e.button !== 0)) return + e.preventDefault(); e.stopPropagation(); pts = [pos(e)] document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp) - }, true) + document.addEventListener('touchmove', onMove, { passive: false }); document.addEventListener('touchend', onUp) + } + canvas.addEventListener('mousedown', onDown, true) + canvas.addEventListener('touchstart', onDown, true) } -async function selectInBox (map, bbox) { - const names = new Set() - const w = Math.abs(bbox[1][0] - bbox[0][0]), h = Math.abs(bbox[1][1] - bbox[0][1]) - const region = (w < 5 && h < 5) ? bbox[0] : bbox // clic quasi immobile = point +// Point-in-polygon (ray casting) sur coordonnées écran. +function _inPoly (pt, poly) { + let inside = false + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const xi = poly[i].x, yi = poly[i].y, xj = poly[j].x, yj = poly[j].y + if (((yi > pt.y) !== (yj > pt.y)) && (pt.x < (xj - xi) * (pt.y - yi) / ((yj - yi) || 1e-9) + xi)) inside = !inside + } + return inside +} +async function selectInLasso (map, path) { // sélectionne pins + amas dont le CENTRE est dans le tracé + const names = new Set(); const clusterIds = [] try { - (map.queryRenderedFeatures(region, { layers: ['aj-c'] }) || []).forEach(f => { try { JSON.parse(f.properties.names || '[]').forEach(n => names.add(n)) } catch (e) {} }) - const clusters = map.queryRenderedFeatures(region, { layers: ['aj-cluster'] }) || [] + const feats = map.queryRenderedFeatures({ layers: ['aj-c', 'aj-cluster'] }) || [] + for (const f of feats) { + const c = f.geometry && f.geometry.coordinates; if (!c) continue + const scr = map.project(c) + if (!_inPoly({ x: scr.x, y: scr.y }, path)) continue + if (f.properties && f.properties.cluster_id != null) clusterIds.push(f.properties.cluster_id) + else { try { JSON.parse(f.properties.names || '[]').forEach(n => names.add(n)) } catch (e) {} } + } const src = map.getSource('aj') - await Promise.all(clusters.map(c => new Promise(res => { try { src.getClusterLeaves(c.properties.cluster_id, 1000, 0, (err, leaves) => { if (!err && leaves) leaves.forEach(l => { try { JSON.parse(l.properties.names || '[]').forEach(n => names.add(n)) } catch (e) {} }); res() }) } catch (e) { res() } }))) + await Promise.all(clusterIds.map(cid => new Promise(res => { try { src.getClusterLeaves(cid, 1000, 0, (err, leaves) => { if (!err && leaves) leaves.forEach(l => { try { JSON.parse(l.properties.names || '[]').forEach(n => names.add(n)) } catch (e) {} }); res() }) } catch (e) { res() } }))) } catch (e) {} names.forEach(n => { selectedJobs[n] = true }) if (names.size) $q.notify({ type: 'positive', message: names.size + ' job(s) sélectionné(s) au lasso', timeout: 1800 }) @@ -2883,6 +2905,7 @@ async function initSuggestMap () { function refreshSuggestMap () { if (!_suggestMap || !_suggestMap.isStyleLoaded()) { setTimeout(refreshSuggestMap, 200); return } const routes = suggestRoutes.value + for (const k in realRoutes) delete realRoutes[k] // repart des estimations ; les routes RÉELLES arrivent en async const lines = []; const pts = []; const b = new window.mapboxgl.LngLatBounds() for (const r of routes) { const coords = [] @@ -2893,6 +2916,35 @@ function refreshSuggestMap () { const ls = _suggestMap.getSource('sr-line'); if (ls) ls.setData({ type: 'FeatureCollection', features: lines }) const ps = _suggestMap.getSource('sr-pt'); if (ps) ps.setData({ type: 'FeatureCollection', features: pts }) try { if (pts.length && !b.isEmpty()) _suggestMap.fitBounds(b, { padding: 50, maxZoom: 13, duration: 400 }) } catch (e) {} + loadRealRoutes(routes) // D v2 : remplace les segments droits par les routes ROUTIÈRES réelles (Mapbox Directions) + km/temps réels +} +// D v2 — routes RÉELLES : Mapbox Directions par tech (domicile→arrêts) → géométrie routière + distance/durée réelles. Cache par signature. +const _routeCache = new Map() +const realRoutes = reactive({}) // techId → { km, mins } (la légende préfère ces valeurs réelles quand présentes) +async function loadRealRoutes (routes) { + if (!MAPBOX_TOKEN || !routes.length || !_suggestMap) return + const results = await Promise.all(routes.map(async r => { + const pts = []; if (r.home) pts.push([r.home.lon, r.home.lat]); for (const s of r.stops) pts.push([s.lon, s.lat]) + if (pts.length < 2 || pts.length > 25) return null // Directions = 25 points max + const sig = pts.map(p => p[0].toFixed(5) + ',' + p[1].toFixed(5)).join(';') + if (_routeCache.has(sig)) return { techId: r.techId, ..._routeCache.get(sig) } + try { + const url = `https://api.mapbox.com/directions/v5/mapbox/driving/${pts.map(p => p[0].toFixed(6) + ',' + p[1].toFixed(6)).join(';')}?geometries=geojson&overview=full&access_token=${MAPBOX_TOKEN}` + const d = await fetch(url).then(x => x.json()); const rt = d && d.routes && d.routes[0]; if (!rt) return null + const info = { geometry: rt.geometry, km: Math.round(rt.distance / 100) / 10, mins: Math.round(rt.duration / 60) } + _routeCache.set(sig, info); return { techId: r.techId, ...info } + } catch (e) { return null } + })) + if (!_suggestMap || !_suggestMap.getSource('sr-line')) return + const byTech = {}; for (const x of results) { if (x) { byTech[x.techId] = x; realRoutes[x.techId] = { km: x.km, mins: x.mins } } } + const lines = [] + for (const r of routes) { + const real = byTech[r.techId] + if (real && real.geometry) { lines.push({ type: 'Feature', geometry: real.geometry, properties: { color: r.color } }); continue } + const coords = []; if (r.home) coords.push([r.home.lon, r.home.lat]); for (const s of r.stops) coords.push([s.lon, s.lat]) + if (coords.length >= 2) lines.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: r.color } }) + } + _suggestMap.getSource('sr-line').setData({ type: 'FeatureCollection', features: lines }) } function destroySuggestMap () { if (_suggestMapRO) { try { _suggestMapRO.disconnect() } catch (e) {} _suggestMapRO = null } if (_suggestMap) { try { _suggestMap.remove() } catch (e) {} _suggestMap = null } } watch(() => assignPanel.showMap, (on) => { if (on) { if (assignPanel.h < 600) assignPanel.h = 640; nextTick(initAssignMap) } else destroyAssignMap() }) @@ -3761,6 +3813,8 @@ function setSelDate (iso) { for (const nm of names) { const j = (assignPanel.jobs || []).find(x => x.name === nm); if (j && iso !== j.scheduled_date) { j.scheduled_date = iso; roster.updateJob(j.name, { scheduled_date: iso }).catch(e => err(e)); n++ } } if (n) $q.notify({ type: 'info', icon: 'event', message: n + ' job(s) reportés au ' + fmtDueLabel(iso), timeout: 3000 }) } +// Niveau requis PERSISTANT d'un job (store hub → survit aux rechargements/sessions) — piloté par la pastille « niv ». +function setJobReqLevel (job, n) { const lv = Math.max(1, Math.min(5, Number(n) || 1)); job.required_level = lv; roster.setJobLevel(job.name, lv).catch(e => err(e)) } function techsForJob (job) { const iso = jobTargetDay(job) const reqSkill = job && job.required_skill diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index 9b56e31..18fa0c4 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -36,6 +36,15 @@ const cfg = require('./config') const fs = require('fs') const path = require('path') const POLICY_FILE = path.join(__dirname, '..', 'data', 'dispatch-policy.json') +// Niveau requis PAR JOB, persistant. (L'API Custom Field d'ERPNext v16 échoue sur ce doctype custom → store hub durable.) +const JOB_LEVELS_FILE = path.join(__dirname, '..', 'data', 'job-levels.json') +function getJobLevels () { try { return JSON.parse(fs.readFileSync(JOB_LEVELS_FILE, 'utf8')) || {} } catch { return {} } } +function setJobLevel (name, level) { + const m = getJobLevels(); const lv = Math.max(0, Math.min(5, Math.round(Number(level) || 0))) + if (lv > 0) m[String(name)] = lv; else delete m[String(name)] + try { fs.mkdirSync(path.dirname(JOB_LEVELS_FILE), { recursive: true }) } catch (e) {} + fs.writeFileSync(JOB_LEVELS_FILE, JSON.stringify(m)); return m +} const SOLVER_URL = cfg.ROSTER_SOLVER_URL || 'http://roster-solver:8090' const PAUSE_STATUS = 'En pause' @@ -397,7 +406,8 @@ async function buildUnassigned () { const rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'creation', 'subject', 'customer_name', 'service_location', 'service_type', 'job_type', 'assigned_group', 'legacy_dept', 'legacy_detail', 'legacy_ticket_id', 'legacy_activation_url', 'priority', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech', 'latitude', 'longitude', 'address'], orderBy: 'modified desc', limit: 400 }) const jobs = (rows || []).filter(j => !j.assigned_tech) const chars = readJobChar().items - for (const j of jobs) { j.required_skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject); const est = estimateForJob(j, chars); j.est_min = est.minutes; j.est_labels = est.labels } + const jlv = getJobLevels() + for (const j of jobs) { j.required_skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject); j.required_level = jlv[j.name] || 0; const est = estimateForJob(j, chars); j.est_min = est.minutes; j.est_labels = est.labels } await attachLocations(jobs) log(`pool: construit ${jobs.length} jobs en ${nowMs() - t0}ms`) // mesure réelle (requête + enrichissement) return jobs @@ -1137,6 +1147,11 @@ async function handle (req, res, method, path, url) { if (r.ok) invalidatePool() // le job quitte le pool → rafraîchir le cache return json(res, r.ok ? 200 : 500, { ...r, job: b.job, tech: b.tech, start_time: placed, duration_h: dur }) } + if (path === '/roster/job-level' && method === 'POST') { // niveau requis PERSISTANT par job (store hub durable) + const b = await parseBody(req); if (!b.name) return json(res, 400, { error: 'name requis' }) + setJobLevel(b.name, b.level); invalidatePool() // le pool porte required_level → rafraîchir + return json(res, 200, { ok: true, name: b.name, level: Math.max(0, Math.min(5, Math.round(Number(b.level) || 0))) }) + } // Persister UN quart immédiatement (ex. « Créer un quart et assigner » sur un tech sans quart) — sinon `addShift` reste LOCAL // (status Proposé en mémoire) et le quart disparaît au rechargement. Idempotent par clé tech|date|template (pas de doublon vs Publier). if (path === '/roster/shift' && method === 'POST') {