refactor(priority): unify on 3 levels (ERPNext standard), high = red, jobs + conversations

- 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) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-02 16:01:25 -04:00
parent 4e5ad011ee
commit 7fa8c6dc74
5 changed files with 19 additions and 38 deletions

View File

@ -53,7 +53,6 @@ export const askAssistant = (message, history) => jpost('/roster/assistant', { m
export const getPolicy = () => jget('/roster/policy') export const getPolicy = () => jget('/roster/policy')
export const savePolicy = (policy) => jpost('/roster/policy', 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 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) { export async function deleteShiftTemplate (name) {
const r = await fetch(HUB + '/roster/template/' + encodeURIComponent(name), { method: 'DELETE' }) const r = await fetch(HUB + '/roster/template/' + encodeURIComponent(name), { method: 'DELETE' })
if (!r.ok) throw new Error('Suppression modèle: ' + r.status) if (!r.ok) throw new Error('Suppression modèle: ' + r.status)

View File

@ -67,15 +67,17 @@ export function channelMeta (channel) {
return CHANNELS[channel] || { icon: 'chat_bubble', color: 'grey-5', label: channel || 'Message' } 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. // priorityMeta(p) → { icon, color, label, rank } pour le DRAPEAU de priorité, cohérent liste + détail, PARTOUT (conversations + jobs).
// rank = tri (urgent en haut). '' / 'none' = aucune priorité → drapeau contour discret (invite à classer). // STANDARD 3 NIVEAUX (comme ERPNext) : haute (ROUGE) · moyenne (neutre gris) · basse. '' / medium = non classé → drapeau contour gris (invite à classer).
const PRIORITIES = { const PRIORITIES = {
urgent: { icon: 'flag', color: 'red-6', label: 'Urgent', rank: 4 }, high: { icon: 'flag', color: 'red-6', label: 'Haute', rank: 3 },
high: { icon: 'flag', color: 'orange-7', label: 'Haute', rank: 3 }, medium: { icon: 'outlined_flag', color: 'grey-5', label: 'Moyenne', rank: 2 }, // défaut ERPNext → contour gris (non classé)
normal: { icon: 'flag', color: 'blue-6', label: 'Normale', rank: 2 },
low: { icon: 'flag', color: 'blue-grey-5', label: 'Basse', rank: 1 }, 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) { export function priorityMeta (p) {
return PRIORITIES[p] || { icon: 'outlined_flag', color: 'grey-5', label: 'Aucune', rank: 0 } return PRIORITIES[p] || { icon: 'outlined_flag', color: 'grey-5', label: 'Aucune', rank: 0 }
} }

View File

@ -1088,8 +1088,6 @@
<q-item-section>{{ priorityMeta(lvl).label }}</q-item-section> <q-item-section>{{ priorityMeta(lvl).label }}</q-item-section>
<q-item-section side v-if="jobPriorityOf(e.jobName) === lvl"><q-icon name="check" color="primary" size="16px" /></q-item-section> <q-item-section side v-if="jobPriorityOf(e.jobName) === lvl"><q-icon name="check" color="primary" size="16px" /></q-item-section>
</q-item> </q-item>
<q-separator />
<q-item clickable @click="setEntryPriority(e.jobName,'')" :active="!jobPriorityOf(e.jobName)"><q-item-section avatar><q-icon name="outlined_flag" color="grey-5" size="18px" /></q-item-section><q-item-section class="text-grey-7">Aucune</q-item-section></q-item>
</q-list></q-menu> </q-list></q-menu>
</q-btn> </q-btn>
<span class="jt-chip" :class="{ on: jobTimeOf(e.jobName).ampm === 'am' }" @click="toggleJobAmpm(e.jobName, 'am')"><q-tooltip>Avant-midi (8-12) -optimiser pour appliquer</q-tooltip>AM</span> <span class="jt-chip" :class="{ on: jobTimeOf(e.jobName).ampm === 'am' }" @click="toggleJobAmpm(e.jobName, 'am')"><q-tooltip>Avant-midi (8-12) -optimiser pour appliquer</q-tooltip>AM</span>
@ -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) const AMPM_WIN = { am: [480, 720], pm: [720, 960] } // 8-12 / 12-16 (heure d'horloge)
function jobTimeOf (name) { return suggestDlg.jobTime[name] || {} } 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 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 }) 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) function jobPriorityOf (name) { return poolPriority.value[name] || '' } // medium priorityMeta = contour gris (non classé)
async function setEntryPriority (name, lvl) { // priorité de DISPATCH (store override, supporte urgent que le champ ERPNext low/medium/high ne fait pas) 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); const prev = j ? j.priority : undefined const j = (assignPanel.jobs || []).find(x => x.name === name); if (!j) return
if (j) j.priority = lvl || 'medium' // optimiste patchJob(j, { priority: lvl || 'medium' }, 'Priorité : ' + priorityMeta(lvl).label)
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 })
} }
// Réglages de l'optimiseur VRP (curseur distancespécialité, vitesse repli, budget de calcul) persistés localement, ajustables sans redéploiement. // Réglages de l'optimiseur VRP (curseur distancespécialité, vitesse repli, budget de calcul) persistés localement, ajustables sans redéploiement.
const solverOpts = reactive({ rankWeight: 2, speedKmh: 45, maxSeconds: 8 }) 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]) 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. // 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 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 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 => ({ urgent: 10, high: 4, 'élevée': 4 }[p] || 0) // priorité poids « servir tôt » const prEarly = p => ({ high: 10, 'élevée': 10, urgent: 10 }[p] || 0) // HAUTE = servie tôt (début de journée)
const out = [] const out = []
for (const iso of Object.keys(byDay)) { for (const iso of Object.keys(byDay)) {
const entries = byDay[iso] const entries = byDay[iso]

View File

@ -1811,7 +1811,7 @@ async function handle (req, res, method, p, url) {
if (sub === 'priority' && method === 'POST') { if (sub === 'priority' && method === 'POST') {
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' }) const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
const b = await parseBody(req) 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() conv.priority = p; saveToDisk()
const payload = { token: conv.token, priority: p } const payload = { token: conv.token, priority: p }
sse.broadcast('conv:' + conv.token, 'conv-update', payload) sse.broadcast('conv:' + conv.token, 'conv-update', payload)

View File

@ -45,17 +45,7 @@ function setJobLevel (name, level) {
try { fs.mkdirSync(path.dirname(JOB_LEVELS_FILE), { recursive: true }) } catch (e) {} try { fs.mkdirSync(path.dirname(JOB_LEVELS_FILE), { recursive: true }) } catch (e) {}
fs.writeFileSync(JOB_LEVELS_FILE, JSON.stringify(m)); return m 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). // Priorité de job = champ ERPNext standard (low/medium/high), édité via /roster/job/update (comme le pool). Plus de store parallèle.
// 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
}
const SOLVER_URL = cfg.ROSTER_SOLVER_URL || 'http://roster-solver:8090' const SOLVER_URL = cfg.ROSTER_SOLVER_URL || 'http://roster-solver:8090'
const PAUSE_STATUS = 'En pause' 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 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 jobs = (rows || []).filter(j => !j.assigned_tech)
const chars = readJobChar().items const chars = readJobChar().items
const jlv = getJobLevels(); const jfl = getJobFlags() 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; if (jfl[j.name]) j.priority = jfl[j.name]; const est = estimateForJob(j, chars); j.est_min = est.minutes; j.est_labels = est.labels } 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) await attachLocations(jobs)
log(`pool: construit ${jobs.length} jobs en ${nowMs() - t0}ms`) // mesure réelle (requête + enrichissement) log(`pool: construit ${jobs.length} jobs en ${nowMs() - t0}ms`) // mesure réelle (requête + enrichissement)
return jobs 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 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))) }) 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 // 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). // (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') { if (path === '/roster/shift' && method === 'POST') {