feat(ops): Suggérer — re-optimize existing (dispatched) jobs as a simulation

Suggérer peut servir de SIMULATION sur des jobs DÉJÀ dispatchés : nouveau
toggle « Ré-optimiser aussi les jobs déjà assignés » dans la config. Quand
activé, le solveur VRP reçoit AUSSI les noms des jobs assignés de la
fenêtre (assignedNamesForWindow, depuis occByTechDay) en plus du pool →
repropose une répartition de TOUT (existant + nouveaux jobs ajoutés).
Force le solveur (l'heuristique locale n'a que le pool en mémoire).
Aperçu seulement — rien n'est écrit avant « Appliquer » (qui réassigne).

Vérifié : toggle rendu dans la config Suggérer, 0 nouvelle erreur. Le
résultat de ré-optimisation (solveur roster-solver) est à confirmer en
prod (comme les autres runs solveur).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-06 08:47:31 -04:00
parent 92c320d08f
commit add3c08cee

View File

@ -1038,6 +1038,11 @@
<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>
<!-- Simulation « -optimiser l'existant » : inclut AUSSI les jobs déjà assignés de la fenêtre (le solveur les replace) → comparer/améliorer une tournée déjà dispatchée, avec les nouveaux jobs. Rien n'est écrit avant « Appliquer ». -->
<div class="row items-center q-mb-sm" style="gap:6px">
<q-toggle v-model="suggestDlg.includeAssigned" dense size="sm" color="deep-purple" label="Ré-optimiser aussi les jobs déjà assignés (simulation)" />
<q-icon name="info" size="15px" color="grey-5"><q-tooltip class="bg-grey-9" style="font-size:11px">Inclut les {{ assignedNamesForWindow().length }} job(s) déjà assigné(s) du/des jour(s) le solveur repropose une répartition de TOUT (existant + nouveaux). Force le solveur VRP. Aperçu seulement « Appliquer » réassigne.</q-tooltip></q-icon>
</div>
<!-- Réglages optimiseur : UN COÛT par critère (pas de slider) persistés localement --> <!-- Réglages optimiseur : UN COÛT par critère (pas de slider) persistés localement -->
<div v-if="suggestDlg.strategy === 'optimize'" class="row items-center q-gutter-sm q-mb-sm" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:6px 10px;flex-wrap:wrap"> <div v-if="suggestDlg.strategy === 'optimize'" class="row items-center q-gutter-sm q-mb-sm" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:6px 10px;flex-wrap:wrap">
<span class="text-caption text-grey-7">Coûts :</span> <span class="text-caption text-grey-7">Coûts :</span>
@ -4242,7 +4247,13 @@ 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: 'optimize', view: 'list', jobTime: {} }) // défaut = solveur VRP (1re génération optimise déjà, plus de placeholders) · strategy: optimize|smart|best|balance|enough const suggestDlg = reactive({ open: false, mode: 'config', plan: [], building: false, applying: false, fromSel: false, techSel: {}, makeShifts: true, strategy: 'optimize', view: 'list', jobTime: {}, includeAssigned: false }) // défaut = solveur VRP · strategy: optimize|smart|best|balance|enough · includeAssigned = simulation re-optimisant AUSSI les jobs déjà assignés
// Noms des jobs DÉJÀ assignés sur la fenêtre (pour la simulation « ré-optimiser l'existant ») depuis l'occupation.
function assignedNamesForWindow () {
const win = new Set(suggestWindow.value); const out = new Set()
for (const key in occByTechDay.value) { if (!win.has(key.split('|')[1])) continue; for (const n of (occByTechDay.value[key].jobs || [])) if (n) out.add(n) }
return [...out]
}
const prevOpen = reactive({}) // revue : sections « déjà assigné(s) » dépliées (clé techId|iso) const prevOpen = reactive({}) // revue : sections « déjà assigné(s) » dépliées (clé techId|iso)
// Revue : détail du ticket au clic + temps révisable + temps de route ENTRE arrêts (OSRM) // Revue : détail du ticket au clic + temps révisable + temps de route ENTRE arrêts (OSRM)
const durOverride = reactive({}) // jobName heures révisées par le dispatcher (survit aux Ré-optimiser de la session) const durOverride = reactive({}) // jobName heures révisées par le dispatcher (survit aux Ré-optimiser de la session)
@ -4637,6 +4648,9 @@ function techQuality (t) {
const suggestTechsRanked = computed(() => [...(visibleTechs.value || [])].sort((a, b) => techQuality(a) - techQuality(b))) const suggestTechsRanked = computed(() => [...(visibleTechs.value || [])].sort((a, b) => techQuality(a) - techQuality(b)))
async function runSuggestion () { async function runSuggestion () {
if (!suggestSelCount.value) { $q.notify({ type: 'warning', message: 'Choisis au moins un technicien disponible.', timeout: 2500 }); return } if (!suggestSelCount.value) { $q.notify({ type: 'warning', message: 'Choisis au moins un technicien disponible.', timeout: 2500 }); return }
// « Ré-optimiser l'existant » : le solveur récupère les données des jobs assignés par leur nom (l'heuristique locale
// n'a que le pool en mémoire) on force le solveur VRP.
if (suggestDlg.includeAssigned) suggestDlg.strategy = 'optimize'
suggestDlg.building = true suggestDlg.building = true
try { try {
if (suggestDlg.strategy === 'optimize') await optimizeSuggestion() // solveur VRP OR-Tools (routes + compétences + temps sur place) if (suggestDlg.strategy === 'optimize') await optimizeSuggestion() // solveur VRP OR-Tools (routes + compétences + temps sur place)
@ -4678,7 +4692,8 @@ async function optimizeSuggestion () {
try { try {
const r = await roster.optimizePlanHub({ const r = await roster.optimizePlanHub({
dates: suggestWindow.value, tech_ids: selTechs.map(t => t.id), dates: suggestWindow.value, tech_ids: selTechs.map(t => t.id),
job_names: suggestJobs().map(j => j.name), // ne répartir QUE les jobs des jours cochés (ou la sélection lasso) // job_names = pool filtré (+ jobs DÉJÀ assignés de la fenêtre si « ré-optimiser l'existant ») le solveur les replace.
job_names: [...new Set([...suggestJobs().map(j => j.name), ...(suggestDlg.includeAssigned ? assignedNamesForWindow() : [])])],
opts: { rank_weight: solverOpts.rankWeight, speed_kmh: solverOpts.speedKmh, max_seconds: solverOpts.maxSeconds, overtime_coef: solverOpts.overtimeCoef }, opts: { rank_weight: solverOpts.rankWeight, speed_kmh: solverOpts.speedKmh, max_seconds: solverOpts.maxSeconds, overtime_coef: solverOpts.overtimeCoef },
dur_overrides: { ...durOverride }, job_windows, dur_overrides: { ...durOverride }, job_windows,
}) })