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:
louispaulb 2026-07-07 11:14:07 -04:00
parent 746a8de6df
commit 3a0b65942c

View File

@ -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) {