feat(planif): « Suggérer » ne répartit que les jobs des jours cochés

Le dispatch auto utilisait tout le pool avec son propre sélecteur de jour,
en ignorant les chips de date du pool (et même la sélection lasso, sur le
chemin solveur hub). Désormais les chips pilotent le dispatch :

- FENÊTRE = les chips de date cochés ( en retard / Sans date → placés
  aujourd'hui) ; aucun chip → repli sur le sélecteur Aujourd'hui/Demain/date.
- JEU DE JOBS restreint aux jours cochés (ou à la sélection lasso) sur les
  3 chemins : heuristique locale, solveur local, ET solveur hub (nouveau
  param `job_names` dans optimize-plan, additif/rétrocompatible).
- UI : bouton « Suggérer (N) » + dialogue « N job(s) des jours cochés → jours ».
- Helper partagé jobMatchesDateChips (dé-dup avec le filtre d'affichage).

Vérifié en preview : chip 07-06 → 19 arrêts tous le 07-06 (local) ;
chips 07-06+07-07 → 34 arrêts, uniquement ces 2 jours (solveur hub déployé).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-03 11:58:38 -04:00
parent c209098a44
commit 6c5ed0060f
2 changed files with 49 additions and 15 deletions

View File

@ -880,7 +880,7 @@
<div class="assign-hdr" @mousedown="panelHeaderDown">
<q-icon name="drag_indicator" size="18px" /><span>Jobs à assigner ({{ assignTypeFilter.length ? assignJobsFiltered.length + '/' + assignPanel.jobs.length : assignPanel.jobs.length }})</span><q-space />
<q-btn v-if="assignNoCoord" flat dense no-caps size="sm" color="warning" icon="wrong_location" :label="String(assignNoCoord)" :loading="assignPanel.locating" class="q-mr-xs" @click="batchLocatePool"><q-tooltip>Localiser les {{ assignNoCoord }} job(s) sans coordonnées : adresse base RQA + GPS (ils apparaissent ensuite sur la carte)</q-tooltip></q-btn>
<q-btn flat dense no-caps size="sm" color="amber-4" icon="auto_awesome" :label="selectedNames.length ? 'Suggérer (' + selectedNames.length + ')' : 'Suggérer'" :loading="suggestDlg.building" class="q-mr-xs" @click="openSuggest"><q-tooltip>Proposer une répartition optimisée (proximité · charge · compétence){{ selectedNames.length ? ' pour la sélection' : '' }} tu revois et ajustes avant d'appliquer</q-tooltip></q-btn>
<q-btn flat dense no-caps size="sm" color="amber-4" icon="auto_awesome" :label="(selectedNames.length || assignDateFilter.length) ? 'Suggérer (' + suggestJobCount + ')' : 'Suggérer'" :loading="suggestDlg.building" class="q-mr-xs" @click="openSuggest"><q-tooltip>Proposer une répartition optimisée (proximité · charge · compétence){{ selectedNames.length ? ' pour la sélection lasso' : (assignDateFilter.length ? ' pour les jours cochés' : '') }} tu revois et ajustes avant d'appliquer</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="map" :color="assignPanel.showMap ? 'amber-4' : 'white'" @click="toggleAssignMap"><q-tooltip>Carte des jobs (regrouper par région)</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="refresh" color="white" :loading="assignPanel.loading" @click="openAssignPanel" />
<q-btn flat dense round size="sm" icon="close" color="white" @click="assignPanel.open = false" />
@ -1020,13 +1020,21 @@
<q-input v-model.number="solverOpts.speedKmh" type="number" dense outlined style="width:86px" label="km/h" :min="20" :max="90"><q-tooltip>Vitesse de repli (nœuds sans coordonnées)</q-tooltip></q-input>
<q-input v-model.number="solverOpts.maxSeconds" type="number" dense outlined style="width:92px" label="Calcul s" :min="2" :max="30"><q-tooltip>Budget de calcul par jour</q-tooltip></q-input>
</div>
<!-- JOUR CIBLÉ (une seule journée) : les jobs en retard / sans date sont tirés sur ce jour. Re-simuler aujourd'hui = OK (non destructif). -->
<!-- FENÊTRE de répartition = les CHIPS de date cochés dans le pool (ou la sélection lasso). Sinon un jour choisi ici. -->
<div class="row items-center q-mb-sm" style="gap:6px;color:#475569;background:#eef2ff;border-radius:6px;padding:5px 8px">
<q-icon name="event" size="16px" color="primary" /><span class="text-caption text-weight-medium">Répartir pour :</span>
<q-btn dense unelevated size="sm" no-caps :color="suggestDay === todayISO() ? 'primary' : 'grey-4'" :text-color="suggestDay === todayISO() ? 'white' : 'grey-8'" label="Aujourd'hui" @click="suggestDay = todayISO()" />
<q-btn dense unelevated size="sm" no-caps :color="suggestDay === tomorrowISO() ? 'primary' : 'grey-4'" :text-color="suggestDay === tomorrowISO() ? 'white' : 'grey-8'" label="Demain" @click="suggestDay = tomorrowISO()" />
<q-input dense outlined type="date" v-model="suggestDay" style="width:150px" />
<q-space /><span class="text-caption text-grey-6">jobs en retard/sans date tirés sur ce jour</span>
<q-icon name="event" size="16px" color="primary" /><span class="text-caption text-weight-medium">Répartir :</span>
<template v-if="selectedNames.length || assignDateFilter.length">
<span class="text-caption text-weight-medium text-indigo-8">{{ suggestJobCount }} job(s) {{ selectedNames.length ? 'sélectionnés' : 'des jours cochés' }}</span>
<q-icon name="arrow_forward" size="13px" color="grey-6" /><span class="text-caption text-grey-7">{{ suggestWindow.map(windowDayLabel).join(', ') }}</span>
<q-space /><span class="text-caption text-grey-6">{{ selectedNames.length ? 'sélection lasso' : 'ajuste les chips sous le pool' }}</span>
</template>
<template v-else>
<span class="text-caption">pour :</span>
<q-btn dense unelevated size="sm" no-caps :color="suggestDay === todayISO() ? 'primary' : 'grey-4'" :text-color="suggestDay === todayISO() ? 'white' : 'grey-8'" label="Aujourd'hui" @click="suggestDay = todayISO()" />
<q-btn dense unelevated size="sm" no-caps :color="suggestDay === tomorrowISO() ? 'primary' : 'grey-4'" :text-color="suggestDay === tomorrowISO() ? 'white' : 'grey-8'" label="Demain" @click="suggestDay = tomorrowISO()" />
<q-input dense outlined type="date" v-model="suggestDay" style="width:150px" />
<q-space /><span class="text-caption text-grey-6">jobs en retard/sans date tirés sur ce jour</span>
</template>
</div>
<div class="row items-center q-mb-xs" style="gap:4px">
<span class="text-caption text-grey-7 q-mr-xs">Rapide :</span>
@ -2564,10 +2572,18 @@ const assignDates = computed(() => {
function toggleAssignDate (iso) { const s = new Set(assignDateFilter.value); s.has(iso) ? s.delete(iso) : s.add(iso); assignDateFilter.value = [...s] }
// Depuis l'aperçu « jobs sur la colonne du jour » ouvre le panneau « À assigner » filtré sur ce jour.
async function openDayInPanel (iso) { await openAssignPanel(); assignSort.value = 'date'; assignDateFilter.value = [iso] }
// Un job correspond-il aux CHIPS de date sélectionnées ? (vide = toutes). '__overdue__' = toutes dates passées.
// Partagé par le filtre d'affichage ET le dispatch auto (« Suggérer n'utilise que les jobs des jours cochés »).
function jobMatchesDateChips (j) {
const df = assignDateFilter.value; if (!df.length) return true
const ds = new Set(df); const t = todayISO()
const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'
return ds.has(d) || (ds.has('__overdue__') && d !== 'Sans date' && d < t)
}
const assignJobsFiltered = computed(() => {
let arr = assignPanel.jobs
const f = assignTypeFilter.value; if (f.length) { const set = new Set(f); arr = arr.filter(j => set.has(j.required_skill || 'autre')) }
const df = assignDateFilter.value; if (df.length) { const ds = new Set(df); const t = todayISO(); arr = arr.filter(j => { const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'; return ds.has(d) || (ds.has('__overdue__') && d !== 'Sans date' && d < t) }) } // '__overdue__' = toutes dates passées
if (assignDateFilter.value.length) arr = arr.filter(jobMatchesDateChips)
return arr
})
function toggleAssignType (k) { const i = assignTypeFilter.value.indexOf(k); if (i >= 0) assignTypeFilter.value.splice(i, 1); else assignTypeFilter.value.push(k) }
@ -4233,8 +4249,7 @@ function jobDur (j) { return (j && (j.est_min ? j.est_min / 60 : Number(j.durati
function buildSuggestion () {
const useSel = selectedNames.value.length > 0
suggestDlg.fromSel = useSel
let jobs = (assignPanel.jobs || []).filter(j => j.status !== 'On Hold')
if (useSel) jobs = jobs.filter(j => selectedJobs[j.name])
const jobs = suggestJobs() // lasso si actif, sinon pool filtré par les chips de date (jours cochés)
const days = (dayList.value || []); const dayIsos = days.map(d => d.iso)
const techs = (visibleTechs.value || []).filter(t => suggestDlg.techSel[t.id]) // seulement les techs choisis (disponibles ce jour)
if (!jobs.length || !dayIsos.length || !techs.length) { suggestDlg.plan = []; return }
@ -4347,19 +4362,35 @@ function buildSuggestion () {
suggestDlg.plan = plan
}
function openSuggest () {
suggestDay.value = mobileSelIso.value || kbSelIso.value || todayISO() // jour ciblé = celui sélectionné au tableau (défaut auj.)
suggestDay.value = mobileSelIso.value || kbSelIso.value || todayISO() // repli si aucun chip coché
suggestDlg.fromSel = selectedNames.value.length > 0
// Étape « disponibilité » d'abord : défaut = techs AVEC un quart CE JOUR (= disponibles) ; sinon tous les visibles.
// Étape « disponibilité » d'abord : défaut = techs AVEC un quart sur un des jours de la fenêtre (= disponibles) ; sinon tous les visibles.
const win = suggestWindow.value // fenêtre = chips de date cochés, sinon le jour du sélecteur
const techs = visibleTechs.value || []
const withShift = techs.filter(t => hasShiftDay(t.id, suggestDay.value))
const withShift = techs.filter(t => win.some(iso => hasShiftDay(t.id, iso)))
const base = withShift.length ? withShift : techs
const sel = {}; base.forEach(t => { sel[t.id] = true }); suggestDlg.techSel = sel
suggestDlg.plan = []; suggestDlg.mode = 'config'; suggestDlg.open = true
}
const suggestSelCount = computed(() => Object.values(suggestDlg.techSel).filter(Boolean).length)
// Dispatch auto = UNE SEULE journée (celle sélectionnée). Les jobs en retard / sans date sont tirés sur CE jour. Non destructif : on revoit avant d'appliquer (re-simuler aujourd'hui = OK).
// Dispatch auto : la FENÊTRE = les CHIPS de date cochées dans le pool (jours à répartir). Sinon (aucun chip) le jour choisi dans le sélecteur.
// Les chips « en retard » / « Sans date » ne sont pas des jours calendaires les jobs correspondants sont placés AUJOURD'HUI. Non destructif (revue avant d'appliquer).
const suggestDay = ref('')
const suggestWindow = computed(() => [suggestDay.value || todayISO()])
const suggestWindow = computed(() => {
const f = assignDateFilter.value || []
if (!f.length) return [suggestDay.value || todayISO()] // aucun chip coché sélecteur de jour du dialogue
const days = new Set(f.filter(iso => iso !== '__overdue__' && iso !== 'Sans date')) // jours concrets cochés (auj./futur)
if (f.includes('__overdue__') || f.includes('Sans date') || !days.size) days.add(todayISO()) // en retard / sans date placés aujourd'hui
return [...days].sort()
})
// Jobs que « Suggérer » doit répartir : la sélection lasso si active, sinon le pool filtré par les CHIPS de date.
const suggestJobs = () => {
const useSel = selectedNames.value.length > 0
let jobs = (assignPanel.jobs || []).filter(j => j.status !== 'On Hold')
jobs = useSel ? jobs.filter(j => selectedJobs[j.name]) : jobs.filter(jobMatchesDateChips)
return jobs
}
const suggestJobCount = computed(() => suggestJobs().length)
const windowDayLabel = iso => iso === todayISO() ? "Aujourd'hui" : (iso === tomorrowISO() ? 'Demain' : (fmtDueLabel(iso) || iso))
const suggestWindowLabel = computed(() => {
const w = suggestWindow.value; if (!w.length) return '—'
@ -4436,6 +4467,7 @@ async function optimizeSuggestion () {
try {
const r = await roster.optimizePlanHub({
dates: suggestWindow.value, tech_ids: selTechs.map(t => t.id),
job_names: suggestJobs().map(j => j.name), // ne répartir QUE les jobs des jours cochés (ou la sélection lasso)
opts: { rank_weight: solverOpts.rankWeight, speed_kmh: solverOpts.speedKmh, max_seconds: solverOpts.maxSeconds, overtime_coef: solverOpts.overtimeCoef },
dur_overrides: { ...durOverride }, job_windows,
})

View File

@ -165,6 +165,7 @@ async function optimizePlan (body) {
const durOv = body.dur_overrides || {}
const winOv = body.job_windows || {} // {jobName:{tw_start_min,tw_end_min}} (AM/PM)
const wantTech = Array.isArray(body.tech_ids) && body.tech_ids.length ? new Set(body.tech_ids) : null
const wantJobs = Array.isArray(body.job_names) && body.job_names.length ? new Set(body.job_names) : null // ne répartir QUE ces jobs (jours cochés / sélection UI)
const [pool, techsAll, templates, assignments, occ, absences] = await Promise.all([
buildUnassigned(), fetchTechnicians(), fetchTemplates(), fetchAssignments(lo, span), occupancyByTechDay(lo, span), absencesByTechDay(lo, span),
@ -197,6 +198,7 @@ async function optimizePlan (body) {
// 🖥 Les jobs SANS DÉPLACEMENT (remote) sont EXCLUS des tournées : aucun arrêt, aucun épinglage d'adresse — à faire du bureau.
const dset = new Set(dates); const byDay = {}; const skipped = []; const remote = []
for (const j of pool) {
if (wantJobs && !wantJobs.has(j.name)) continue // restreint aux jobs demandés (chips de date / sélection)
const sched = (j.scheduled_date && /^\d{4}-\d{2}-\d{2}$/.test(String(j.scheduled_date))) ? j.scheduled_date : null
if (sched && sched > hi) { skipped.push(j.name); continue }
const iso = (sched && dset.has(sched)) ? sched : dates[0]