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:
parent
473b360c4c
commit
0542e525ea
|
|
@ -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é 5–90 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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user