- Extract useAutoDispatch.js (autoDistribute + optimizeRoute) - Add serializeAssistants() to useHelpers — removes 6 duplications - Move smartAssign/fullUnassign into Pinia store - Add drag-and-drop on dispatch criteria modal - DispatchV2Page.vue: 1463 → 1385 lines Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
137 lines
5.9 KiB
JavaScript
137 lines
5.9 KiB
JavaScript
// ── 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 }
|
|
}
|