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:
parent
4e5ad011ee
commit
7fa8c6dc74
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1088,8 +1088,6 @@
|
|||
<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>
|
||||
<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-btn>
|
||||
<span class="jt-chip" :class="{ on: jobTimeOf(e.jobName).ampm === 'am' }" @click="toggleJobAmpm(e.jobName, 'am')"><q-tooltip>Avant-midi (8-12) — Ré-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)
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user