fix(dispatch): créneaux/occupation TIENNENT COMPTE DU TRAJET entre jobs

Le trajet entre jobs était compté comme du temps LIBRE → un tech dont la
journée est pleine de route entre villes (ex. Saint-Anicet ↔ Huntingdon)
paraissait « avoir de la place ». Corrigé aux 2 endroits :

- techOccupancy (bandes « Trouver un créneau » + resource_availability de
  l'assistant) : free_h = quart − occupé − TRAJET. Le trajet est estimé le
  long de la tournée (jobs pointés dans l'ordre horaire puis jobs sans heure
  à la suite — sinon une journée de jobs legacy sans heure paraît sans route),
  borné au temps restant (jamais négatif). Nouveaux champs travel_h (jour+total).
- suggestSlots : réserve le trajet RÉEL — arriver depuis le job précédent ET
  repartir vers le job suivant (avant : buffer fixe 15 min à l'avant seulement,
  le travelMin calculé n'était qu'affiché). Bord = début/fin de quart → 0 ;
  bord = job → trajet estimé (coords) ou repli 15 min si coords du nouveau job
  inconnues. Un créneau n'est offert que si le trou contient arrivée+job+départ.

Helper travelH() partagé (barème existant ≈ dist×1,5 min/km, borné 5–90).
Vérifié live : Houssam Nabout lun 20/07 (5 jobs 2 villes) passe de « 1 h libre /
90% » à « 0 h libre / 100% plein » ; techs à 1 job ou même lieu → trajet 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-20 07:38:30 -04:00
parent 473b360c4c
commit 0542e525ea

View File

@ -134,10 +134,19 @@ function rankTechs (techs, jobCoords, jobDuration = 1) {
// window, minus a travel buffer before the new job, minus a "not in the
// past" cutoff. Scores surface the most natural inserts (earliest first,
// then shortest travel), with a 2-slot-per-tech cap to diversify results.
const SLOT_TRAVEL_BUFFER_H = 0.25 // 15 min pre-job slack before proposed start
const SLOT_TRAVEL_BUFFER_H = 0.25 // repli de trajet (h) quand une coordonnée manque = 15 min
const SLOT_HORIZON_DAYS = 7
const SLOT_MAX_PER_TECH = 2
// Trajet estimé (en HEURES) entre 2 points [lng,lat] — même barème que les créneaux (≈ distance × 1,5 min/km, borné 590 min).
// distKm renvoie 999 si une coordonnée manque → on retombe sur SLOT_TRAVEL_BUFFER_H. Le trajet ENTRE deux jobs
// N'EST PAS du temps libre : il doit être réservé, sinon on suroffre un tech dont la journée est déjà pleine de route.
function travelH (a, b) {
const km = distKm(a, b)
if (!isFinite(km) || km >= 999) return SLOT_TRAVEL_BUFFER_H
return Math.max(5, Math.min(90, Math.round(km * 1.5))) / 60
}
async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date, limit = 5, skill = '', ignoreReserved = false } = {}) {
const baseDate = after_date || todayET()
const duration = parseFloat(duration_h) || 1
@ -242,32 +251,38 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
let cursor = shift.start_h, prevCoords = homeCoords
for (const j of dayJobs) {
gaps.push({
start_h: cursor, end_h: j.start_h, prev_coords: prevCoords,
start_h: cursor, end_h: j.start_h, prev_coords: prevCoords, next_coords: j.coords,
position: gaps.length === 0 ? 'first' : 'between',
})
cursor = j.end_h
prevCoords = j.coords
}
gaps.push({
start_h: cursor, end_h: shift.end_h, prev_coords: prevCoords,
start_h: cursor, end_h: shift.end_h, prev_coords: prevCoords, next_coords: null,
position: dayJobs.length === 0 ? 'free_day' : 'last',
})
for (const g of gaps) {
const gapLen = g.end_h - g.start_h
if (gapLen < duration + SLOT_TRAVEL_BUFFER_H) continue
// TRAJET réel réservé : ARRIVER (depuis le job précédent) + REPARTIR (vers le job suivant).
// Bord = début/fin de QUART → aucun trajet de ce côté ; bord = JOB → réserver le déplacement.
// Sans coordonnées du nouveau job (jobCoords absent) → repli forfaitaire SLOT_TRAVEL_BUFFER_H par bord-job.
const bordersPrevJob = g.position === 'between' || g.position === 'last'
const bordersNextJob = g.position === 'between' || g.position === 'first'
const travelIn = bordersPrevJob ? (jobCoords ? travelH(g.prev_coords, jobCoords) : SLOT_TRAVEL_BUFFER_H) : 0
const travelOut = bordersNextJob ? (jobCoords ? travelH(jobCoords, g.next_coords) : SLOT_TRAVEL_BUFFER_H) : 0
if (gapLen < travelIn + duration + travelOut) continue // le trou doit contenir : arriver + faire le job + repartir
const startH = g.start_h + SLOT_TRAVEL_BUFFER_H
const startH = g.start_h + travelIn
const endH = startH + duration
if (endH > g.end_h) continue
if (endH + travelOut > g.end_h + 1e-6) continue // laisser le temps de rejoindre le job suivant
// Skip slots already in the past (or within 30 min).
if (dateStr === today && startH < nowH + 0.5) continue
const distanceKm = jobCoords && g.prev_coords ? distKm(g.prev_coords, jobCoords) : null
const travelMin = distanceKm != null
? Math.max(5, Math.min(90, Math.round(distanceKm * 1.5)))
: Math.round(SLOT_TRAVEL_BUFFER_H * 60)
// Minutes de trajet AFFICHÉES = déplacement depuis le lieu précédent (domicile au 1er créneau, sinon job précédent).
const travelMin = Math.round((jobCoords && g.prev_coords ? travelH(g.prev_coords, jobCoords) : SLOT_TRAVEL_BUFFER_H) * 60)
const reasons = []
if (g.position === 'free_day') reasons.push('Journée libre')
@ -333,7 +348,7 @@ async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = ''
['scheduled_date', '>=', dates[0]],
['scheduled_date', '<=', dates[dates.length - 1]],
]))}&fields=${encodeURIComponent(JSON.stringify([
'name', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'job_type',
'name', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'job_type', 'longitude', 'latitude',
]))}&limit_page_length=500`),
erpFetch(`/api/resource/Shift Template?fields=${encodeURIComponent(JSON.stringify(['name', 'start_time', 'end_time', 'on_call']))}&limit_page_length=100`),
erpFetch(`/api/resource/Shift Assignment?filters=${encodeURIComponent(JSON.stringify([['assignment_date', 'in', dates]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'assignment_date', 'shift_template']))}&limit_page_length=2000`),
@ -372,13 +387,21 @@ async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = ''
// Match par technician_id OU docname (comme les quarts/congés) : les jobs legacy portent parfois l'un ou l'autre.
const dayJobsRaw = allJobs
.filter(j => (j.assigned_tech === tech.technician_id || j.assigned_tech === tech.name) && j.scheduled_date === dateStr)
.map(j => { const s = j.start_time ? timeToHours(j.start_time) : null; const dur = parseFloat(j.duration_h) || 1; return { start_h: s, dur, end_h: s != null ? s + dur : null, reserved: j.job_type === 'Réservation' } })
.map(j => { const s = j.start_time ? timeToHours(j.start_time) : null; const dur = parseFloat(j.duration_h) || 1; return { start_h: s, dur, end_h: s != null ? s + dur : null, reserved: j.job_type === 'Réservation', coords: (j.latitude && j.longitude) ? [parseFloat(j.longitude), parseFloat(j.latitude)] : null } })
const timed = dayJobsRaw.filter(j => j.start_h != null).sort((a, b) => a.start_h - b.start_h)
const untimed = dayJobsRaw.filter(j => j.start_h == null) // jobs assignés SANS heure fixée (legacy osTicket) — occupent quand même la journée
const clamp = (j) => Math.max(0, Math.min(j.end_h, shift.end_h) - Math.max(j.start_h, shift.start_h))
const timedBusy = timed.reduce((acc, j) => acc + clamp(j), 0)
const untimedBusy = untimed.reduce((acc, j) => acc + j.dur, 0)
const busyH = timedBusy + untimedBusy // ⇐ correctif : les jobs sans heure comptent (avant : ignorés ⇒ « 40 h libre » alors que plein)
// DÉPLACEMENT le long de la tournée = temps INDISPONIBLE (le trajet n'est PAS du temps libre) → retranché du « libre ».
// Corrige « le tech a de la place » alors que ses trous sont mangés par la route entre 2 villes (ex. Saint-Anicet ↔ Huntingdon).
// Ordre estimé : jobs pointés dans l'ordre horaire, puis jobs SANS heure fixée à la suite — sinon une journée de
// jobs legacy « sans heure » éparpillés paraît sans trajet, donc « libre » à tort (cas Houssam : 5 jobs, 0 pointé).
const routeOrder = [...timed, ...untimed].filter(j => j.coords)
let travelBusy = 0
for (let i = 1; i < routeOrder.length; i++) travelBusy += travelH(routeOrder[i - 1].coords, routeOrder[i].coords)
travelBusy = Math.min(travelBusy, Math.max(0, shiftH - busyH)) // borné au temps restant (jamais de free_h négatif)
const blocks = timed.map(j => ({
start: hoursToTime(Math.max(j.start_h, shift.start_h)), end: hoursToTime(Math.min(j.end_h, shift.end_h)),
top: shiftH > 0 ? +Math.max(0, (Math.max(j.start_h, shift.start_h) - shift.start_h) / shiftH).toFixed(3) : 0,
@ -398,17 +421,18 @@ async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = ''
}
return {
date: dateStr, dow, off: false, shift_start: hoursToTime(shift.start_h), shift_end: hoursToTime(shift.end_h),
shift_h: +shiftH.toFixed(1), busy_h: +busyH.toFixed(1), free_h: +Math.max(0, shiftH - busyH).toFixed(1),
shift_h: +shiftH.toFixed(1), busy_h: +busyH.toFixed(1), travel_h: +travelBusy.toFixed(1), free_h: +Math.max(0, shiftH - busyH - travelBusy).toFixed(1),
jobs: dayJobsRaw.length, untimed_jobs: untimed.length,
occupancy: shiftH > 0 ? Math.min(1, +(busyH / shiftH).toFixed(2)) : 0, blocks,
occupancy: shiftH > 0 ? Math.min(1, +((busyH + travelBusy) / shiftH).toFixed(2)) : 0, blocks,
}
})
const totShift = cells.reduce((a, c) => a + (c.shift_h || 0), 0)
const totBusy = cells.reduce((a, c) => a + (c.busy_h || 0), 0)
const totTravel = cells.reduce((a, c) => a + (c.travel_h || 0), 0)
return {
tech_id: tech.technician_id, tech_name: tech.full_name,
shift_h: +totShift.toFixed(1), busy_h: +totBusy.toFixed(1), free_h: +Math.max(0, totShift - totBusy).toFixed(1),
occupancy: totShift > 0 ? Math.min(1, +(totBusy / totShift).toFixed(2)) : null, days: cells,
shift_h: +totShift.toFixed(1), busy_h: +totBusy.toFixed(1), travel_h: +totTravel.toFixed(1), free_h: +Math.max(0, totShift - totBusy - totTravel).toFixed(1),
occupancy: totShift > 0 ? Math.min(1, +((totBusy + totTravel) / totShift).toFixed(2)) : null, days: cells,
}
})
// Le moins occupé d'abord = meilleurs candidats ; les techs sans aucun quart sur l'horizon en dernier.