'use strict' const cfg = require('./config') const { log, json, parseBody, erpFetch } = require('./helpers') const SCORE_WEIGHTS = { proximityMultiplier: 4, // distance (km, capped at 100) * this = proximity penalty proximityMax: 100, // cap distance at this km value loadMultiplier: 30, // tech.load * this = load penalty overloadPenalty: 500, // added when tech has insufficient capacity gpsFreshnessBonus: 20, // subtracted when GPS is live } function todayET () { return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) } function nowHoursET () { const parts = new Date().toLocaleString('en-CA', { timeZone: 'America/Toronto', hour12: false }).split(' ') const [hh, mm] = parts[1].split(':').map(Number) return hh + (mm || 0) / 60 } function timeToHours (t) { if (!t) return 0 const [h, m] = t.split(':').map(Number) return h + (m || 0) / 60 } function hoursToTime (h) { const hh = Math.floor(h) const mm = Math.round((h - hh) * 60) return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') } function dateAddDays (baseStr, n) { const d = new Date(baseStr + 'T12:00:00') d.setDate(d.getDate() + n) return d.toISOString().slice(0, 10) } // Euclidean approximation, km at Montreal latitude function distKm (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) } async function getTechsWithLoad (dateStr) { const [techRes, jobRes] = await Promise.all([ erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify(['name', 'technician_id', 'full_name', 'status', 'longitude', 'latitude', 'traccar_device_id', 'phone']))}&limit_page_length=50`), erpFetch(`/api/resource/Dispatch Job?filters=${encodeURIComponent(JSON.stringify({ status: ['in', ['open', 'assigned']], scheduled_date: dateStr }))}&fields=${encodeURIComponent(JSON.stringify(['name', 'assigned_tech', 'duration_h', 'longitude', 'latitude', 'status', 'route_order']))}&limit_page_length=200`), ]) if (techRes.status !== 200) throw new Error('Failed to fetch technicians') const techs = techRes.data.data || [] const jobs = jobRes.status === 200 ? (jobRes.data.data || []) : [] return techs.map(t => { const queue = jobs .filter(j => j.assigned_tech === t.technician_id) .sort((a, b) => (a.route_order || 0) - (b.route_order || 0)) const load = queue.reduce((s, j) => s + (parseFloat(j.duration_h) || 1), 0) return { id: t.technician_id, name: t.full_name, status: t.status || 'available', coords: [t.longitude || -73.5673, t.latitude || 45.5017], traccarDeviceId: t.traccar_device_id, phone: t.phone, queue, load, tags: [], } }) } async function enrichWithGps (techs) { try { const { getDevices, getPositions } = require('./traccar') const devices = await getDevices() const deviceMap = {} techs.forEach(t => { if (!t.traccarDeviceId) return const dev = devices.find(d => d.id === parseInt(t.traccarDeviceId) || d.uniqueId === t.traccarDeviceId) if (dev) deviceMap[dev.id] = t }) const deviceIds = Object.keys(deviceMap).map(Number) if (!deviceIds.length) return const positions = await getPositions(deviceIds) positions.forEach(p => { const tech = deviceMap[p.deviceId] if (tech && p.latitude && p.longitude) { tech.gpsCoords = [p.longitude, p.latitude] tech.gpsOnline = true } }) } catch (e) { log('GPS enrichment error:', e.message) } } function rankTechs (techs, jobCoords, jobDuration = 1) { const hasCoords = jobCoords && (jobCoords[0] || jobCoords[1]) const candidates = techs.filter(t => t.status !== 'off' && t.status !== 'unavailable') return candidates.map(tech => { const pos = tech.gpsOnline ? tech.gpsCoords : tech.coords const distance = hasCoords ? distKm(pos, jobCoords) : 999 const remainingCap = Math.max(0, 8 - tech.load) let score = 0 const reasons = [] score += Math.min(distance, SCORE_WEIGHTS.proximityMax) * SCORE_WEIGHTS.proximityMultiplier reasons.push(distance < 5 ? `${distance.toFixed(1)} km (très proche)` : distance < 15 ? `${distance.toFixed(1)} km` : `${distance.toFixed(1)} km (loin)`) score += tech.load * SCORE_WEIGHTS.loadMultiplier if (remainingCap < jobDuration) { score += SCORE_WEIGHTS.overloadPenalty reasons.push(`Surchargé (${tech.load.toFixed(1)}h/8h)`) } else if (tech.load < 4) { reasons.push(`Dispo (${tech.load.toFixed(1)}h/8h)`) } else { reasons.push(`Chargé (${tech.load.toFixed(1)}h/8h)`) } if (tech.gpsOnline) { score -= SCORE_WEIGHTS.gpsFreshnessBonus; reasons.push('GPS en direct') } return { techId: tech.id, techName: tech.name, phone: tech.phone, score, distance, load: tech.load, remainingCap, reasons } }).sort((a, b) => a.score - b.score) } // ── Slot suggestion ──────────────────────────────────────────────────────── // Finds open time windows across techs for the next N days. Keeps the logic // simple & predictable: gaps between pinned jobs within a default shift // window, minus a travel buffer before the new job, minus a "not in the // past" cutoff. Scores surface the most natural inserts (earliest first, // then shortest travel), with a 2-slot-per-tech cap to diversify results. const SLOT_DEFAULT_SHIFT = { start_h: 8, end_h: 17 } const SLOT_TRAVEL_BUFFER_H = 0.25 // 15 min pre-job slack before proposed start const SLOT_HORIZON_DAYS = 7 const SLOT_MAX_PER_TECH = 2 async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date, limit = 5 } = {}) { const baseDate = after_date || todayET() const duration = parseFloat(duration_h) || 1 const dates = Array.from({ length: SLOT_HORIZON_DAYS }, (_, i) => dateAddDays(baseDate, i)) const jobCoords = latitude && longitude ? [parseFloat(longitude), parseFloat(latitude)] : null const [techRes, jobRes] = await Promise.all([ erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify([ 'name', 'technician_id', 'full_name', 'status', 'longitude', 'latitude', 'absence_from', 'absence_until', ]))}&limit_page_length=50`), erpFetch(`/api/resource/Dispatch Job?filters=${encodeURIComponent(JSON.stringify([ ['status', 'in', ['open', 'assigned']], ['scheduled_date', '>=', dates[0]], ['scheduled_date', '<=', dates[dates.length - 1]], ]))}&fields=${encodeURIComponent(JSON.stringify([ 'name', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'longitude', 'latitude', ]))}&limit_page_length=500`), ]) if (techRes.status !== 200) throw new Error('Failed to fetch technicians') const techs = (techRes.data.data || []).filter(t => t.status !== 'unavailable') const allJobs = jobRes.status === 200 ? (jobRes.data.data || []) : [] const today = todayET() const nowH = nowHoursET() const slots = [] for (const tech of techs) { const homeCoords = tech.longitude && tech.latitude ? [parseFloat(tech.longitude), parseFloat(tech.latitude)] : null for (const dateStr of dates) { // Absence window skip. if (tech.absence_from && tech.absence_until && dateStr >= tech.absence_from && dateStr <= tech.absence_until) continue // Day's pinned jobs (only those with a real start_time — floating jobs // without a time are ignored since we don't know when they'll land). const dayJobs = allJobs .filter(j => j.assigned_tech === tech.technician_id && j.scheduled_date === dateStr && j.start_time) .map(j => { const s = timeToHours(j.start_time) return { start_h: s, end_h: s + (parseFloat(j.duration_h) || 1), coords: j.latitude && j.longitude ? [parseFloat(j.longitude), parseFloat(j.latitude)] : null, } }) .sort((a, b) => a.start_h - b.start_h) // Build gaps bounded by shift_start/end. const shift = SLOT_DEFAULT_SHIFT const gaps = [] let cursor = shift.start_h, prevCoords = homeCoords for (const j of dayJobs) { gaps.push({ start_h: cursor, end_h: j.start_h, prev_coords: prevCoords, position: gaps.length === 0 ? 'first' : 'between', }) cursor = j.end_h prevCoords = j.coords } gaps.push({ start_h: cursor, end_h: shift.end_h, prev_coords: prevCoords, position: dayJobs.length === 0 ? 'free_day' : 'last', }) for (const g of gaps) { const gapLen = g.end_h - g.start_h if (gapLen < duration + SLOT_TRAVEL_BUFFER_H) continue const startH = g.start_h + SLOT_TRAVEL_BUFFER_H const endH = startH + duration if (endH > g.end_h) continue // Skip slots already in the past (or within 30 min). if (dateStr === today && startH < nowH + 0.5) continue const distanceKm = jobCoords && g.prev_coords ? distKm(g.prev_coords, jobCoords) : null const travelMin = distanceKm != null ? Math.max(5, Math.min(90, Math.round(distanceKm * 1.5))) : Math.round(SLOT_TRAVEL_BUFFER_H * 60) const reasons = [] if (g.position === 'free_day') reasons.push('Journée libre') else if (g.position === 'first') reasons.push('Début de journée') else if (g.position === 'last') reasons.push('Fin de journée') else reasons.push('Entre 2 rendez-vous') if (distanceKm != null) reasons.push(`${distanceKm.toFixed(1)} km du précédent`) slots.push({ tech_id: tech.technician_id, tech_name: tech.full_name, date: dateStr, start_time: hoursToTime(startH), end_time: hoursToTime(endH), travel_min: travelMin, distance_km: distanceKm != null ? +distanceKm.toFixed(1) : null, gap_h: +gapLen.toFixed(1), reasons, position: g.position, }) } } } // Sort: earliest first, then shortest travel. slots.sort((a, b) => { if (a.date !== b.date) return a.date < b.date ? -1 : 1 if (a.start_time !== b.start_time) return a.start_time < b.start_time ? -1 : 1 return (a.travel_min || 0) - (b.travel_min || 0) }) // Diversify: cap slots-per-tech so we don't return 5 options from the // same person. Dispatchers want to compare across resources. const byTech = {} const picked = [] for (const s of slots) { byTech[s.tech_id] = byTech[s.tech_id] || 0 if (byTech[s.tech_id] >= SLOT_MAX_PER_TECH) continue picked.push(s) byTech[s.tech_id]++ if (picked.length >= limit) break } return picked } async function createDispatchJob ({ subject, address, priority, duration_h, job_type, customer, service_location, source_issue, notes, latitude, longitude, assigned_tech, scheduled_date }) { const ticketId = 'DJ-' + Date.now().toString(36).toUpperCase() const payload = { ticket_id: ticketId, subject: subject || 'Travail urgent', address: address || '', duration_h: parseFloat(duration_h) || 1, priority: priority || 'high', status: assigned_tech ? 'assigned' : 'open', job_type: job_type || 'Dépannage', customer: customer || '', service_location: service_location || '', source_issue: source_issue || '', notes: notes || '', latitude: latitude || '', longitude: longitude || '', assigned_tech: assigned_tech || '', scheduled_date: scheduled_date || todayET(), } const r = await erpFetch('/api/resource/Dispatch Job', { method: 'POST', body: JSON.stringify(payload) }) if (r.status < 200 || r.status >= 300) throw new Error('Failed to create dispatch job') return { success: true, job_id: r.data?.data?.name || ticketId, ...payload } } async function handle (req, res, method, path) { const sub = path.replace('/dispatch/', '') // POST /dispatch/best-tech — find optimal tech for a job location if (sub === 'best-tech' && method === 'POST') { try { const body = await parseBody(req) const dateStr = body.date || todayET() const jobCoords = body.latitude && body.longitude ? [parseFloat(body.longitude), parseFloat(body.latitude)] : null const techs = await getTechsWithLoad(dateStr) await enrichWithGps(techs) const ranked = rankTechs(techs, jobCoords, parseFloat(body.duration_h) || 1) return json(res, 200, { ranking: ranked }) } catch (e) { log('best-tech error:', e.message) return json(res, 500, { error: e.message }) } } // POST /dispatch/suggest-slots — return 5 best available time windows if (sub === 'suggest-slots' && method === 'POST') { try { const body = await parseBody(req) const slots = await suggestSlots(body) return json(res, 200, { slots }) } catch (e) { log('suggest-slots error:', e.message) return json(res, 500, { error: e.message }) } } // POST /dispatch/create-job — create + optionally auto-assign to best tech if (sub === 'create-job' && method === 'POST') { try { const body = await parseBody(req) let assignedTech = body.assigned_tech if (body.auto_assign) { const dateStr = body.scheduled_date || todayET() const jobCoords = body.latitude && body.longitude ? [parseFloat(body.longitude), parseFloat(body.latitude)] : null const techs = await getTechsWithLoad(dateStr) await enrichWithGps(techs) const ranked = rankTechs(techs, jobCoords, parseFloat(body.duration_h) || 1) if (ranked.length) { assignedTech = ranked[0].techId log(`Auto-assigned to ${ranked[0].techName} (score: ${ranked[0].score.toFixed(0)}, ${ranked[0].reasons.join(', ')})`) } } const result = await createDispatchJob({ ...body, assigned_tech: assignedTech }) return json(res, 200, result) } catch (e) { log('create-job error:', e.message) return json(res, 500, { error: e.message }) } } json(res, 404, { error: 'Dispatch endpoint not found' }) } async function agentCreateDispatchJob ({ customer_id, service_location, subject, priority, job_type, notes, auto_assign }) { let address = '', latitude = null, longitude = null if (service_location) { const locRes = await erpFetch(`/api/resource/Service Location/${encodeURIComponent(service_location)}`) if (locRes.status === 200) { const loc = locRes.data.data address = [loc.address_line, loc.city, loc.postal_code].filter(Boolean).join(', ') latitude = loc.latitude || null longitude = loc.longitude || null } } const dateStr = todayET() let assignedTech = null, techInfo = null if (auto_assign !== false) { try { const jobCoords = latitude && longitude ? [parseFloat(longitude), parseFloat(latitude)] : null const techs = await getTechsWithLoad(dateStr) await enrichWithGps(techs) const ranked = rankTechs(techs, jobCoords, 1) if (ranked.length) { assignedTech = ranked[0].techId techInfo = { name: ranked[0].techName, phone: ranked[0].phone, distance: ranked[0].distance, reasons: ranked[0].reasons } log(`Agent auto-assigned to ${ranked[0].techName}`) } } catch (e) { log('Agent auto-assign error:', e.message) } } const result = await createDispatchJob({ subject: subject || 'Intervention urgente', address, latitude, longitude, priority: priority || 'high', duration_h: 1, job_type: job_type || 'Dépannage', customer: customer_id || '', service_location: service_location || '', notes: notes || '', assigned_tech: assignedTech, scheduled_date: dateStr, }) return { success: true, job_id: result.job_id, assigned_tech: techInfo ? `${techInfo.name} (${techInfo.reasons.join(', ')})` : 'Aucun tech disponible — job créé en attente', address: result.address, message: techInfo ? `Travail créé et assigné à ${techInfo.name}` : 'Travail créé, en attente d\'assignation', } } module.exports = { handle, agentCreateDispatchJob, rankTechs, getTechsWithLoad, enrichWithGps, suggestSlots }