gigafibre-fsm/apps/ops/src/composables/useBestTech.js
louispaulb 320655b0a0 refactor: major cleanup — remove dead dispatch app, commit all backend code, extract client composables
- Remove apps/dispatch/ (100% replaced by ops dispatch module, unmaintained)
- Commit services/targo-hub/lib/ (24 modules, 6290 lines — was never tracked)
- Commit services/docuseal + services/legacy-db docker-compose configs
- Extract client app composables: useOTP, useAddressSearch, catalog data, format utils
- Refactor CartPage.vue 630→175 lines, CatalogPage.vue 375→95 lines
- Clean hardcoded credentials from config.js fallback values
- Add client portal: catalog, cart, checkout, OTP verification, address search
- Add ops: NetworkPage, AgentFlowsPage, ConversationPanel, UnifiedCreateModal
- Add ops composables: useBestTech, useConversations, usePermissions, useScanner
- Add field app: scanner composable, docker/nginx configs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 17:38:38 -04:00

158 lines
6.8 KiB
JavaScript

// ── 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]
}