fix(dispatch): optimizer respects existing load; drop pseudo-tech placeholders; edit skills from review
- Overload fix: each vehicle's window is reduced by the tech's already- assigned load that day (shift_start += existing) — no more 20.8/16h (verified: William 20.8→5.3/8h). Capacity is now real. - No more "Tournée N / non casé" pseudo-techs: the optimizer uses ONLY the selected techs; everything it can't place goes to ONE plain "⚠️ Non assignés" bucket (weekend + capacity/skill overflow). Assign them via the proximity+load ⇄ menu, or add techs. - Not forced to fill every tech: unneeded techs are simply left empty (day off) — the VRP no longer fake-distributes. - Edit/reorder skills from the optimizer: ✏️ on each tech header opens the same skill editor (drag-reorder chips + ★ + rank list). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7d1a5b067f
commit
86bd8080d2
|
|
@ -1052,6 +1052,8 @@
|
|||
<q-space />
|
||||
<!-- Quart manquant (Q2 §1) : alerte + créer le quart 8-16 (local, enregistré au Publier) -->
|
||||
<q-btn v-if="g.noShiftDays && g.noShiftDays.length" flat dense no-caps size="sm" color="amber-8" icon="event_busy" :label="'Créer quart' + (g.noShiftDays.length > 1 ? ' ×' + g.noShiftDays.length : '')" @click="createShiftForGroup(g)"><q-tooltip>{{ g.techName }} n'a pas de quart {{ g.noShiftDays.length > 1 ? 'sur ' + g.noShiftDays.length + ' jour(s)' : 'ce jour' }} — créer un quart 8-16</q-tooltip></q-btn>
|
||||
<!-- Éditer / réordonner les compétences du tech directement depuis l'optimiseur (même éditeur : chips ★ + glisser priorité) -->
|
||||
<q-btn v-if="!g.placeholder" flat dense round size="sm" icon="edit" color="grey-6" @click="openSkillEditorFor(g.techId, $event)"><q-tooltip>Compétences & niveaux de {{ g.techName }} — glisser pour l'ordre de priorité</q-tooltip></q-btn>
|
||||
<!-- Assigner / fusionner TOUTE la file (marche depuis un placeholder → vrai tech, ou pour fusionner 2 files) -->
|
||||
<q-btn flat dense round size="sm" :icon="g.placeholder ? 'person_add' : 'drive_file_move'" :color="g.placeholder ? 'amber-9' : 'grey-7'"><q-tooltip>{{ g.placeholder ? 'Assigner cette tournée à un tech' : ('Déplacer / fusionner tous les jobs de ' + g.techName) }}</q-tooltip>
|
||||
<q-menu anchor="bottom right" self="top right"><q-list dense style="min-width:250px;max-height:42vh;overflow:auto">
|
||||
|
|
@ -1811,7 +1813,7 @@ const dayList = computed(() => {
|
|||
function dowOf (iso) { const [y, m, d] = iso.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)).getUTCDay() }
|
||||
const isWeekendIso = iso => { const d = dowOf(iso); return d === 0 || d === 6 } // samedi/dimanche → PAS de quart auto (techs de fin de semaine dédiés)
|
||||
// File PLACEHOLDER (dispatch auto) : pas un vrai tech → « à assigner ». `__wknd__` (legacy) + `__hold__|<iso>|<n>` (tournées sans quart, tout jour).
|
||||
const isHoldId = id => id === '__wknd__' || (typeof id === 'string' && id.startsWith('__hold__'))
|
||||
const isHoldId = id => id === '__wknd__' || id === '__unassigned__' || (typeof id === 'string' && id.startsWith('__hold__'))
|
||||
// ── MODE JOUR (style Gaiia) : 1 seule colonne pleine largeur → timelines hautes + règle d'heures, idéal pour disposer
|
||||
// les jobs (glisser depuis le panneau « jobs du jour » vers un tech, réordonner via l'éditeur de jour). ─────────────
|
||||
const dayMode = computed(() => dayList.value.length === 1)
|
||||
|
|
@ -2177,6 +2179,7 @@ const skillDialog = ref(null) // tech dont on édite les compétences
|
|||
const skillMenuTarget = ref(null) // élément cliqué = ancre du popover (près de la souris, sur la rangée)
|
||||
const skillMenuShown = ref(false)
|
||||
function openSkillEditor (t, ev) { skillDialog.value = t; skillMenuTarget.value = (ev && ev.currentTarget) || null; skillMenuShown.value = true }
|
||||
function openSkillEditorFor (techId, ev) { const t = (visibleTechs.value || []).find(x => x.id === techId); if (t) openSkillEditor(t, ev) } // depuis la revue de l'optimiseur
|
||||
// TagEditor (showLevel) ↔ tech : skills = libellés (CSV) · skill_levels = {compétence: niveau 1–5} (JSON).
|
||||
// Par compétence : SCORE (maîtrise 1–5, qualité) + EFFICACITÉ (facteur vitesse ; défaut = efficacité globale).
|
||||
function skillLevelOf (t, sk) { return (t.skill_levels && t.skill_levels[sk]) || 0 } // 0 = non défini (étoiles vides) ; pas de défaut trompeur
|
||||
|
|
@ -4125,11 +4128,9 @@ async function optimizeSuggestion () {
|
|||
buildSuggestion() // pose les jours + skill/dur/coords/reqLevel sur chaque entrée + les placeholders
|
||||
suggestDlg.makeShifts = true // on SUPPOSE un quart 8-16 pour chaque tech sélectionné → à créer au moment du Publier
|
||||
const plan = suggestDlg.plan
|
||||
const byDay = {}; const kept = []
|
||||
const byDay = {}; const unassigned = [] // PAS de faux « tech placeholder » : on n'utilise QUE les techs sélectionnés ; le reste = 1 seule liste « non assignés »
|
||||
for (const e of plan) {
|
||||
// Les placeholders de SEMAINE sont replongés dans leur jour : on suppose un quart pour chaque tech sélectionné → le solveur les case.
|
||||
// Les placeholders de WEEK-END restent (jamais de quart auto le week-end).
|
||||
if (e.placeholder && isWeekendIso(e.iso)) { kept.push(e); continue }
|
||||
if (e.placeholder && isWeekendIso(e.iso)) { unassigned.push(e); continue } // week-end : pas de quart auto → non assigné
|
||||
;(byDay[e.iso] = byDay[e.iso] || []).push(e)
|
||||
}
|
||||
const selTechs = (visibleTechs.value || []).filter(t => suggestDlg.techSel[t.id])
|
||||
|
|
@ -4137,7 +4138,7 @@ async function optimizeSuggestion () {
|
|||
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 prEarly = p => ({ urgent: 10, high: 4, 'élevée': 4 }[p] || 0) // priorité → poids « servir tôt »
|
||||
const out = [...kept]
|
||||
const out = []
|
||||
for (const iso of Object.keys(byDay)) {
|
||||
const entries = byDay[iso]
|
||||
const byName = Object.fromEntries(entries.map(e => [e.jobName, e]))
|
||||
|
|
@ -4154,20 +4155,25 @@ async function optimizeSuggestion () {
|
|||
// Fenêtre = quart réel ; SINON quart 8-16 supposé (créé au Publier). Jamais de quart auto le week-end → pas de véhicule.
|
||||
const w = shiftWindowMin(t.id, iso) || (isWeekendIso(iso) ? null : { start: 480, end: 960 })
|
||||
if (!w) continue
|
||||
// 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 startMin = Math.min(w.end - 15, w.start + usedMin)
|
||||
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: w.start, 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 })
|
||||
}
|
||||
if (!vehicles.length) { out.push(...entries); continue } // aucun tech éligible (ex. week-end sans quart) → garder l'assignation greedy
|
||||
if (!vehicles.length) { unassigned.push(...entries); continue } // aucun tech éligible ce jour → non assignés
|
||||
let res
|
||||
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)
|
||||
try { res = await roster.optimizeRoutes({ jobs, vehicles, max_seconds: solverOpts.maxSeconds, rank_weight: solverOpts.rankWeight, speed_kmh: solverOpts.speedKmh }) } catch (e) { unassigned.push(...entries); continue }
|
||||
if (!res || res.status !== 'OK') { unassigned.push(...entries); continue } // solveur absent/erreur → non assignés (ne jamais perdre de jobs)
|
||||
const placed = new Set()
|
||||
for (const rt of (res.routes || [])) {
|
||||
const t = selTechs.find(x => x.id === rt.vehicle) || { id: rt.vehicle, name: rt.vehicle_name }
|
||||
for (const st of (rt.stops || [])) { const e0 = byName[st.job_id]; if (!e0) continue; placed.add(st.job_id); out.push({ ...e0, techId: t.id, techName: t.name, placeholder: false, noShift: !hasShiftDay(t.id, iso), capable: true }) } // noShift → pastille « Créer quart »
|
||||
}
|
||||
for (const jid of (res.unassigned || [])) { const e0 = byName[jid]; if (!e0 || placed.has(jid)) continue; out.push({ ...e0, techId: `__hold__|${iso}|opt`, techName: `⏳ ${FR_DOW[dowOf(iso)] || ''} ${iso.slice(8)} — non casé (à assigner)`, placeholder: true, noShift: false }) }
|
||||
for (const jid of (res.unassigned || [])) { const e0 = byName[jid]; if (e0 && !placed.has(jid)) unassigned.push(e0) }
|
||||
}
|
||||
// UN SEUL groupe « Non assignés » (aucun faux tech) : capacité/compétence insuffisante avec les techs sélectionnés → en ajouter, ou assigner à la main via le menu ⇄ (classé proximité + charge).
|
||||
for (const e of unassigned) out.push({ ...e, techId: '__unassigned__', techName: '⚠️ Non assignés — ajoute des techs ou assigne à la main', placeholder: true, noShift: false })
|
||||
suggestDlg.plan = out
|
||||
}
|
||||
// D v2 — ordonne une tournée par PLUS-PROCHE-VOISIN depuis le domicile du tech + distance totale (haversine, sans appel réseau).
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user