From 7d1a5b067f21abdf82a7501386ed4aa9d2790b03 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 2 Jul 2026 15:16:13 -0400 Subject: [PATCH] feat(dispatch): persistent URGENT flag per job (SLA/commercial) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Like the omnichannel inbox urgent flag, but for jobs — a 🚩 that persists and drives the optimizer. - hub: durable job-flags store (data/job-flags.json) mirroring job-levels — getJobFlags/setJobFlag + POST /roster/job-flag {name, urgent}; buildUnassigned sets j.urgent; invalidatePool on write. - SPA: 🚩 chip on each review entry (replaces the session ⚡). Optimistic local toggle + persists via roster.setJobFlag; reflects j.urgent from the pool. Flagged jobs → priority_boost (never dropped) + urgent_weight (served early). Bumped urgent early-weight 6→10. Verified: flag round-trips (set→urgent, revert→cleared) through the pool; chips render (🚩/AM/PM). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/ops/src/api/roster.js | 1 + apps/ops/src/pages/PlanificationPage.vue | 22 ++++++++++++++++------ services/targo-hub/lib/roster.js | 18 ++++++++++++++++-- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js index d6e3384..764d057 100644 --- a/apps/ops/src/api/roster.js +++ b/apps/ops/src/api/roster.js @@ -53,6 +53,7 @@ export const askAssistant = (message, history) => jpost('/roster/assistant', { m 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 const setJobFlag = (name, urgent) => jpost('/roster/job-flag', { name, urgent }) // drapeau urgent persistant par job (SLA/commercial) 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 ee14949..135476a 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -1076,9 +1076,9 @@ {{ e.techName }} n'a pas la compétence « {{ e.skill }} » — à réassigner Aucun quart publié ce jour (assignable, mais ⚠ hors quart) + Drapeau URGENT persistant (client SLA/commercial) — jamais laissé de côté + servi en début de journée. Ré-optimiser pour appliquer.🚩 Avant-midi (8-12) — Ré-optimiser pour appliquerAM Après-midi (12-16) — Ré-optimiser pour appliquerPM - Urgent — servir en DÉBUT de journée (Ré-optimiser)⚡ Déplacer vers un autre tech (classés par proximité + charge) @@ -3914,7 +3914,15 @@ const suggestDlg = reactive({ open: false, mode: 'config', plan: [], building: f const AMPM_WIN = { am: [480, 720], pm: [720, 960] } // 8-12 / 12-16 (heure d'horloge) function jobTimeOf (name) { return suggestDlg.jobTime[name] || {} } function toggleJobAmpm (name, v) { const c = suggestDlg.jobTime[name] || {}; suggestDlg.jobTime[name] = { ...c, ampm: c.ampm === v ? null : v } } -function toggleJobEarly (name) { const c = suggestDlg.jobTime[name] || {}; suggestDlg.jobTime[name] = { ...c, early: !c.early } } +// Drapeau URGENT PERSISTANT par job (comme le flag urgent des conversations) — SLA/commercial. Écrit au hub + reflété localement. +const poolUrgent = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = !!j.urgent; return m }) +function isJobUrgent (name) { return !!poolUrgent.value[name] } +async function toggleJobUrgent (name) { + const j = (assignPanel.jobs || []).find(x => x.name === name); const next = !(j && j.urgent) + if (j) j.urgent = next // optimiste + try { await roster.setJobFlag(name, next) } catch (e) { if (j) j.urgent = !next; err(e); return } + $q.notify({ type: next ? 'warning' : 'info', icon: next ? 'flag' : 'outlined_flag', message: next ? 'Job marqué URGENT (prioritaire, servi tôt)' : 'Drapeau urgent retiré', timeout: 2000 }) +} // Réglages de l'optimiseur VRP (curseur distance↔spécialité, vitesse repli, budget de calcul) — persistés localement, ajustables sans redéploiement. const solverOpts = reactive({ rankWeight: 2, speedKmh: 45, maxSeconds: 8 }) try { const s = JSON.parse(localStorage.getItem('ops.solverOpts') || 'null'); if (s && typeof s === 'object') Object.assign(solverOpts, s) } catch (e) { /* défauts */ } @@ -4128,7 +4136,7 @@ async function optimizeSuggestion () { // Priorité du job → « priority_boost » (pénalité de drop) : URGENT jamais laissé de côté ; BASSE lâchée en 1er si la capacité manque. const poolPr = Object.fromEntries((assignPanel.jobs || []).map(j => [j.name, String(j.priority || '').toLowerCase()])) const prBoost = p => ({ urgent: 400000, high: 400000, 'élevée': 400000, medium: 0, moyenne: 0, normal: 0, low: -60000, basse: -60000 }[p] ?? 0) - const prEarly = p => ({ urgent: 6, high: 3, 'élevée': 3 }[p] || 0) // priorité → poids « servir tôt » + const prEarly = p => ({ urgent: 10, high: 4, 'élevée': 4 }[p] || 0) // priorité → poids « servir tôt » const out = [...kept] for (const iso of Object.keys(byDay)) { const entries = byDay[iso] @@ -4136,8 +4144,10 @@ async function optimizeSuggestion () { const jobs = entries.map(e => { const jt = suggestDlg.jobTime[e.jobName] || {} const win = AMPM_WIN[jt.ampm] // AM/PM → fenêtre horaire dure - const urgent_weight = Math.max(prEarly(poolPr[e.jobName]), jt.early ? 12 : 0) // urgence (priorité OU ⚡ manuel) → début de journée - return { id: e.jobName, lat: e.lat, lon: e.lon, service_min: Math.max(5, Math.round((e.dur || 1) * 60)), skill: e.skill || '', priority_boost: prBoost(poolPr[e.jobName]), urgent_weight, ...(win ? { tw_start_min: win[0], tw_end_min: win[1] } : {}) } + const urg = poolUrgent.value[e.jobName] // drapeau URGENT persistant (SLA/commercial) + const priority_boost = Math.max(prBoost(poolPr[e.jobName]), urg ? 400000 : 0) // urgent = jamais lâché + const urgent_weight = Math.max(prEarly(poolPr[e.jobName]), urg ? 10 : 0) // urgent = servi tôt (début de journée) + return { id: e.jobName, lat: e.lat, lon: e.lon, service_min: Math.max(5, Math.round((e.dur || 1) * 60)), skill: e.skill || '', priority_boost, urgent_weight, ...(win ? { tw_start_min: win[0], tw_end_min: win[1] } : {}) } }) const vehicles = [] for (const t of selTechs) { @@ -5094,7 +5104,7 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return .jt-chip { display: inline-flex; align-items: center; font-size: 9px; font-weight: 700; padding: 1px 4px; margin-right: 2px; border-radius: 6px; border: 1px solid #cbd5e1; color: #64748b; background: #fff; cursor: pointer; user-select: none; line-height: 1.4; } .jt-chip:hover { border-color: #6366f1; } .jt-chip.on { background: #6366f1; color: #fff; border-color: #6366f1; } -.jt-early.on { background: #f59e0b; border-color: #f59e0b; } +.jt-urgent.on { background: #ef4444; border-color: #ef4444; color: #fff; } .suggest-day { padding: 2px 8px 6px; } .suggest-day-lbl { font-size: 11px; font-weight: 700; color: #475569; padding: 4px 2px 2px; } .suggest-entry { display: flex; align-items: center; gap: 2px; padding: 4px 6px; border-radius: 6px; font-size: 12.5px; } diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index e28f285..21b693f 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -45,6 +45,15 @@ function setJobLevel (name, level) { try { fs.mkdirSync(path.dirname(JOB_LEVELS_FILE), { recursive: true }) } catch (e) {} fs.writeFileSync(JOB_LEVELS_FILE, JSON.stringify(m)); return m } +// Drapeau URGENT persistant par job (comme le flag urgent des conversations) — client commercial/SLA/contrainte. +const JOB_FLAGS_FILE = path.join(__dirname, '..', 'data', 'job-flags.json') +function getJobFlags () { try { return JSON.parse(fs.readFileSync(JOB_FLAGS_FILE, 'utf8')) || {} } catch { return {} } } +function setJobFlag (name, urgent) { + const m = getJobFlags() + if (urgent) m[String(name)] = 1; else delete m[String(name)] + try { fs.mkdirSync(path.dirname(JOB_FLAGS_FILE), { recursive: true }) } catch (e) {} + fs.writeFileSync(JOB_FLAGS_FILE, JSON.stringify(m)); return m +} const SOLVER_URL = cfg.ROSTER_SOLVER_URL || 'http://roster-solver:8090' const PAUSE_STATUS = 'En pause' @@ -445,8 +454,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 - 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 } + const jlv = getJobLevels(); const jfl = getJobFlags() + 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; j.urgent = !!jfl[j.name]; 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 @@ -1203,6 +1212,11 @@ async function handle (req, res, method, path, url) { 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))) }) } + if (path === '/roster/job-flag' && method === 'POST') { // drapeau URGENT persistant par job (SLA/commercial) → store hub durable + const b = await parseBody(req); if (!b.name) return json(res, 400, { error: 'name requis' }) + setJobFlag(b.name, !!b.urgent); invalidatePool() // le pool porte j.urgent → rafraîchir + return json(res, 200, { ok: true, name: b.name, urgent: !!b.urgent }) + } // 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') {