gigafibre-fsm/services/targo-hub/lib/dispatch.js
louispaulb 41d9b5f316 feat: flow editor, Gemini QR scanner with offline queue, dispatch planning v2
Major additions accumulated over 9 days — single commit per request.

Flow editor (new):
- Generic visual editor for step trees, usable by project wizard + agent flows
- PROJECT_KINDS / AGENT_KINDS catalogs decouple UI from domain
- Drag-and-drop reorder via vuedraggable with scope isolation per peer group
- Chain-aware depends_on rewrite on reorder (sequential only — DAGs preserved)
- Variable picker with per-applies_to catalog (Customer / Quotation /
  Service Contract / Issue / Subscription), insert + copy-clipboard modes
- trigger_condition helper with domain-specific JSONLogic examples
- Global FlowEditorDialog mounted once in MainLayout, Odoo inline pattern
- Server: targo-hub flow-runtime.js, flow-api.js, flow-templates.js
- ERPNext: Flow Template/Run doctypes, scheduler, 5 seeded system templates
- depends_on chips resolve to step labels instead of opaque "s4" ids

QR/OCR scanner (field app):
- Camera capture → Gemini Vision via targo-hub with 8s timeout
- IndexedDB offline queue retries photos when signal returns
- Watcher merges late-arriving scan results into the live UI

Dispatch:
- Planning mode (draft → publish) with offer pool for unassigned jobs
- Shared presets, recurrence selector, suggested-slots dialog
- PublishScheduleModal, unassign confirmation

Ops app:
- ClientDetailPage composables extraction (useClientData, useDeviceStatus,
  useWifiDiagnostic, useModemDiagnostic)
- Project wizard: shared detail sections, wizard catalog/publish composables
- Address pricing composable + pricing-mock data
- Settings redesign hosting flow templates

Targo-hub:
- Contract acceptance (JWT residential + DocuSeal commercial tracks)
- Referral system
- Modem-bridge diagnostic normalizer
- Device extractors consolidated

Migration scripts:
- Invoice/quote print format setup, Jinja rendering
- Additional import + fix scripts (reversals, dates, customers, payments)

Docs:
- Consolidated: old scattered MDs → HANDOFF, ARCHITECTURE, DATA_AND_FLOWS,
  FLOW_EDITOR_ARCHITECTURE, BILLING_AND_PAYMENTS, CPE_MANAGEMENT,
  APP_DESIGN_GUIDELINES
- Archived legacy wizard PHP for reference
- STATUS snapshots for 2026-04-18/19

Cleanup:
- Removed ~40 generated PDFs/HTMLs (invoice_preview*, rendered_jinja*)
- .gitignore now covers invoice preview output + nested .DS_Store

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 10:44:17 -04:00

403 lines
16 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' })
}
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 }