Backend services: - targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas, extract dispatch scoring weights, trim section dividers across 9 files - modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(), consolidate DM query factory, fix duplicate username fill bug, trim headers (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%) Frontend: - useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into 6 focused helpers (processOnlineStatus, processWanIPs, processRadios, processMeshNodes, processClients, checkRadioIssues) - EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments Documentation (17 → 13 files, -1,400 lines): - New consolidated README.md (architecture, services, dependencies, auth) - Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md - Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md - Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md - Update ROADMAP.md with current phase status - Delete CONTEXT.md (absorbed into README) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
226 lines
9.2 KiB
JavaScript
226 lines
9.2 KiB
JavaScript
'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' })
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
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/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 }
|