fix(dispatch): optimize by default; per-day occupation (not 16h); balance >100% capped 120%; medium=amber

- default strategy = optimize → the FIRST Générer runs the VRP (real
  techs, no more greedy placeholders on first attempt).
- occupation bar is now PER-DAY (worst day), so a 2-day window shows /8h
  not /16h; overload shows in red.
- solver: each vehicle may go up to +20% overtime (≈120% cap, hard) but
  time past the nominal shift is soft-penalized → overflow spreads to
  techs still under 100% before anyone does overtime. Verified: 20 jobs /
  2 techs → 9h+9h (balanced, ≤9.6h), overflow dropped; under capacity it
  still concentrates (no forced equalization).
- priority medium flag = amber (yellow/orange) per request.

Verified live: default optimize on, 16 real techs / 0 placeholders,
per-day /8h occupation, none over, medium flag amber.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-02 17:39:45 -04:00
parent 7fa8c6dc74
commit d9215a20fa
3 changed files with 25 additions and 12 deletions

View File

@ -71,11 +71,11 @@ export function channelMeta (channel) {
// STANDARD 3 NIVEAUX (comme ERPNext) : haute (ROUGE) · moyenne (neutre gris) · basse. '' / medium = non classé → drapeau contour gris (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 = {
high: { icon: 'flag', color: 'red-6', label: 'Haute', rank: 3 }, 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é) medium: { icon: 'flag', color: 'amber-6', label: 'Moyenne', rank: 2 }, // jaune/orange pâle
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 // rétro-compatibilité anciennes valeurs (conversations) : urgent→haute (rouge), normal→moyenne
urgent: { icon: 'flag', color: 'red-6', label: 'Haute', rank: 3 }, urgent: { icon: 'flag', color: 'red-6', label: 'Haute', rank: 3 },
normal: { icon: 'outlined_flag', color: 'grey-5', label: 'Moyenne', rank: 2 }, normal: { icon: 'flag', color: 'amber-6', label: 'Moyenne', rank: 2 },
} }
export const PRIORITY_LEVELS = ['high', 'medium', 'low'] // 3 niveaux (standard ERPNext) — haute en tête export const PRIORITY_LEVELS = ['high', 'medium', 'low'] // 3 niveaux (standard ERPNext) — haute en tête
export function priorityMeta (p) { export function priorityMeta (p) {

View File

@ -3923,7 +3923,7 @@ 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', jobTime: {} }) // strategy: optimize|smart|best|balance|enough · view: list|map · jobTime: {jobName:{ampm,early}} 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
// Contraintes horaires par job ( Optimiser) : AM/PM (fenêtre dure) + « tôt » (urgence début de journée). Session (clé = jobName, survit aux ré-optimisations). // Contraintes horaires par job ( Optimiser) : AM/PM (fenêtre dure) + « tôt » (urgence début de journée). Session (clé = jobName, survit aux ré-optimisations).
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] || {} }
@ -4165,8 +4165,9 @@ async function optimizeSuggestion () {
// RÉDUIT la capacité par la charge DÉJÀ assignée ce jour (sinon surcharge, ex. « déjà 8.8h » + 12h 20/16h). // RÉDUIT la capacité par la charge DÉJÀ assignée ce jour (sinon surcharge, ex. « déjà 8.8h » + 12h 20/16h).
const occ = techDayOcc(t.id, iso); const usedMin = occ ? Math.round((occ.usedH || 0) * 60) : 0 const occ = techDayOcc(t.id, iso); const usedMin = occ ? Math.round((occ.usedH || 0) * 60) : 0
const startMin = Math.min(w.end - 15, w.start + usedMin) const startMin = Math.min(w.end - 15, w.start + usedMin)
const overtimeMin = Math.round(0.2 * (w.end - w.start)) // plafond ~120 % du quart nominal ; au-delà de 100 % = pénalisé (équilibrage)
const h = techHomes.value[t.id] const h = techHomes.value[t.id]
vehicles.push({ id: t.id, name: t.name, skills: t.skills || [], home_lat: (h && isFinite(+h.lat)) ? +h.lat : null, home_lon: (h && isFinite(+h.lon)) ? +h.lon : null, shift_start_min: startMin, shift_end_min: w.end }) vehicles.push({ id: t.id, name: t.name, skills: t.skills || [], home_lat: (h && isFinite(+h.lat)) ? +h.lat : null, home_lon: (h && isFinite(+h.lon)) ? +h.lon : null, shift_start_min: startMin, shift_end_min: w.end, overtime_min: overtimeMin })
} }
if (!vehicles.length) { unassigned.push(...entries); continue } // aucun tech éligible ce jour non assignés if (!vehicles.length) { unassigned.push(...entries); continue } // aucun tech éligible ce jour non assignés
let res let res
@ -4211,15 +4212,22 @@ const suggestGroups = computed(() => {
return { ...d, entries: r.ordered, km: r.km, mins: estTravelMin(r.km), label: dnumOf(d.iso) } return { ...d, entries: r.ordered, km: r.km, mins: estTravelMin(r.km), label: dnumOf(d.iso) }
}) })
// Occupation : capacité (quarts réels ou 8h) + charge DÉJÀ présente sur ces jours, + les heures proposées barre projetée. // Occupation : capacité (quarts réels ou 8h) + charge DÉJÀ présente sur ces jours, + les heures proposées barre projetée.
let cap = 0, existing = 0, placeholder = isHoldId(g.techId) // Occupation PAR JOUR (le vrai plafond = 8h/jour, PAS la somme des 2 jours) : on affiche le JOUR le plus chargé (contrainte qui lie).
if (!placeholder) for (const d of days) { const l = techLoad(techDayOcc(g.techId, d.iso), hasShiftDay(g.techId, d.iso)); cap += (l.cap || 8); existing += (l.h || 0) } const placeholder = isHoldId(g.techId)
const projected = existing + g.hours let worstPct = 0, worstProj = 0, worstCap = 8, existing = 0, over = false
if (!placeholder) for (const d of days) {
const l = techLoad(techDayOcc(g.techId, d.iso), hasShiftDay(g.techId, d.iso))
const dcap = l.cap || 8; const dproj = (l.h || 0) + (d.hours || 0); existing += (l.h || 0)
const pct = dcap ? dproj / dcap * 100 : 0
if (pct > worstPct) { worstPct = pct; worstProj = dproj; worstCap = dcap }
if (dproj > dcap * 1.001) over = true
}
const noShiftDays = placeholder ? [] : days.filter(d => !isWeekendIso(d.iso) && !hasShiftDay(g.techId, d.iso)).map(d => d.iso) // jours (hors WE) sans quart alerte + créer quart const noShiftDays = placeholder ? [] : days.filter(d => !isWeekendIso(d.iso) && !hasShiftDay(g.techId, d.iso)).map(d => d.iso) // jours (hors WE) sans quart alerte + créer quart
const skills = [...new Set(days.flatMap(d => d.entries).map(e => e.skill).filter(Boolean))] // compétences requises par cette file cible d'assignation doit les couvrir const skills = [...new Set(days.flatMap(d => d.entries).map(e => e.skill).filter(Boolean))] // compétences requises par cette file cible d'assignation doit les couvrir
return { return {
...g, dayList: days, km: Math.round(tkm * 10) / 10, mins: estTravelMin(tkm), ...g, dayList: days, km: Math.round(tkm * 10) / 10, mins: estTravelMin(tkm),
cap: Math.round(cap * 10) / 10, existing: Math.round(existing * 10) / 10, projected: Math.round(projected * 10) / 10, cap: Math.round(worstCap * 10) / 10, existing: Math.round(existing * 10) / 10, projected: Math.round(worstProj * 10) / 10,
fillPct: cap ? Math.min(100, Math.round(projected / cap * 100)) : 0, over: !placeholder && projected > cap + 0.1, placeholder, noShiftDays, skills fillPct: Math.min(100, Math.round(worstPct)), over: !placeholder && over, placeholder, noShiftDays, skills
} }
}).sort((a, b) => b.jobs - a.jobs) }).sort((a, b) => b.jobs - a.jobs)
}) })

View File

@ -124,21 +124,26 @@ def solve_route(req: dict) -> dict:
horizon = 1440 horizon = 1440
for veh in vehicles: for veh in vehicles:
horizon = max(horizon, int(veh.get("shift_end_min") or 0)) horizon = max(horizon, int(veh.get("shift_end_min") or 0) + int(veh.get("overtime_min") or 0))
for j in jobs: for j in jobs:
if j.get("tw_end_min") is not None: if j.get("tw_end_min") is not None:
horizon = max(horizon, int(j["tw_end_min"])) horizon = max(horizon, int(j["tw_end_min"]))
routing.AddDimension(time_idx, horizon, horizon, False, "Time") # slack=horizon (attente permise), start non forcé à 0 routing.AddDimension(time_idx, horizon, horizon, False, "Time") # slack=horizon (attente permise), start non forcé à 0
time_dim = routing.GetDimensionOrDie("Time") time_dim = routing.GetDimensionOrDie("Time")
# Fenêtre de QUART par véhicule (début/fin de tournée dans le quart). # Fenêtre de QUART par véhicule. La fin peut aller jusqu'à +overtime_min (plafond ~120%), MAIS toute heure au-delà du quart
# nominal est PÉNALISÉE (borne souple) → le solveur ÉTALE l'excédent vers les techs encore sous 100% avant de faire des heures sup.
ot_coef = int(req.get("overtime_coef") or 20)
for v, veh in enumerate(vehicles): for v, veh in enumerate(vehicles):
s0 = int(veh.get("shift_start_min") or 0) s0 = int(veh.get("shift_start_min") or 0)
s1 = int(veh.get("shift_end_min") or horizon) s1 = int(veh.get("shift_end_min") or horizon)
if s1 < s0: if s1 < s0:
s1 = horizon s1 = horizon
ot = max(0, int(veh.get("overtime_min") or 0))
time_dim.CumulVar(routing.Start(v)).SetRange(s0, s1) time_dim.CumulVar(routing.Start(v)).SetRange(s0, s1)
time_dim.CumulVar(routing.End(v)).SetRange(s0, s1) time_dim.CumulVar(routing.End(v)).SetRange(s0, s1 + ot) # jusqu'à ~120 % du quart
if ot > 0:
time_dim.SetCumulVarSoftUpperBound(routing.End(v), s1, ot_coef) # au-delà du quart nominal = coûteux → équilibre
# Fenêtre horaire par job (optionnelle) + compétence (véhicules autorisés) + pénalité de drop. # Fenêtre horaire par job (optionnelle) + compétence (véhicules autorisés) + pénalité de drop.
# NB : SetAllowedVehiclesForIndex a un typemap Span cassé en ortools 9.15 → on contraint VehicleVar ∈ autorisés {-1} # NB : SetAllowedVehiclesForIndex a un typemap Span cassé en ortools 9.15 → on contraint VehicleVar ∈ autorisés {-1}