gigafibre-fsm/src/composables/useAutoDispatch.js
louispaulb 859f043bb2 Refactor: extract autoDispatch, serializeAssistants, store assign logic
- 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>
2026-03-24 17:25:33 -04:00

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 }
}