diff --git a/services/targo-hub/lib/dispatch.js b/services/targo-hub/lib/dispatch.js index a484d40..3bfe0e1 100644 --- a/services/targo-hub/lib/dispatch.js +++ b/services/targo-hub/lib/dispatch.js @@ -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.