From 7fa8c6dc74cccbc06458a77a22f0b9a8f017375e Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 2 Jul 2026 16:01:25 -0400 Subject: [PATCH] refactor(priority): unify on 3 levels (ERPNext standard), high = red, jobs + conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared priorityMeta/PRIORITY_LEVELS → 3 levels: Haute (RED) · Moyenne (neutral grey) · Basse. Default/medium = grey outlined_flag. Aliases urgent→high, normal→medium for old conversation data. - conversations: priority endpoint accepts 'medium' (keeps old values for back-compat); the flag menu now shows the 3 levels. - jobs: dropped the parallel job-flags override store — the job flag now writes the REAL ERPNext priority (low/medium/high) via patchJob/ updateJob, same source as the pool sheet (no more two mechanisms). setEntryPriority reuses patchJob. Solver: high never dropped + served early, low dropped first. Verified: job flag + conversation flag both grey-by-default, menu Haute/ Moyenne/Basse, high=red. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/ops/src/api/roster.js | 1 - .../src/composables/useConversationDisplay.js | 14 +++++++----- apps/ops/src/pages/PlanificationPage.vue | 18 ++++++--------- services/targo-hub/lib/conversation.js | 2 +- services/targo-hub/lib/roster.js | 22 +++---------------- 5 files changed, 19 insertions(+), 38 deletions(-) diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js index 745b0a9..d6e3384 100644 --- a/apps/ops/src/api/roster.js +++ b/apps/ops/src/api/roster.js @@ -53,7 +53,6 @@ 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, priority) => jpost('/roster/job-flag', { name, priority }) // priorité de dispatch persistante 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/composables/useConversationDisplay.js b/apps/ops/src/composables/useConversationDisplay.js index 1caf3d3..71e8b35 100644 --- a/apps/ops/src/composables/useConversationDisplay.js +++ b/apps/ops/src/composables/useConversationDisplay.js @@ -67,15 +67,17 @@ export function channelMeta (channel) { return CHANNELS[channel] || { icon: 'chat_bubble', color: 'grey-5', label: channel || 'Message' } } -// priorityMeta(p) → { icon, color, label, rank } pour le DRAPEAU de priorité (style ClickUp), cohérent liste + détail. -// rank = tri (urgent en haut). '' / 'none' = aucune priorité → drapeau contour discret (invite à classer). +// priorityMeta(p) → { icon, color, label, rank } pour le DRAPEAU de priorité, cohérent liste + détail, PARTOUT (conversations + jobs). +// STANDARD 3 NIVEAUX (comme ERPNext) : haute (ROUGE) · moyenne (neutre gris) · basse. '' / medium = non classé → drapeau contour gris (invite à classer). const PRIORITIES = { - urgent: { icon: 'flag', color: 'red-6', label: 'Urgent', rank: 4 }, - high: { icon: 'flag', color: 'orange-7', label: 'Haute', rank: 3 }, - normal: { icon: 'flag', color: 'blue-6', label: 'Normale', rank: 2 }, + high: { icon: 'flag', color: 'red-6', label: 'Haute', rank: 3 }, + medium: { icon: 'outlined_flag', color: 'grey-5', label: 'Moyenne', rank: 2 }, // défaut ERPNext → contour gris (non classé) low: { icon: 'flag', color: 'blue-grey-5', label: 'Basse', rank: 1 }, + // rétro-compatibilité anciennes valeurs (conversations) : urgent→haute (rouge), normal→moyenne + urgent: { icon: 'flag', color: 'red-6', label: 'Haute', rank: 3 }, + normal: { icon: 'outlined_flag', color: 'grey-5', label: 'Moyenne', rank: 2 }, } -export const PRIORITY_LEVELS = ['urgent', 'high', 'normal', 'low'] // ordre d'affichage du menu (plus urgent → moins) +export const PRIORITY_LEVELS = ['high', 'medium', 'low'] // 3 niveaux (standard ERPNext) — haute en tête export function priorityMeta (p) { return PRIORITIES[p] || { icon: 'outlined_flag', color: 'grey-5', label: 'Aucune', rank: 0 } } diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 6919a3d..4c3e549 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -1088,8 +1088,6 @@ {{ priorityMeta(lvl).label }} - - Aucune Avant-midi (8-12) — Ré-optimiser pour appliquerAM @@ -3930,14 +3928,12 @@ 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 } } -// PRIORITÉ de dispatch PERSISTANTE par job — MÊME composant que les conversations (priorityMeta : drapeau contour gris → coloré + liste de niveaux). SLA/commercial. +// PRIORITÉ du job — MÊME drapeau que les conversations (priorityMeta) + champ ERPNext STANDARD (low/medium/high) via patchJob, comme la fiche du pool. Une seule source. const poolPriority = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = String(j.priority || '').toLowerCase(); return m }) -function jobPriorityOf (name) { const p = poolPriority.value[name] || ''; return (p === 'medium' || p === 'moyenne') ? '' : p } // medium ERPNext = non classé → drapeau contour gris (invite à classer) -async function setEntryPriority (name, lvl) { // priorité de DISPATCH (store override, supporte urgent que le champ ERPNext low/medium/high ne fait pas) - const j = (assignPanel.jobs || []).find(x => x.name === name); const prev = j ? j.priority : undefined - if (j) j.priority = lvl || 'medium' // optimiste - try { await roster.setJobFlag(name, lvl) } catch (e) { if (j) j.priority = prev; err(e); return } - $q.notify({ type: 'info', icon: priorityMeta(lvl).icon, message: 'Priorité : ' + priorityMeta(lvl).label, timeout: 1600 }) +function jobPriorityOf (name) { return poolPriority.value[name] || '' } // medium → priorityMeta = contour gris (non classé) +function setEntryPriority (name, lvl) { // écrit la priorité ERPNext du job (low/medium/high) — réutilise patchJob (optimiste + updateJob) + const j = (assignPanel.jobs || []).find(x => x.name === name); if (!j) return + patchJob(j, { priority: lvl || 'medium' }, 'Priorité : ' + priorityMeta(lvl).label) } // 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 }) @@ -4149,8 +4145,8 @@ async function optimizeSuggestion () { const selTechs = (visibleTechs.value || []).filter(t => suggestDlg.techSel[t.id]) // 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: 10, high: 4, 'élevée': 4 }[p] || 0) // priorité → poids « servir tôt » + const prBoost = p => ({ high: 400000, 'élevée': 400000, urgent: 400000, medium: 0, moyenne: 0, normal: 0, low: -60000, basse: -60000 }[p] ?? 0) // HAUTE jamais lâchée ; basse lâchée en 1er + const prEarly = p => ({ high: 10, 'élevée': 10, urgent: 10 }[p] || 0) // HAUTE = servie tôt (début de journée) const out = [] for (const iso of Object.keys(byDay)) { const entries = byDay[iso] diff --git a/services/targo-hub/lib/conversation.js b/services/targo-hub/lib/conversation.js index 3f76dc6..46e7a59 100644 --- a/services/targo-hub/lib/conversation.js +++ b/services/targo-hub/lib/conversation.js @@ -1811,7 +1811,7 @@ async function handle (req, res, method, p, url) { if (sub === 'priority' && method === 'POST') { const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' }) const b = await parseBody(req) - const p = ['urgent', 'high', 'normal', 'low', ''].includes(b.priority) ? b.priority : '' + const p = ['high', 'medium', 'low', 'urgent', 'normal', ''].includes(b.priority) ? b.priority : '' // 3 niveaux (high/medium/low) ; urgent/normal gardés pour rétro-compat conv.priority = p; saveToDisk() const payload = { token: conv.token, priority: p } sse.broadcast('conv:' + conv.token, 'conv-update', payload) diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index 3e2084f..a49bee1 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -45,17 +45,7 @@ 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 } -// PRIORITÉ de dispatch persistante par job (même notion que la priorité des conversations : urgent|high|normal|low). -// Store durable = SURCHARGE de la priorité ERPNext pour le dispatch (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 {} } } -const PRIO_LEVELS = ['urgent', 'high', 'normal', 'low'] -function setJobFlag (name, priority) { - const m = getJobFlags(); const p = String(priority || '').toLowerCase() - if (PRIO_LEVELS.includes(p)) m[String(name)] = p; else delete m[String(name)] // niveau valide → surcharge ; sinon (vide/« aucune ») → retire - try { fs.mkdirSync(path.dirname(JOB_FLAGS_FILE), { recursive: true }) } catch (e) {} - fs.writeFileSync(JOB_FLAGS_FILE, JSON.stringify(m)); return m -} +// Priorité de job = champ ERPNext standard (low/medium/high), édité via /roster/job/update (comme le pool). Plus de store parallèle. const SOLVER_URL = cfg.ROSTER_SOLVER_URL || 'http://roster-solver:8090' const PAUSE_STATUS = 'En pause' @@ -456,8 +446,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(); 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; if (jfl[j.name]) j.priority = jfl[j.name]; 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 @@ -1214,12 +1204,6 @@ 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') { // PRIORITÉ de dispatch persistante par job (SLA/commercial) → store hub durable (surcharge) - const b = await parseBody(req); if (!b.name) return json(res, 400, { error: 'name requis' }) - const p = String(b.priority || '').toLowerCase() - setJobFlag(b.name, p); invalidatePool() // le pool porte j.priority → rafraîchir - return json(res, 200, { ok: true, name: b.name, priority: PRIO_LEVELS.includes(p) ? p : '' }) - } // 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') {