feat(dispatch): solver settings (distance↔specialty, speed, time) + urgent priority
- Config UI (Optimiser mode): live settings — a distance↔specialty slider (rank_weight 0-10), fallback speed km/h, and solve-time budget. Persisted to localStorage, passed to the solver per run → tune the curve without redeploying. - Urgent jobs prioritized: each job's priority maps to priority_boost (drop penalty) — urgent/high never dropped, low priority dropped first when capacity is tight. SPA-only (solver already accepts rank_weight/speed_kmh/max_seconds/ priority_boost). Verified: settings panel renders, optimize runs end-to-end (16 techs, OSRM). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bc157588d1
commit
3513f7e15f
|
|
@ -978,6 +978,23 @@
|
||||||
<q-btn-toggle v-model="suggestDlg.strategy" dense no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8" :options="[{ label: '⚡ Optimiser', value: 'optimize' }, { label: 'Intelligent', value: 'smart' }, { label: 'Meilleurs d\'abord', value: 'best' }, { label: 'Équilibré', value: 'balance' }, { label: 'Juste ce qu\'il faut', value: 'enough' }]" />
|
<q-btn-toggle v-model="suggestDlg.strategy" dense no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8" :options="[{ label: '⚡ Optimiser', value: 'optimize' }, { label: 'Intelligent', value: 'smart' }, { label: 'Meilleurs d\'abord', value: 'best' }, { label: 'Équilibré', value: 'balance' }, { label: 'Juste ce qu\'il faut', value: 'enough' }]" />
|
||||||
<span class="text-caption text-grey-6">{{ ({ optimize: 'solveur VRP (OR-Tools) : routes réelles minimisées + compétences + temps sur place — regroupe les secteurs, spécialistes d\'abord', smart: 'proximité + compétence + charge', best: 'concentre le travail sur les meilleurs (cascade)', balance: 'répartit également entre tous (round-robin)', enough: 'qualifié mais le moins sur-qualifié → réserve les experts (distance + temps pris en compte)' })[suggestDlg.strategy] }}</span>
|
<span class="text-caption text-grey-6">{{ ({ optimize: 'solveur VRP (OR-Tools) : routes réelles minimisées + compétences + temps sur place — regroupe les secteurs, spécialistes d\'abord', smart: 'proximité + compétence + charge', best: 'concentre le travail sur les meilleurs (cascade)', balance: 'répartit également entre tous (round-robin)', enough: 'qualifié mais le moins sur-qualifié → réserve les experts (distance + temps pris en compte)' })[suggestDlg.strategy] }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Réglages de l'optimiseur (curseur distance↔spécialité, vitesse, budget calcul) — persistés localement -->
|
||||||
|
<div v-if="suggestDlg.strategy === 'optimize'" class="row items-start q-mb-sm q-col-gutter-md" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:8px 12px 10px">
|
||||||
|
<div class="col-12 col-sm" style="min-width:220px">
|
||||||
|
<div class="text-caption text-grey-7">Priorité : <b>distance</b> ↔ <b>spécialité</b></div>
|
||||||
|
<q-slider v-model="solverOpts.rankWeight" :min="0" :max="10" :step="1" dense color="primary" label :label-value="solverOpts.rankWeight === 0 ? 'distance pure' : (solverOpts.rankWeight >= 7 ? 'spécialité forte' : solverOpts.rankWeight)" />
|
||||||
|
<div class="text-grey-5" style="font-size:10px">0 = plus court chemin d'abord · haut = garde les spécialistes sur leur métier</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-sm-auto" style="max-width:120px">
|
||||||
|
<q-input v-model.number="solverOpts.speedKmh" type="number" dense outlined label="Vitesse km/h" :min="20" :max="90" />
|
||||||
|
<div class="text-grey-5" style="font-size:10px">repli sans coords</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-sm-auto" style="max-width:120px">
|
||||||
|
<q-input v-model.number="solverOpts.maxSeconds" type="number" dense outlined label="Calcul (s)" :min="2" :max="30" />
|
||||||
|
<div class="text-grey-5" style="font-size:10px">budget par jour</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 text-caption text-grey-6">⚡ Les jobs <b>urgents/élevés</b> sont priorisés (jamais laissés de côté) ; <b>basse</b> priorité lâchée en premier si la capacité manque.</div>
|
||||||
|
</div>
|
||||||
<div class="text-caption q-mb-sm" style="color:#475569;background:#eef2ff;border-radius:6px;padding:4px 8px"><q-icon name="date_range" size="14px" class="q-mr-xs" color="primary" />Répartit sur : <b>{{ suggestWindowLabel }}</b><span class="text-grey-6"> — défaut aujourd'hui + demain ; filtre par date (chips de la liste) pour cibler d'autres jours. Les jobs datés au-delà sont laissés pour plus tard.</span></div>
|
<div class="text-caption q-mb-sm" style="color:#475569;background:#eef2ff;border-radius:6px;padding:4px 8px"><q-icon name="date_range" size="14px" class="q-mr-xs" color="primary" />Répartit sur : <b>{{ suggestWindowLabel }}</b><span class="text-grey-6"> — défaut aujourd'hui + demain ; filtre par date (chips de la liste) pour cibler d'autres jours. Les jobs datés au-delà sont laissés pour plus tard.</span></div>
|
||||||
<div class="row items-center q-mb-xs" style="gap:4px">
|
<div class="row items-center q-mb-xs" style="gap:4px">
|
||||||
<span class="text-caption text-grey-7 q-mr-xs">Rapide :</span>
|
<span class="text-caption text-grey-7 q-mr-xs">Rapide :</span>
|
||||||
|
|
@ -3887,7 +3904,11 @@ function selectAllSector (city) {
|
||||||
if (assignPanel.showMap) refreshAssignMap()
|
if (assignPanel.showMap) refreshAssignMap()
|
||||||
}
|
}
|
||||||
// ── D : SUGGESTION de répartition (glouton proximité + charge + compétence), REVUE en surcouche avant d'appliquer ──
|
// ── D : SUGGESTION de répartition (glouton proximité + charge + compétence), REVUE en surcouche avant d'appliquer ──
|
||||||
const suggestDlg = reactive({ open: false, mode: 'config', plan: [], building: false, applying: false, fromSel: false, techSel: {}, makeShifts: true, strategy: 'smart', view: 'list' }) // strategy: smart|best|balance|enough · view: list|map
|
const suggestDlg = reactive({ open: false, mode: 'config', plan: [], building: false, applying: false, fromSel: false, techSel: {}, makeShifts: true, strategy: 'smart', view: 'list' }) // strategy: optimize|smart|best|balance|enough · view: list|map
|
||||||
|
// 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 */ }
|
||||||
|
watch(solverOpts, v => { try { localStorage.setItem('ops.solverOpts', JSON.stringify(v)) } catch (e) {} }, { deep: true })
|
||||||
function jobDur (j) { return (j && (j.est_min ? j.est_min / 60 : Number(j.duration_h || 1))) || 1 }
|
function jobDur (j) { return (j && (j.est_min ? j.est_min / 60 : Number(j.duration_h || 1))) || 1 }
|
||||||
// Construit un plan job→tech×jour SANS rien écrire (proposition). Chaque job garde sa date si elle est dans la fenêtre.
|
// Construit un plan job→tech×jour SANS rien écrire (proposition). Chaque job garde sa date si elle est dans la fenêtre.
|
||||||
function buildSuggestion () {
|
function buildSuggestion () {
|
||||||
|
|
@ -4088,11 +4109,14 @@ async function optimizeSuggestion () {
|
||||||
;(byDay[e.iso] = byDay[e.iso] || []).push(e)
|
;(byDay[e.iso] = byDay[e.iso] || []).push(e)
|
||||||
}
|
}
|
||||||
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.
|
||||||
|
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 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]
|
||||||
const byName = Object.fromEntries(entries.map(e => [e.jobName, e]))
|
const byName = Object.fromEntries(entries.map(e => [e.jobName, e]))
|
||||||
const jobs = entries.map(e => ({ id: e.jobName, lat: e.lat, lon: e.lon, service_min: Math.max(5, Math.round((e.dur || 1) * 60)), skill: e.skill || '' }))
|
const jobs = entries.map(e => ({ 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]) }))
|
||||||
const vehicles = []
|
const vehicles = []
|
||||||
for (const t of selTechs) {
|
for (const t of selTechs) {
|
||||||
// Fenêtre = quart réel ; SINON quart 8-16 supposé (créé au Publier). Jamais de quart auto le week-end → pas de véhicule.
|
// Fenêtre = quart réel ; SINON quart 8-16 supposé (créé au Publier). Jamais de quart auto le week-end → pas de véhicule.
|
||||||
|
|
@ -4103,7 +4127,7 @@ async function optimizeSuggestion () {
|
||||||
}
|
}
|
||||||
if (!vehicles.length) { out.push(...entries); continue } // aucun tech éligible (ex. week-end sans quart) → garder l'assignation greedy
|
if (!vehicles.length) { out.push(...entries); continue } // aucun tech éligible (ex. week-end sans quart) → garder l'assignation greedy
|
||||||
let res
|
let res
|
||||||
try { res = await roster.optimizeRoutes({ jobs, vehicles, max_seconds: 8, rank_weight: 2 }) } catch (e) { out.push(...entries); continue } // rank_weight faible = DISTANCE prioritaire, spécialité = simple départage
|
try { res = await roster.optimizeRoutes({ jobs, vehicles, max_seconds: solverOpts.maxSeconds, rank_weight: solverOpts.rankWeight, speed_kmh: solverOpts.speedKmh }) } catch (e) { out.push(...entries); continue } // réglages ajustables (curseur distance↔spécialité, vitesse, budget calcul)
|
||||||
if (!res || res.status !== 'OK') { out.push(...entries); continue } // solveur absent/erreur/infaisable → on GARDE l'assignation greedy (ne jamais perdre de jobs)
|
if (!res || res.status !== 'OK') { out.push(...entries); continue } // solveur absent/erreur/infaisable → on GARDE l'assignation greedy (ne jamais perdre de jobs)
|
||||||
const placed = new Set()
|
const placed = new Set()
|
||||||
for (const rt of (res.routes || [])) {
|
for (const rt of (res.routes || [])) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user