// ── Auto-dispatch composable: autoDistribute + optimizeRoute ───────────────── import { localDateStr } from './useHelpers' import { updateJob } from 'src/api/dispatch' export function useAutoDispatch (deps) { const { store, MAPBOX_TOKEN, filteredResources, periodStart, getJobDate, unscheduledJobs, bottomSelected, dispatchCriteria, pushUndo, invalidateRoutes } = deps async function autoDistribute () { const techs = filteredResources.value if (!techs.length) return const today = localDateStr(new Date()) let pool if (bottomSelected.value.size) { pool = [...bottomSelected.value].map(id => store.jobs.find(j => j.id === id)).filter(Boolean) } else { pool = unscheduledJobs.value.filter(j => !j.scheduledDate || j.scheduledDate === today) } const unassigned = pool.filter(j => j.coords && (j.coords[0] !== 0 || j.coords[1] !== 0)) if (!unassigned.length) return const prevQueues = {} techs.forEach(t => { prevQueues[t.id] = [...t.queue] }) const prevAssignments = unassigned.map(j => ({ jobId: j.id, techId: j.assignedTech, scheduledDate: j.scheduledDate })) function techLoadForDay (tech, dayStr) { return tech.queue.filter(j => getJobDate(j.id) === dayStr).reduce((s, j) => s + (parseFloat(j.duration) || 1), 0) } function dist (a, b) { if (!a || !b) return 999 const dx = (a[0] - b[0]) * 80, dy = (a[1] - b[1]) * 111 return Math.sqrt(dx * dx + dy * dy) } function techLastPosForDay (tech, dayStr) { const dj = tech.queue.filter(j => getJobDate(j.id) === dayStr) if (dj.length) { const last = dj[dj.length - 1]; if (last.coords && last.coords[0] !== 0) return last.coords } return tech.coords } const criteria = dispatchCriteria.value.filter(c => c.enabled) const sorted = [...unassigned].sort((a, b) => { for (const c of criteria) { if (c.id === 'urgency') { const p = { high: 0, medium: 1, low: 2 } const diff = (p[a.priority] ?? 2) - (p[b.priority] ?? 2) if (diff !== 0) return diff } } return 0 }) const useSkills = criteria.some(c => c.id === 'skills') const weights = {} criteria.forEach((c, i) => { weights[c.id] = criteria.length - i }) sorted.forEach(job => { const assignDay = job.scheduledDate || today let bestTech = null, bestScore = Infinity techs.forEach(tech => { let score = 0 if (weights.balance) score += techLoadForDay(tech, assignDay) * (weights.balance || 1) if (weights.proximity) score += dist(techLastPosForDay(tech, assignDay), job.coords) / 60 * (weights.proximity || 1) if (weights.skills && useSkills) { const jt = job.tags || [], tt = tech.tags || [] score += (jt.length > 0 ? (jt.length - jt.filter(t => tt.includes(t)).length) * 2 : 0) * (weights.skills || 1) } if (score < bestScore) { bestScore = score; bestTech = tech } }) if (bestTech) store.smartAssign(job.id, bestTech.id, assignDay) }) pushUndo({ type: 'autoDistribute', assignments: prevAssignments, prevQueues }) bottomSelected.value = new Set() invalidateRoutes() } async function optimizeRoute (tech) { const dayStr = localDateStr(periodStart.value) const dayJobs = tech.queue.filter(j => getJobDate(j.id) === dayStr) if (dayJobs.length < 2) return const jobsWithCoords = dayJobs.filter(j => j.coords && (j.coords[0] !== 0 || j.coords[1] !== 0)) if (jobsWithCoords.length < 2) return const urgent = jobsWithCoords.filter(j => j.priority === 'high') const normal = jobsWithCoords.filter(j => j.priority !== 'high') function nearestNeighbor (start, jobs) { const result = [], remaining = [...jobs] let cur = start while (remaining.length) { let bi = 0, bd = Infinity remaining.forEach((j, i) => { const dx = j.coords[0] - cur[0], dy = j.coords[1] - cur[1], d = dx * dx + dy * dy if (d < bd) { bd = d; bi = i } }) result.push(remaining.splice(bi, 1)[0]) cur = result.at(-1).coords } return result } const home = (tech.coords?.[0] && tech.coords?.[1]) ? tech.coords : jobsWithCoords[0].coords const orderedUrgent = nearestNeighbor(home, urgent) const orderedNormal = nearestNeighbor(orderedUrgent.length ? orderedUrgent.at(-1).coords : home, normal) const reordered = [...orderedUrgent, ...orderedNormal] try { const hasHome = !!(tech.coords?.[0] && tech.coords?.[1]) const coords = [] if (hasHome) coords.push(`${tech.coords[0]},${tech.coords[1]}`) reordered.forEach(j => coords.push(`${j.coords[0]},${j.coords[1]}`)) if (coords.length <= 12) { const url = `https://api.mapbox.com/optimized-trips/v1/mapbox/driving/${coords.join(';')}?overview=false${hasHome ? '&source=first' : ''}&roundtrip=false&destination=any&access_token=${MAPBOX_TOKEN}` const res = await fetch(url) const data = await res.json() if (data.code === 'Ok' && data.waypoints) { const off = hasHome ? 1 : 0, uc = orderedUrgent.length const mu = reordered.slice(0, uc).map((j, i) => ({ job: j, o: data.waypoints[i + off].waypoint_index })).sort((a, b) => a.o - b.o).map(x => x.job) const mn = reordered.slice(uc).map((j, i) => ({ job: j, o: data.waypoints[i + uc + off].waypoint_index })).sort((a, b) => a.o - b.o).map(x => x.job) reordered.length = 0 reordered.push(...mu, ...mn) } } } catch (_) {} pushUndo({ type: 'optimizeRoute', techId: tech.id, prevQueue: [...tech.queue] }) const otherJobs = tech.queue.filter(j => getJobDate(j.id) !== dayStr) tech.queue = [...reordered, ...otherJobs] tech.queue.forEach((j, i) => { j.routeOrder = i updateJob(j.name || j.id, { route_order: i, start_time: '' }).catch(() => {}) }) invalidateRoutes() } return { autoDistribute, optimizeRoute } }