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:
parent
7fa8c6dc74
commit
d9215a20fa
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user