feat(dispatch): persistent URGENT flag per job (SLA/commercial)

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) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-02 15:16:13 -04:00
parent 8b2d7b54e9
commit 7d1a5b067f
3 changed files with 33 additions and 8 deletions

View File

@ -53,6 +53,7 @@ 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, urgent) => jpost('/roster/job-flag', { name, urgent }) // drapeau urgent persistant 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

@ -1076,9 +1076,9 @@
<q-icon v-if="!e.capable" name="warning" size="14px" color="deep-orange" class="q-mr-xs"><q-tooltip>{{ e.techName }} n'a pas la compétence « {{ e.skill }} » à réassigner</q-tooltip></q-icon> <q-icon v-if="!e.capable" name="warning" size="14px" color="deep-orange" class="q-mr-xs"><q-tooltip>{{ e.techName }} n'a pas la compétence « {{ e.skill }} » à réassigner</q-tooltip></q-icon>
<q-icon v-if="e.noShift" name="event_busy" size="14px" color="amber-8" class="q-mr-xs"><q-tooltip>Aucun quart publié ce jour (assignable, mais hors quart)</q-tooltip></q-icon> <q-icon v-if="e.noShift" name="event_busy" size="14px" color="amber-8" class="q-mr-xs"><q-tooltip>Aucun quart publié ce jour (assignable, mais hors quart)</q-tooltip></q-icon>
<!-- Contraintes horaires (prises en compte par -optimiser) : AM/PM = fenêtre · = urgent, servir en début de journée --> <!-- Contraintes horaires (prises en compte par -optimiser) : AM/PM = fenêtre · = urgent, servir en début de journée -->
<span class="jt-chip jt-urgent" :class="{ on: isJobUrgent(e.jobName) }" @click="toggleJobUrgent(e.jobName)"><q-tooltip>Drapeau URGENT persistant (client SLA/commercial) jamais laissé de côté + servi en début de journée. -optimiser pour appliquer.</q-tooltip>🚩</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> <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 === 'pm' }" @click="toggleJobAmpm(e.jobName, 'pm')"><q-tooltip>Après-midi (12-16) -optimiser pour appliquer</q-tooltip>PM</span> <span class="jt-chip" :class="{ on: jobTimeOf(e.jobName).ampm === 'pm' }" @click="toggleJobAmpm(e.jobName, 'pm')"><q-tooltip>Après-midi (12-16) -optimiser pour appliquer</q-tooltip>PM</span>
<span class="jt-chip jt-early" :class="{ on: jobTimeOf(e.jobName).early }" @click="toggleJobEarly(e.jobName)"><q-tooltip>Urgent servir en DÉBUT de journée (-optimiser)</q-tooltip></span>
<q-space /> <q-space />
<q-btn flat dense round size="sm" icon="swap_horiz" color="grey-7"><q-tooltip>Déplacer vers un autre tech (classés par proximité + charge)</q-tooltip> <q-btn flat dense round size="sm" icon="swap_horiz" color="grey-7"><q-tooltip>Déplacer vers un autre tech (classés par proximité + charge)</q-tooltip>
<q-menu anchor="bottom right" self="top right"><q-list dense style="min-width:250px;max-height:46vh;overflow:auto"> <q-menu anchor="bottom right" self="top right"><q-list dense style="min-width:250px;max-height:46vh;overflow:auto">
@ -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) 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 } }
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 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 })
try { const s = JSON.parse(localStorage.getItem('ops.solverOpts') || 'null'); if (s && typeof s === 'object') Object.assign(solverOpts, s) } catch (e) { /* défauts */ } 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. // 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 => ({ 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] const out = [...kept]
for (const iso of Object.keys(byDay)) { for (const iso of Object.keys(byDay)) {
const entries = byDay[iso] const entries = byDay[iso]
@ -4136,8 +4144,10 @@ async function optimizeSuggestion () {
const jobs = entries.map(e => { const jobs = entries.map(e => {
const jt = suggestDlg.jobTime[e.jobName] || {} const jt = suggestDlg.jobTime[e.jobName] || {}
const win = AMPM_WIN[jt.ampm] // AM/PM fenêtre horaire dure 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 const urg = poolUrgent.value[e.jobName] // drapeau URGENT persistant (SLA/commercial)
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 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 = [] const vehicles = []
for (const t of selTechs) { 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 { 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:hover { border-color: #6366f1; }
.jt-chip.on { background: #6366f1; color: #fff; 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 { padding: 2px 8px 6px; }
.suggest-day-lbl { font-size: 11px; font-weight: 700; color: #475569; padding: 4px 2px 2px; } .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; } .suggest-entry { display: flex; align-items: center; gap: 2px; padding: 4px 6px; border-radius: 6px; font-size: 12.5px; }

View File

@ -45,6 +45,15 @@ 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
} }
// 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 SOLVER_URL = cfg.ROSTER_SOLVER_URL || 'http://roster-solver:8090'
const PAUSE_STATUS = 'En pause' 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 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 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; 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; j.urgent = !!jfl[j.name]; 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
@ -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 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') { // 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 // 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') {