planif Suggérer : ordre AM→PM des arrêts + picker « déplacer vers » filtré
- nnOrder respecte la fenêtre horaire : bloc AM (8-12) → sans-fenêtre → PM (12-16), plus-proche-voisin DANS chaque bloc. Avant : NN pur → une job PM pouvait finir 1re, une « premier en AM » 2e (bug signalé). Utilise le toggle AM/PM explicite OU detectAmpm (texte : « **premier en AM** », « PM Fin de journée »). - Picker « déplacer vers un autre tech » : n'affiche que les techs SÉLECTIONNÉS + compétents par défaut, + « Afficher tous les techniciens… » pour lever le filtre (repli sur tous si aucun sélectionné-compétent). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
746a8de6df
commit
3a0b65942c
|
|
@ -1184,6 +1184,8 @@
|
||||||
<q-item-section>{{ t.name }}<span v-if="planCountByTech[t.id] && t.id !== g.techId" class="text-teal-7" style="font-size:11px"> · fusionner ({{ planCountByTech[t.id] }})</span></q-item-section>
|
<q-item-section>{{ t.name }}<span v-if="planCountByTech[t.id] && t.id !== g.techId" class="text-teal-7" style="font-size:11px"> · fusionner ({{ planCountByTech[t.id] }})</span></q-item-section>
|
||||||
<q-item-section side v-if="!covers(t, g.skills)"><q-icon name="block" color="grey-5" size="15px"><q-tooltip>Sans la compétence requise</q-tooltip></q-icon></q-item-section>
|
<q-item-section side v-if="!covers(t, g.skills)"><q-icon name="block" color="grey-5" size="15px"><q-tooltip>Sans la compétence requise</q-tooltip></q-icon></q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
<q-separator v-if="!showAllMoveTechs" />
|
||||||
|
<q-item v-if="!showAllMoveTechs" clickable @click="showAllMoveTechs = true"><q-item-section class="text-primary" style="font-size:12px"><q-icon name="expand_more" size="14px" class="q-mr-xs" />Afficher tous les techniciens…</q-item-section></q-item>
|
||||||
</q-list></q-menu>
|
</q-list></q-menu>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1253,6 +1255,8 @@
|
||||||
<q-item-section>{{ r.name }}<div class="text-grey-6" style="font-size:10px">{{ r.distKm != null ? '📍 ≈' + (Math.round(r.distKm * 10) / 10) + ' km' : 'sans domicile' }} · {{ r.h }}/{{ r.cap }}h<span v-if="r.noShift"> · sans quart</span></div></q-item-section>
|
<q-item-section>{{ r.name }}<div class="text-grey-6" style="font-size:10px">{{ r.distKm != null ? '📍 ≈' + (Math.round(r.distKm * 10) / 10) + ' km' : 'sans domicile' }} · {{ r.h }}/{{ r.cap }}h<span v-if="r.noShift"> · sans quart</span></div></q-item-section>
|
||||||
<q-item-section side v-if="!r.capable"><q-icon name="block" color="grey-5" size="15px"><q-tooltip>Sans la compétence</q-tooltip></q-icon></q-item-section>
|
<q-item-section side v-if="!r.capable"><q-icon name="block" color="grey-5" size="15px"><q-tooltip>Sans la compétence</q-tooltip></q-icon></q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
<q-separator v-if="!showAllMoveTechs" />
|
||||||
|
<q-item v-if="!showAllMoveTechs" clickable @click="showAllMoveTechs = true"><q-item-section class="text-primary" style="font-size:12px"><q-icon name="expand_more" size="14px" class="q-mr-xs" />Afficher tous les techniciens…</q-item-section></q-item>
|
||||||
</q-list></q-menu>
|
</q-list></q-menu>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn flat dense round size="sm" icon="computer" :color="isJobRemote(e.jobName) ? 'indigo-6' : 'grey-4'" @click="toggleJobRemote(e)"><q-tooltip>{{ isJobRemote(e.jobName) ? '🖥 SANS déplacement (à distance) — Ré-optimiser pour l\'exclure des tournées' : 'Marquer « sans déplacement » (configurable à distance, ex. boîtier tel via ACS)' }}</q-tooltip></q-btn>
|
<q-btn flat dense round size="sm" icon="computer" :color="isJobRemote(e.jobName) ? 'indigo-6' : 'grey-4'" @click="toggleJobRemote(e)"><q-tooltip>{{ isJobRemote(e.jobName) ? '🖥 SANS déplacement (à distance) — Ré-optimiser pour l\'exclure des tournées' : 'Marquer « sans déplacement » (configurable à distance, ex. boîtier tel via ACS)' }}</q-tooltip></q-btn>
|
||||||
|
|
@ -4811,6 +4815,7 @@ 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 }
|
||||||
|
showAllMoveTechs.value = false // picker « déplacer vers » : repart filtré aux techs sélectionnés+compétents
|
||||||
// « Ré-optimiser l'existant » : le solveur récupère les données des jobs assignés par leur nom (l'heuristique locale
|
// « 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.
|
// n'a que le pool en mémoire) → on force le solveur VRP.
|
||||||
if (suggestDlg.includeAssigned) suggestDlg.strategy = 'optimize'
|
if (suggestDlg.includeAssigned) suggestDlg.strategy = 'optimize'
|
||||||
|
|
@ -4934,14 +4939,26 @@ async function optimizeSuggestionLocal () {
|
||||||
}
|
}
|
||||||
// D v2 — ordonne une tournée par PLUS-PROCHE-VOISIN depuis le domicile du tech + distance totale (haversine, sans appel réseau).
|
// D v2 — ordonne une tournée par PLUS-PROCHE-VOISIN depuis le domicile du tech + distance totale (haversine, sans appel réseau).
|
||||||
function nnOrder (entries, home) {
|
function nnOrder (entries, home) {
|
||||||
const pts = entries.filter(e => e.lat != null && e.lon != null)
|
// ORDRE = fenêtre horaire D'ABORD (AM 8-12 → sans-fenêtre → PM 12-16), plus-proche-voisin DANS chaque bloc,
|
||||||
const noLL = entries.filter(e => e.lat == null || e.lon == null)
|
// en chaînant la position d'un bloc au suivant. Respecte « faire en AM / PM » (toggle explicite OU détecté du texte
|
||||||
if (pts.length <= 1) return { ordered: [...pts, ...noLL], km: 0 }
|
// ex. « **Passer en premier en AM** », « PM Fin de journée »). AVANT : NN pur → une job PM pouvait finir en 1er.
|
||||||
const rem = [...pts]; const out = []
|
const ampmKey = e => { const a = jobTimeOf(e.jobName).ampm || detectAmpm(e); return a === 'am' ? 'am' : a === 'pm' ? 'pm' : 'none' }
|
||||||
let cur = (home && isFinite(+home.lat) && isFinite(+home.lon)) ? { lat: +home.lat, lon: +home.lon } : rem[0]
|
const blocks = { am: [], none: [], pm: [] }
|
||||||
let km = 0
|
for (const e of entries) blocks[ampmKey(e)].push(e)
|
||||||
while (rem.length) { let bi = 0, bd = Infinity; for (let i = 0; i < rem.length; i++) { const d = haversineKm(cur.lat, cur.lon, rem[i].lat, rem[i].lon); if (d < bd) { bd = d; bi = i } } if (isFinite(bd)) km += bd; cur = rem[bi]; out.push(rem.splice(bi, 1)[0]) }
|
const out = []; let km = 0
|
||||||
return { ordered: [...out, ...noLL], km: Math.round(km * 10) / 10 }
|
let cur = (home && isFinite(+home.lat) && isFinite(+home.lon)) ? { lat: +home.lat, lon: +home.lon } : null
|
||||||
|
for (const key of ['am', 'none', 'pm']) {
|
||||||
|
const rem = blocks[key].filter(e => e.lat != null && e.lon != null)
|
||||||
|
const noLL = blocks[key].filter(e => e.lat == null || e.lon == null)
|
||||||
|
while (rem.length) {
|
||||||
|
let bi = 0, bd = Infinity
|
||||||
|
if (cur) for (let i = 0; i < rem.length; i++) { const d = haversineKm(cur.lat, cur.lon, rem[i].lat, rem[i].lon); if (d < bd) { bd = d; bi = i } }
|
||||||
|
if (cur && isFinite(bd)) km += bd
|
||||||
|
cur = rem[bi]; out.push(rem.splice(bi, 1)[0])
|
||||||
|
}
|
||||||
|
out.push(...noLL)
|
||||||
|
}
|
||||||
|
return { ordered: out, km: Math.round(km * 10) / 10 }
|
||||||
}
|
}
|
||||||
const estTravelMin = km => Math.round(km / 45 * 60) // ~45 km/h moyen (route + arrêts) → estimation « ≈ »
|
const estTravelMin = km => Math.round(km / 45 * 60) // ~45 km/h moyen (route + arrêts) → estimation « ≈ »
|
||||||
// Regroupé par tech puis par jour, chaque jour ordonné en tournée (D v2) + km/temps de route estimés.
|
// Regroupé par tech puis par jour, chaque jour ordonné en tournée (D v2) + km/temps de route estimés.
|
||||||
|
|
@ -5163,9 +5180,17 @@ const covers = (t, skills) => (skills || []).filter(Boolean).every(s => (t.skill
|
||||||
// Existe-t-il AU MOINS un tech capable ? Sinon (données de compétences incomplètes) on ne bloque personne (évite de piéger une file inassignable).
|
// Existe-t-il AU MOINS un tech capable ? Sinon (données de compétences incomplètes) on ne bloque personne (évite de piéger une file inassignable).
|
||||||
const anyCovers = (skills) => !((skills || []).filter(Boolean).length) || (visibleTechs.value || []).some(t => covers(t, skills))
|
const anyCovers = (skills) => !((skills || []).filter(Boolean).length) || (visibleTechs.value || []).some(t => covers(t, skills))
|
||||||
// Liste de techs triée CAPABLES d'abord (couvrent la/les compétence(s)), puis alphabétique — pour les menus d'assignation.
|
// Liste de techs triée CAPABLES d'abord (couvrent la/les compétence(s)), puis alphabétique — pour les menus d'assignation.
|
||||||
function capableTechsFirst (skills) { return [...(visibleTechs.value || [])].sort((a, b) => (covers(b, skills) - covers(a, skills)) || String(a.name || '').localeCompare(String(b.name || ''))) }
|
// Par défaut, le picker « déplacer vers » ne montre QUE les techs SÉLECTIONNÉS pour cette simulation ET compétents ;
|
||||||
|
// « Afficher tous » (showAllMoveTechs) lève le filtre. Repli sur la liste complète si aucun sélectionné-compétent (jamais vide).
|
||||||
|
const showAllMoveTechs = ref(false)
|
||||||
|
function moveTechFilter (list, skills) {
|
||||||
|
if (showAllMoveTechs.value) return list
|
||||||
|
const sel = list.filter(t => suggestDlg.techSel[t.id] && covers(t, skills))
|
||||||
|
return sel.length ? sel : list
|
||||||
|
}
|
||||||
|
function capableTechsFirst (skills) { return moveTechFilter([...(visibleTechs.value || [])].sort((a, b) => (covers(b, skills) - covers(a, skills)) || String(a.name || '').localeCompare(String(b.name || ''))), skills) }
|
||||||
// Une entrée de la revue → objet job-like pour réutiliser techsForJob (classement proximité + occupation).
|
// Une entrée de la revue → objet job-like pour réutiliser techsForJob (classement proximité + occupation).
|
||||||
function techsForEntry (e) { return techsForJob({ required_skill: e.skill, lat: e.lat, lon: e.lon, scheduled_date: e.iso }) }
|
function techsForEntry (e) { return moveTechFilter(techsForJob({ required_skill: e.skill, lat: e.lat, lon: e.lon, scheduled_date: e.iso }), e.skill ? [e.skill] : []) }
|
||||||
const techById = computed(() => { const m = {}; for (const t of (visibleTechs.value || [])) m[t.id] = t; return m })
|
const techById = computed(() => { const m = {}; for (const t of (visibleTechs.value || [])) m[t.id] = t; return m })
|
||||||
// Réassigne UNE entrée : quand la cible est un VRAI tech, on sort du placeholder + on (re)calcule quart manquant ET compétence.
|
// Réassigne UNE entrée : quand la cible est un VRAI tech, on sort du placeholder + on (re)calcule quart manquant ET compétence.
|
||||||
function reassignEntry (e, techId, techName) {
|
function reassignEntry (e, techId, techName) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user