feat(dispatch): urgency → early-in-day + AM/PM windows per job
Time-aware dispatch in the VRP optimizer: - solver: per-job urgent_weight → SetCumulVarSoftUpperBound(idx, 0, w), a soft cost on arrival time that pulls urgent jobs to the START of the route (conditions the tech's day start). Verified: a far urgent job is served first instead of last. AM/PM handled via existing tw_start/end. - SPA: per-entry AM / PM / ⚡ chips (session map suggestDlg.jobTime, survives re-solves) + a "Ré-optimiser" button. optimizeSuggestion maps AM→[480,720], PM→[720,960] (hard window) and urgency (job priority OR manual ⚡) → urgent_weight. Verified: chips render, toggle persists across re-optimize, OSRM still engaged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3513f7e15f
commit
f5a7547c1f
|
|
@ -1075,6 +1075,10 @@
|
||||||
<span class="text-grey-6 q-mx-xs" style="font-size:11px;white-space:nowrap">{{ Math.round(e.dur * 10) / 10 }}h</span>
|
<span class="text-grey-6 q-mx-xs" style="font-size:11px;white-space:nowrap">{{ Math.round(e.dur * 10) / 10 }}h</span>
|
||||||
<q-icon v-if="!e.capable" name="warning" size="14px" color="deep-orange" class="q-mr-xs"><q-tooltip>{{ e.techName }} n'a pas la compétence « {{ e.skill }} » — à réassigner</q-tooltip></q-icon>
|
<q-icon v-if="!e.capable" name="warning" size="14px" color="deep-orange" class="q-mr-xs"><q-tooltip>{{ e.techName }} n'a pas la compétence « {{ e.skill }} » — à réassigner</q-tooltip></q-icon>
|
||||||
<q-icon v-if="e.noShift" name="event_busy" size="14px" color="amber-8" class="q-mr-xs"><q-tooltip>Aucun quart publié ce jour (assignable, mais ⚠ hors quart)</q-tooltip></q-icon>
|
<q-icon v-if="e.noShift" name="event_busy" size="14px" color="amber-8" class="q-mr-xs"><q-tooltip>Aucun quart publié ce jour (assignable, mais ⚠ hors quart)</q-tooltip></q-icon>
|
||||||
|
<!-- Contraintes horaires (prises en compte par ⚡ Ré-optimiser) : AM/PM = fenêtre · ⚡ = urgent, servir en début de journée -->
|
||||||
|
<span class="jt-chip" :class="{ on: jobTimeOf(e.jobName).ampm === 'am' }" @click="toggleJobAmpm(e.jobName, 'am')"><q-tooltip>Avant-midi (8-12) — Ré-optimiser pour appliquer</q-tooltip>AM</span>
|
||||||
|
<span class="jt-chip" :class="{ on: jobTimeOf(e.jobName).ampm === 'pm' }" @click="toggleJobAmpm(e.jobName, 'pm')"><q-tooltip>Après-midi (12-16) — Ré-optimiser pour appliquer</q-tooltip>PM</span>
|
||||||
|
<span class="jt-chip jt-early" :class="{ on: jobTimeOf(e.jobName).early }" @click="toggleJobEarly(e.jobName)"><q-tooltip>Urgent — servir en DÉBUT de journée (Ré-optimiser)</q-tooltip>⚡</span>
|
||||||
<q-space />
|
<q-space />
|
||||||
<q-btn flat dense round size="sm" icon="swap_horiz" color="grey-7"><q-tooltip>Déplacer vers un autre tech</q-tooltip>
|
<q-btn flat dense round size="sm" icon="swap_horiz" color="grey-7"><q-tooltip>Déplacer vers un autre tech</q-tooltip>
|
||||||
<q-menu anchor="bottom right" self="top right"><q-list dense style="min-width:220px;max-height:42vh;overflow:auto">
|
<q-menu anchor="bottom right" self="top right"><q-list dense style="min-width:220px;max-height:42vh;overflow:auto">
|
||||||
|
|
@ -1097,6 +1101,7 @@
|
||||||
<q-btn v-if="suggestDlg.mode === 'review'" flat no-caps icon="arrow_back" label="Techniciens" @click="suggestDlg.mode = 'config'" />
|
<q-btn v-if="suggestDlg.mode === 'review'" flat no-caps icon="arrow_back" label="Techniciens" @click="suggestDlg.mode = 'config'" />
|
||||||
<q-space v-if="suggestDlg.mode === 'review'" />
|
<q-space v-if="suggestDlg.mode === 'review'" />
|
||||||
<q-btn flat no-caps label="Annuler" v-close-popup />
|
<q-btn flat no-caps label="Annuler" v-close-popup />
|
||||||
|
<q-btn v-if="suggestDlg.mode === 'review'" outline no-caps color="primary" icon="bolt" label="Ré-optimiser" :loading="suggestDlg.building" @click="reoptimize"><q-tooltip>Relance le solveur VRP en tenant compte des contraintes AM/PM/⚡ posées sur les jobs</q-tooltip></q-btn>
|
||||||
<q-btn v-if="suggestDlg.mode === 'config'" unelevated no-caps color="primary" icon="auto_awesome" :label="'Générer (' + suggestSelCount + ' tech)'" :loading="suggestDlg.building" :disable="!suggestSelCount" @click="runSuggestion" />
|
<q-btn v-if="suggestDlg.mode === 'config'" unelevated no-caps color="primary" icon="auto_awesome" :label="'Générer (' + suggestSelCount + ' tech)'" :loading="suggestDlg.building" :disable="!suggestSelCount" @click="runSuggestion" />
|
||||||
<q-btn v-else unelevated no-caps color="primary" icon="check" :label="'Appliquer ' + suggestDlg.plan.length + ' assignation(s)'" :loading="suggestDlg.applying" :disable="!suggestDlg.plan.length" @click="applySuggestion" />
|
<q-btn v-else unelevated no-caps color="primary" icon="check" :label="'Appliquer ' + suggestDlg.plan.length + ' assignation(s)'" :loading="suggestDlg.applying" :disable="!suggestDlg.plan.length" @click="applySuggestion" />
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
|
|
@ -3904,7 +3909,12 @@ 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: optimize|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', jobTime: {} }) // strategy: optimize|smart|best|balance|enough · view: list|map · jobTime: {jobName:{ampm,early}}
|
||||||
|
// 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)
|
||||||
|
function jobTimeOf (name) { return suggestDlg.jobTime[name] || {} }
|
||||||
|
function toggleJobAmpm (name, v) { const c = suggestDlg.jobTime[name] || {}; suggestDlg.jobTime[name] = { ...c, ampm: c.ampm === v ? null : v } }
|
||||||
|
function toggleJobEarly (name) { const c = suggestDlg.jobTime[name] || {}; suggestDlg.jobTime[name] = { ...c, early: !c.early } }
|
||||||
// Réglages de l'optimiseur VRP (curseur distance↔spécialité, vitesse repli, budget de calcul) — persistés localement, ajustables sans redéploiement.
|
// 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 })
|
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 */ }
|
try { const s = JSON.parse(localStorage.getItem('ops.solverOpts') || 'null'); if (s && typeof s === 'object') Object.assign(solverOpts, s) } catch (e) { /* défauts */ }
|
||||||
|
|
@ -4088,6 +4098,12 @@ async function runSuggestion () {
|
||||||
suggestDlg.view = 'list'; suggestMapDay.value = suggestWindow.value[0] || '' // carte des tournées : 1er jour de la fenêtre par défaut
|
suggestDlg.view = 'list'; suggestMapDay.value = suggestWindow.value[0] || '' // carte des tournées : 1er jour de la fenêtre par défaut
|
||||||
suggestDlg.mode = 'review'
|
suggestDlg.mode = 'review'
|
||||||
}
|
}
|
||||||
|
// Ré-optimiser depuis la revue : relance le solveur VRP en tenant compte des contraintes AM/PM/⚡ posées sur les jobs.
|
||||||
|
async function reoptimize () {
|
||||||
|
suggestDlg.strategy = 'optimize'; suggestDlg.building = true
|
||||||
|
try { await optimizeSuggestion() } catch (e) { err(e) } finally { suggestDlg.building = false }
|
||||||
|
suggestDlg.view = 'list'; suggestMapDay.value = suggestWindow.value[0] || ''
|
||||||
|
}
|
||||||
// Fenêtre de quart (min depuis minuit) d'un tech un jour donné, depuis ses cellules + le modèle. null si aucun quart régulier.
|
// Fenêtre de quart (min depuis minuit) d'un tech un jour donné, depuis ses cellules + le modèle. null si aucun quart régulier.
|
||||||
function shiftWindowMin (techId, iso) {
|
function shiftWindowMin (techId, iso) {
|
||||||
let s = Infinity, e = -Infinity
|
let s = Infinity, e = -Infinity
|
||||||
|
|
@ -4112,11 +4128,17 @@ async function optimizeSuggestion () {
|
||||||
// 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.
|
// 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 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 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: 6, high: 3, 'élevée': 3 }[p] || 0) // priorité → poids « servir tôt »
|
||||||
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 || '', priority_boost: prBoost(poolPr[e.jobName]) }))
|
const jobs = entries.map(e => {
|
||||||
|
const jt = suggestDlg.jobTime[e.jobName] || {}
|
||||||
|
const win = AMPM_WIN[jt.ampm] // AM/PM → fenêtre horaire dure
|
||||||
|
const urgent_weight = Math.max(prEarly(poolPr[e.jobName]), jt.early ? 12 : 0) // urgence (priorité OU ⚡ manuel) → début de journée
|
||||||
|
return { 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]), urgent_weight, ...(win ? { tw_start_min: win[0], tw_end_min: win[1] } : {}) }
|
||||||
|
})
|
||||||
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.
|
||||||
|
|
@ -5067,6 +5089,10 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
|
||||||
.suggest-occ-fill.high { background: #f59e0b; }
|
.suggest-occ-fill.high { background: #f59e0b; }
|
||||||
.suggest-occ-fill.over { background: #ef4444; }
|
.suggest-occ-fill.over { background: #ef4444; }
|
||||||
.suggest-occ-txt { font-size: 11px; font-weight: 700; color: #475569; white-space: nowrap; }
|
.suggest-occ-txt { font-size: 11px; font-weight: 700; color: #475569; white-space: nowrap; }
|
||||||
|
.jt-chip { display: inline-flex; align-items: center; font-size: 9px; font-weight: 700; padding: 1px 4px; margin-right: 2px; border-radius: 6px; border: 1px solid #cbd5e1; color: #64748b; background: #fff; cursor: pointer; user-select: none; line-height: 1.4; }
|
||||||
|
.jt-chip:hover { border-color: #6366f1; }
|
||||||
|
.jt-chip.on { background: #6366f1; color: #fff; border-color: #6366f1; }
|
||||||
|
.jt-early.on { background: #f59e0b; border-color: #f59e0b; }
|
||||||
.suggest-day { padding: 2px 8px 6px; }
|
.suggest-day { padding: 2px 8px 6px; }
|
||||||
.suggest-day-lbl { font-size: 11px; font-weight: 700; color: #475569; padding: 4px 2px 2px; }
|
.suggest-day-lbl { font-size: 11px; font-weight: 700; color: #475569; padding: 4px 2px 2px; }
|
||||||
.suggest-entry { display: flex; align-items: center; gap: 2px; padding: 4px 6px; border-radius: 6px; font-size: 12.5px; }
|
.suggest-entry { display: flex; align-items: center; gap: 2px; padding: 4px 6px; border-radius: 6px; font-size: 12.5px; }
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,12 @@ def solve_route(req: dict) -> dict:
|
||||||
idx = manager.NodeToIndex(i)
|
idx = manager.NodeToIndex(i)
|
||||||
tws, twe = j.get("tw_start_min"), j.get("tw_end_min")
|
tws, twe = j.get("tw_start_min"), j.get("tw_end_min")
|
||||||
if tws is not None and twe is not None and int(twe) >= int(tws):
|
if tws is not None and twe is not None and int(twe) >= int(tws):
|
||||||
time_dim.CumulVar(idx).SetRange(int(tws), int(twe))
|
time_dim.CumulVar(idx).SetRange(int(tws), int(twe)) # fenêtre AM/PM (dur) : ex. AM [480,720], PM [720,960]
|
||||||
|
uw = int(j.get("urgent_weight") or 0)
|
||||||
|
if uw > 0:
|
||||||
|
# URGENCE → servir TÔT : coût = uw × heure d'arrivée (borne souple à 0) → tire le job en début de tournée
|
||||||
|
# (conditionne le début de journée du tech). Reste souple : ne casse pas la faisabilité, se combine aux fenêtres AM/PM.
|
||||||
|
time_dim.SetCumulVarSoftUpperBound(idx, 0, uw)
|
||||||
sk = j.get("skill")
|
sk = j.get("skill")
|
||||||
allowed = [v for v, veh in enumerate(vehicles) if skill_ok(veh, sk)]
|
allowed = [v for v, veh in enumerate(vehicles) if skill_ok(veh, sk)]
|
||||||
if len(allowed) < n_veh: # compétence restreint réellement → filtre DUR
|
if len(allowed) < n_veh: # compétence restreint réellement → filtre DUR
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user