// ── Find optimal technician for emergency mid-day job insertion ─────────────── // Scores each tech based on: GPS proximity, current load, skills match, queue fit // Uses real-time GPS when available, falls back to last job coords or home base // ───────────────────────────────────────────────────────────────────────────── import { MAPBOX_TOKEN } from 'src/config/erpnext' // Euclidean distance approximation in km (Montreal latitude) function distKm (a, b) { if (!a || !b || (!a[0] && !a[1]) || (!b[0] && !b[1])) return 999 const dx = (a[0] - b[0]) * 80 // 1° lng ≈ 80 km at 45°N const dy = (a[1] - b[1]) * 111 // 1° lat ≈ 111 km return Math.sqrt(dx * dx + dy * dy) } // Get tech's effective current position: GPS > last queued job > home function techCurrentPos (tech, dateStr) { if (tech.gpsCoords && tech.gpsOnline) return { coords: tech.gpsCoords, source: 'gps' } const todayJobs = tech.queue.filter(j => j.scheduledDate === dateStr) // Find last completed or in-progress job const done = todayJobs.filter(j => j.status === 'completed' || j.status === 'en-route') if (done.length) { const last = done[done.length - 1] if (last.coords && (last.coords[0] || last.coords[1])) return { coords: last.coords, source: 'lastJob' } } // Or next job in queue if (todayJobs.length) { const next = todayJobs.find(j => j.coords && (j.coords[0] || j.coords[1])) if (next) return { coords: next.coords, source: 'nextJob' } } return { coords: tech.coords, source: 'home' } } // Compute load: total hours assigned today function techDayLoad (tech, dateStr) { return tech.queue .filter(j => j.scheduledDate === dateStr) .reduce((sum, j) => sum + (parseFloat(j.duration) || 1), 0) } // Best insertion point in tech's queue (minimizes detour) function bestInsertionIdx (tech, jobCoords, dateStr) { const dayJobs = tech.queue.filter(j => j.scheduledDate === dateStr) if (!dayJobs.length) return 0 if (!jobCoords || (!jobCoords[0] && !jobCoords[1])) return dayJobs.length // Try each insertion point, pick the one with least total detour let bestIdx = dayJobs.length, bestDetour = Infinity for (let i = 0; i <= dayJobs.length; i++) { const prev = i === 0 ? (tech.gpsCoords || tech.coords) : dayJobs[i - 1].coords const next = i < dayJobs.length ? dayJobs[i].coords : null const directDist = next ? distKm(prev, next) : 0 const detour = distKm(prev, jobCoords) + (next ? distKm(jobCoords, next) : 0) - directDist if (detour < bestDetour) { bestDetour = detour; bestIdx = i } } return bestIdx } /** * Score and rank all technicians for an emergency job. * * @param {Object} params * @param {Array} params.technicians - All available techs * @param {Array} params.jobCoords - [lng, lat] of the emergency job * @param {number} params.jobDuration - Hours needed * @param {Array} params.jobTags - Required skill tags * @param {string} params.dateStr - YYYY-MM-DD (today) * @returns {Array} Ranked techs: [{ tech, score, distance, load, insertIdx, reasons }] */ export function rankTechs ({ technicians, jobCoords, jobDuration = 1, jobTags = [], dateStr }) { const hasCoords = jobCoords && (jobCoords[0] || jobCoords[1]) const candidates = technicians.filter(t => t.status !== 'off' && t.status !== 'unavailable') const scored = candidates.map(tech => { const pos = techCurrentPos(tech, dateStr) const distance = hasCoords ? distKm(pos.coords, jobCoords) : 999 const load = techDayLoad(tech, dateStr) const remainingCap = Math.max(0, 8 - load) const insertIdx = hasCoords ? bestInsertionIdx(tech, jobCoords, dateStr) : tech.queue.filter(j => j.scheduledDate === dateStr).length // ── Scoring (lower = better) ── let score = 0 const reasons = [] // 1. Proximity (weight: 40%) — distance in km, normalized to ~0-100 const proxScore = Math.min(distance, 100) score += proxScore * 4 if (distance < 5) reasons.push(`📍 ${distance.toFixed(1)} km (très proche)`) else if (distance < 15) reasons.push(`📍 ${distance.toFixed(1)} km`) else reasons.push(`📍 ${distance.toFixed(1)} km (loin)`) // 2. Load balance (weight: 30%) — prefer techs with capacity const loadScore = load * 10 // 0-80 range score += loadScore * 3 if (remainingCap < jobDuration) { score += 500 // Heavy penalty: can't fit the job reasons.push(`⚠ Surchargé (${load.toFixed(1)}h/${8}h)`) } else if (load < 4) { reasons.push(`✓ Dispo (${load.toFixed(1)}h/${8}h)`) } else { reasons.push(`◐ Chargé (${load.toFixed(1)}h/${8}h)`) } // 3. Skills match (weight: 20%) if (jobTags.length) { const techTags = tech.tags || [] const missing = jobTags.filter(t => !techTags.includes(t)) score += missing.length * 200 if (missing.length) reasons.push(`⚠ Manque: ${missing.join(', ')}`) else reasons.push('✓ Skills OK') } // 4. GPS freshness bonus (weight: 10%) — trust live GPS more if (pos.source === 'gps') { score -= 20 // Bonus for having live GPS reasons.push('🛰 GPS en direct') } else { reasons.push(`📌 Position: ${pos.source === 'home' ? 'domicile' : 'estimée'}`) } return { tech, score, distance, load, remainingCap, insertIdx, posSource: pos.source, reasons } }) return scored.sort((a, b) => a.score - b.score) } /** * Get real driving times from Mapbox for top N candidates (optional refinement). * Updates the distance/score with actual driving duration. */ export async function refineWithDrivingTimes (ranked, jobCoords, topN = 3) { if (!jobCoords || (!jobCoords[0] && !jobCoords[1])) return ranked const top = ranked.slice(0, topN) const rest = ranked.slice(topN) const refined = await Promise.all(top.map(async (r) => { const techPos = r.tech.gpsCoords || r.tech.coords if (!techPos || (!techPos[0] && !techPos[1])) return r try { const url = `https://api.mapbox.com/directions/v5/mapbox/driving-traffic/${techPos[0]},${techPos[1]};${jobCoords[0]},${jobCoords[1]}?overview=false&access_token=${MAPBOX_TOKEN}` const res = await fetch(url) const data = await res.json() if (data.routes?.[0]) { const mins = Math.round(data.routes[0].duration / 60) const km = (data.routes[0].distance / 1000).toFixed(1) r.drivingMins = mins r.drivingKm = km // Replace proximity score with actual driving time r.score = mins * 4 + r.load * 30 + (r.reasons.some(r => r.includes('Manque')) ? 200 : 0) r.reasons[0] = `🚗 ${mins} min (${km} km)` } } catch { /* keep euclidean estimate */ } return r })) return [...refined.sort((a, b) => a.score - b.score), ...rest] }