- 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>
158 lines
6.8 KiB
JavaScript
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]
|
|
}
|