gigafibre-fsm/services/targo-hub/lib/dispatch.js
louispaulb f97152c831 feat(dispatch-agent): diagnostic interactif (raison→compétence→dispo) + adresse libre avant de créer
Suite au « Failed to create dispatch job » : au-delà du fix nextJobRef (6022cdf), on rend l'assistant plus INTERACTIF
et on capte l'adresse tapée :
- create_job accepte désormais 'address' (texte libre) → transmis à agentCreateDispatchJob → stocké sur le Dispatch Job
  (avant, l'adresse dictée était perdue si pas de lieu de service lié). Vérifié : job.address = « 2338 rue Ste-Clotilde ».
- Nouveau lecteur resolve_skill (raison → compétence(s) requise(s) via skill-resolver) → l'agent DIAGNOSTIQUE la ressource
  nécessaire. Vérifié : « voir signal fibre » → installation.
- Prompt système : pour une intervention, procéder par étapes — (1) cerner la raison (question si vague), (2) resolve_skill,
  (3) find_slot (dispo réelle), (4) SEULEMENT ensuite proposer create_job/assign_tech/proposer_rdv_client + résumer le
  diagnostic avant l'action. L'agent ne saute plus à la création.
Déployé (3 fichiers), hub sain, resolve_skill listé dans /staff-agent/tools.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:41:42 -04:00

1096 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict'
const cfg = require('./config')
const { log, json, parseBody, erpFetch, readJsonFile } = require('./helpers')
// Prédicat « ce tech a-t-il la compétence ? » PARTAGÉ (même sémantique que capacityByDay/solveur). Le champ ERP `skills`
// (ou `_user_tags`) est une CSV → techHasSkill l'accepte telle quelle. SOURCE UNIQUE : lib/skill-resolver.js.
const { techHasSkill, techHasSkills } = require('./skill-resolver')
// Techs archivés (store hub partagé avec roster.js) → exclus des créneaux + occupation. Lu à chaud (change rarement).
const archivedTechSet = () => new Set((readJsonFile('/app/data/archived_techs.json', []) || []).map(String))
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=500`), // 500 : couvre tout l'effectif (>50) — sinon les techs au-delà du 50e sont invisibles au dispatch/ranking
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_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, skill = '', ignoreReserved = false } = {}) {
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 wantSkill = String(skill || '').trim().toLowerCase()
const isRepair = /r[ée]par|d[ée]pann|panne|bris/.test(wantSkill) // les WEEK-ENDS sont RÉSERVÉS aux réparations → sautés pour les autres compétences
const [techRes, jobRes, tplRes, shiftRes, availRes] = await Promise.all([
erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify([
'name', 'technician_id', 'full_name', 'status', 'longitude', 'latitude',
'absence_from', 'absence_until', 'skills', '_user_tags',
]))}&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', 'job_type', 'priority',
]))}&limit_page_length=500`),
erpFetch(`/api/resource/Shift Template?fields=${encodeURIComponent(JSON.stringify(['name', 'start_time', 'end_time', 'on_call']))}&limit_page_length=100`),
erpFetch(`/api/resource/Shift Assignment?filters=${encodeURIComponent(JSON.stringify([['assignment_date', 'in', dates]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'assignment_date', 'shift_template']))}&limit_page_length=2000`),
// Congés/absences APPROUVÉS (Tech Availability) — où vivent les vacances (import calendrier, dialogue congés, grille). Sinon un tech en congé mais avec un quart matérialisé ressort quand même.
erpFetch(`/api/resource/Tech Availability?filters=${encodeURIComponent(JSON.stringify([['status', '=', 'Approuvé'], ['from_date', '<=', dates[dates.length - 1]], ['to_date', '>=', dates[0]]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'from_date', 'to_date']))}&limit_page_length=500`),
])
if (techRes.status !== 200) throw new Error('Failed to fetch technicians')
// FILTRE COMPÉTENCE : ne garder que les techs qui ONT le skill demandé (champ skills ou tags Frappe).
const hasSkill = (t) => techHasSkill(t.skills || t._user_tags, wantSkill)
const techs = (() => { const _arch = archivedTechSet(); return (techRes.data.data || []).filter(t => t.status !== 'unavailable' && t.status !== 'En pause' && !_arch.has(t.technician_id) && !_arch.has(t.name) && hasSkill(t)) })()
// Jours de congé approuvé par tech (clé = technician, qui correspond au technician_id ou au docname). Aligné sur roster.buildUnavailability.
const vacBy = {}
for (const a of ((availRes.data && availRes.data.data) || [])) {
const set = vacBy[a.technician] || (vacBy[a.technician] = new Set())
for (const d of dates) if (d >= a.from_date && d <= a.to_date) set.add(d)
}
const vacDays = (t) => vacBy[t.technician_id] || vacBy[t.name] || null
const allJobs = jobRes.status === 200 ? (jobRes.data.data || []) : []
// Fenêtres de quart RÉELLES par tech×jour (Shift Assignment × Shift Template) — plus de fenêtre 8-17 par défaut : un tech SANS quart ce jour = pas de créneau.
const tpl = {}; for (const t of ((tplRes.data && tplRes.data.data) || [])) tpl[t.name] = t
const winBy = {} // 'technician|date' → { start_h, end_h } (quart réel, garde exclue)
for (const a of ((shiftRes.data && shiftRes.data.data) || [])) {
const tp = tpl[a.shift_template]; if (!tp || tp.on_call) continue
const s = timeToHours(tp.start_time), e = timeToHours(tp.end_time); if (s == null || e == null || !(e > s)) continue
const k = a.technician + '|' + a.assignment_date
const w = winBy[k]; winBy[k] = w ? { start_h: Math.min(w.start_h, s), end_h: Math.max(w.end_h, e) } : { start_h: s, end_h: e }
}
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
const techVac = vacDays(tech) // jours de congé approuvé (Tech Availability)
for (const dateStr of dates) {
// WEEK-END réservé aux réparations : on saute sam/dim pour toute autre compétence.
const dow = new Date(dateStr + 'T12:00:00').getUTCDay()
if ((dow === 0 || dow === 6) && !isRepair) continue
// Congé approuvé (Tech Availability) OU fenêtre d'absence du Dispatch Technician → pas de créneau.
if (techVac && techVac.has(dateStr)) continue
if (tech.absence_from && tech.absence_until &&
dateStr >= tech.absence_from && dateStr <= tech.absence_until) continue
// QUART RÉEL du tech ce jour (Shift Assignment) — pas de quart = pas de créneau (créer un quart pour en ouvrir).
const shift = winBy[tech.technician_id + '|' + dateStr] || winBy[tech.name + '|' + dateStr]
if (!shift) continue
// Day's pinned jobs (only those with a real start_time) — servent à découper les trous.
// Match par technician_id OU docname (les jobs legacy portent parfois l'un ou l'autre).
// BLOC DE TEMPS interne (formation/réunion/projet…) = job_type Interne/Réservation. En mode URGENT (ignoreReserved),
// la PRIORITÉ est le curseur : un bloc de priorité ≤ moyenne est TRAVERSÉ (l'urgence peut prendre le tech) ; un bloc
// « Urgent » (high) RÉSISTE = bloc dur. Les vrais jobs client ne sont JAMAIS traversés (jamais de double-booking).
const isSoftBlock = (j) => (j.job_type === 'Réservation' || j.job_type === 'Interne') && String(j.priority || 'medium').toLowerCase() !== 'high'
const belongs = (j) => (j.assigned_tech === tech.technician_id || j.assigned_tech === tech.name) &&
j.scheduled_date === dateStr && !(ignoreReserved && isSoftBlock(j))
const dayJobs = allJobs
.filter(j => belongs(j) && 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)
// « Humainement possible » : un tech ne peut pas dépasser son quart. Les jobs assignés SANS heure fixée
// (legacy osTicket) occupent quand même la journée → on retranche leur charge + celle déjà placée ; si la
// capacité restante ne suffit pas pour cette durée, PAS de créneau ce jour (sinon on suroffre un tech déjà plein).
const shiftH = shift.end_h - shift.start_h
const clampD = (j) => Math.max(0, Math.min(j.end_h, shift.end_h) - Math.max(j.start_h, shift.start_h))
const timedBusy = dayJobs.reduce((a, j) => a + clampD(j), 0)
const untimedLoad = allJobs.filter(j => belongs(j) && !j.start_time)
.reduce((a, j) => a + (parseFloat(j.duration_h) || 1), 0)
if (timedBusy + untimedLoad + duration > shiftH + 0.01) continue
// Build gaps bounded by shift_start/end (shift = quart réel du tech ce jour, résolu ci-dessus).
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
}
// Occupation par ressource (tech) sur l'horizon, FILTRÉE par compétence — même modèle de données que suggestSlots
// (quart réel = capacité, jobs planifiés = occupé), mais renvoie l'OCCUPATION plutôt que les trous. Alimente la
// visualisation « bandes verticales » de « Trouver un créneau » : on voit d'un coup qui a de la place pour le skill choisi.
async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = '' } = {}) {
const baseDate = after_date || todayET()
const span = Math.max(1, Math.min(42, parseInt(days, 10) || SLOT_HORIZON_DAYS)) // jusqu'à 6 semaines (calendrier mois de l'horaire tech)
const dates = Array.from({ length: span }, (_, i) => dateAddDays(baseDate, i))
// `skill` accepte UNE compétence OU plusieurs en CSV (« réparation,sans-fil ») → le tech doit les avoir TOUTES.
const wantSkills = String(skill || '').split(',').map(s => s.trim()).filter(Boolean)
const [techRes, jobRes, tplRes, shiftRes, availRes] = await Promise.all([
erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify([
'name', 'technician_id', 'full_name', 'status', 'absence_from', 'absence_until', 'skills', '_user_tags',
]))}&limit_page_length=500`), // 500 : couvre tout l'effectif (56+ techs) — le cap 50 laissait les techs au-delà du 50e SANS occupation → calendrier mois de leur horaire tout gris (bug Philippe Bourdon & co.)
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', 'job_type',
]))}&limit_page_length=500`),
erpFetch(`/api/resource/Shift Template?fields=${encodeURIComponent(JSON.stringify(['name', 'start_time', 'end_time', 'on_call']))}&limit_page_length=100`),
erpFetch(`/api/resource/Shift Assignment?filters=${encodeURIComponent(JSON.stringify([['assignment_date', 'in', dates]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'assignment_date', 'shift_template']))}&limit_page_length=2000`),
erpFetch(`/api/resource/Tech Availability?filters=${encodeURIComponent(JSON.stringify([['status', '=', 'Approuvé'], ['from_date', '<=', dates[dates.length - 1]], ['to_date', '>=', dates[0]]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'from_date', 'to_date']))}&limit_page_length=500`),
])
if (techRes.status !== 200) throw new Error('Failed to fetch technicians')
const hasSkill = (t) => techHasSkills(t.skills || t._user_tags, wantSkills)
const techs = (() => { const _arch = archivedTechSet(); return (techRes.data.data || []).filter(t => t.status !== 'unavailable' && t.status !== 'En pause' && !_arch.has(t.technician_id) && !_arch.has(t.name) && hasSkill(t)) })()
const allJobs = jobRes.status === 200 ? (jobRes.data.data || []) : []
// Congés approuvés (Tech Availability) → jours « off » dans l'occupation (aligné sur suggestSlots + roster).
const vacBy = {}
for (const a of ((availRes.data && availRes.data.data) || [])) {
const set = vacBy[a.technician] || (vacBy[a.technician] = new Set())
for (const d of dates) if (d >= a.from_date && d <= a.to_date) set.add(d)
}
const tpl = {}; for (const t of ((tplRes.data && tplRes.data.data) || [])) tpl[t.name] = t
const winBy = {} // 'tech|date' → { start_h, end_h } (quart réel)
for (const a of ((shiftRes.data && shiftRes.data.data) || [])) {
const tp = tpl[a.shift_template]; if (!tp || tp.on_call) continue
const s = timeToHours(tp.start_time), e = timeToHours(tp.end_time); if (s == null || e == null || !(e > s)) continue
const k = a.technician + '|' + a.assignment_date
const w = winBy[k]; winBy[k] = w ? { start_h: Math.min(w.start_h, s), end_h: Math.max(w.end_h, e) } : { start_h: s, end_h: e }
}
const out = techs.map(tech => {
const techVac = vacBy[tech.technician_id] || vacBy[tech.name] || null
const cells = dates.map(dateStr => {
const dow = new Date(dateStr + 'T12:00:00').getUTCDay()
const onLeave = techVac && techVac.has(dateStr)
const absent = tech.absence_from && tech.absence_until && dateStr >= tech.absence_from && dateStr <= tech.absence_until
const shift = winBy[tech.technician_id + '|' + dateStr] || winBy[tech.name + '|' + dateStr]
if (onLeave) return { date: dateStr, dow, off: true, reason: 'vacation', occupancy: null }
if (absent) return { date: dateStr, dow, off: true, reason: 'absence', occupancy: null }
if (!shift) return { date: dateStr, dow, off: true, reason: (dow === 0 || dow === 6) ? 'weekend' : 'no_shift', occupancy: null }
const shiftH = shift.end_h - shift.start_h
// Match par technician_id OU docname (comme les quarts/congés) : les jobs legacy portent parfois l'un ou l'autre.
const dayJobsRaw = allJobs
.filter(j => (j.assigned_tech === tech.technician_id || j.assigned_tech === tech.name) && j.scheduled_date === dateStr)
.map(j => { const s = j.start_time ? timeToHours(j.start_time) : null; const dur = parseFloat(j.duration_h) || 1; return { start_h: s, dur, end_h: s != null ? s + dur : null, reserved: j.job_type === 'Réservation' } })
const timed = dayJobsRaw.filter(j => j.start_h != null).sort((a, b) => a.start_h - b.start_h)
const untimed = dayJobsRaw.filter(j => j.start_h == null) // jobs assignés SANS heure fixée (legacy osTicket) — occupent quand même la journée
const clamp = (j) => Math.max(0, Math.min(j.end_h, shift.end_h) - Math.max(j.start_h, shift.start_h))
const timedBusy = timed.reduce((acc, j) => acc + clamp(j), 0)
const untimedBusy = untimed.reduce((acc, j) => acc + j.dur, 0)
const busyH = timedBusy + untimedBusy // ⇐ correctif : les jobs sans heure comptent (avant : ignorés ⇒ « 40 h libre » alors que plein)
const blocks = timed.map(j => ({
start: hoursToTime(Math.max(j.start_h, shift.start_h)), end: hoursToTime(Math.min(j.end_h, shift.end_h)),
top: shiftH > 0 ? +Math.max(0, (Math.max(j.start_h, shift.start_h) - shift.start_h) / shiftH).toFixed(3) : 0,
height: shiftH > 0 ? +Math.max(0, clamp(j) / shiftH).toFixed(3) : 0,
reserved: j.reserved,
}))
// Jobs sans heure : empilés à partir de la charge horaire déjà placée, marqués « untimed » (rendu hachuré côté SPA).
let cum = timedBusy
for (const j of untimed) {
blocks.push({
start: null, end: null,
top: shiftH > 0 ? +Math.min(1, cum / shiftH).toFixed(3) : 0,
height: shiftH > 0 ? +Math.max(0.03, j.dur / shiftH).toFixed(3) : 0,
reserved: j.reserved, untimed: true,
})
cum += j.dur
}
return {
date: dateStr, dow, off: false, shift_start: hoursToTime(shift.start_h), shift_end: hoursToTime(shift.end_h),
shift_h: +shiftH.toFixed(1), busy_h: +busyH.toFixed(1), free_h: +Math.max(0, shiftH - busyH).toFixed(1),
jobs: dayJobsRaw.length, untimed_jobs: untimed.length,
occupancy: shiftH > 0 ? Math.min(1, +(busyH / shiftH).toFixed(2)) : 0, blocks,
}
})
const totShift = cells.reduce((a, c) => a + (c.shift_h || 0), 0)
const totBusy = cells.reduce((a, c) => a + (c.busy_h || 0), 0)
return {
tech_id: tech.technician_id, tech_name: tech.full_name,
shift_h: +totShift.toFixed(1), busy_h: +totBusy.toFixed(1), free_h: +Math.max(0, totShift - totBusy).toFixed(1),
occupancy: totShift > 0 ? Math.min(1, +(totBusy / totShift).toFixed(2)) : null, days: cells,
}
})
// Le moins occupé d'abord = meilleurs candidats ; les techs sans aucun quart sur l'horizon en dernier.
out.sort((a, b) => (a.occupancy == null ? 2 : a.occupancy) - (b.occupancy == null ? 2 : b.occupancy) || String(a.tech_name).localeCompare(b.tech_name))
return { start: baseDate, days: span, dates, skill: wantSkills.join(','), skills: wantSkills, techs: out }
}
// Numéro de job STANDARDISÉ « YYYYMMDD-XXX » (compteur PAR JOUR). Le nom du Dispatch Job = ce champ (autoname field:ticket_id).
// Best-effort (max du jour + 1) ; le VRAI garde-fou anti-collision = l'unicité du nom à l'insertion (le créateur réessaie au besoin).
async function nextJobRef () {
const ymd = String(todayET()).slice(0, 10).replace(/-/g, '')
let max = 0
try {
const filters = encodeURIComponent(JSON.stringify([['name', 'like', ymd + '-%']]))
const fields = encodeURIComponent(JSON.stringify(['name']))
const r = await erpFetch(`/api/resource/Dispatch Job?filters=${filters}&fields=${fields}&limit_page_length=0`)
const re = new RegExp('^' + ymd + '-(\\d{3})$')
for (const j of ((r && r.data && r.data.data) || [])) { const m = re.exec(String(j.name || '')); if (m) { const n = parseInt(m[1], 10); if (n > max) max = n } }
} catch (e) { log('nextJobRef: ' + e.message) }
return ymd + '-' + String(max + 1).padStart(3, '0')
}
// Crée une CHAÎNE d'installation (projet + sous-tâches) en UN geste depuis un template (project-templates.js).
// Numéro du projet = ref YYYYMMDD-XXX partagé ; étapes = ref-s1..sN. Chaînage = patron éprouvé de checkout.js
// (étape racine 'open', dépendantes 'On Hold' jusqu'à complétion via depends_on ; parent_job = étape racine pour le regroupement).
async function createInstallChain ({ customer, customer_name, template, scheduled_date, priority, assigned_tech, address }) {
const { getTemplateSteps } = require('./project-templates')
const steps = getTemplateSteps(template || 'fiber_install')
if (!steps.length) return { ok: false, error: 'template inconnu: ' + (template || 'fiber_install') }
const ref = await nextJobRef()
const created = [] // noms = ticket_id (autoname field:ticket_id → name === ticket_id) → chaînage déterministe, sans dépendre de la réponse POST
try {
for (let i = 0; i < steps.length; i++) {
const step = steps[i]
const ticketId = `${ref}-s${i + 1}`
const dependsOn = (step.depends_on_step != null && created[step.depends_on_step]) ? created[step.depends_on_step] : ''
const payload = {
ticket_id: ticketId,
subject: step.subject,
customer: customer || '',
address: address || '',
duration_h: step.duration_h || 1,
priority: priority || step.priority || 'medium',
status: dependsOn ? 'On Hold' : 'open', // racine 'open', dépendantes 'On Hold' jusqu'à complétion (unblockDependents)
job_type: step.job_type || 'Autre',
assigned_group: step.assigned_group || '',
assigned_tech: (i === 0 && assigned_tech) ? assigned_tech : '',
depends_on: dependsOn,
parent_job: created.length ? created[0] : '', // regroupement sous l'étape racine
step_order: i + 1,
scheduled_date: scheduled_date || '',
}
await erpFetch('/api/resource/Dispatch Job', { method: 'POST', body: JSON.stringify(payload) })
created.push(ticketId)
}
} catch (e) {
return { ok: false, error: 'échec étape ' + (created.length + 1) + ': ' + e.message, ref, jobs: created }
}
return { ok: true, ref, project: ref, jobs: created, count: created.length }
}
async function createDispatchJob ({ subject, address, priority, duration_h, job_type, customer, service_location, source_issue, notes, latitude, longitude, assigned_tech, scheduled_date }) {
const ticketId = await nextJobRef()
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 }
}
// ── Chain-walk helpers ──────────────────────────────────────────────────────
// A Dispatch Job can have `depends_on` → another Dispatch Job. When a job is
// created as part of a chain (acceptance.js → createDeferredJobs), the
// dependent child is born with status='On Hold' so it doesn't show up in the
// tech's active list. When the parent flips to 'Completed', we walk the chain:
// find all `depends_on == parent && status == 'On Hold'` and flip them to
// 'open' so the next step becomes visible.
//
// We also handle fan-out (multiple children of one parent) and fire SSE
// `job-unblocked` so the tech SPA can refresh without a poll.
async function unblockDependents (jobName) {
if (!jobName) return []
const filters = encodeURIComponent(JSON.stringify([
['depends_on', '=', jobName],
['status', '=', 'On Hold'],
]))
const fields = encodeURIComponent(JSON.stringify(['name', 'subject', 'assigned_tech', 'scheduled_date']))
const r = await erpFetch(`/api/resource/Dispatch%20Job?filters=${filters}&fields=${fields}&limit_page_length=20`)
const deps = (r.status === 200 && Array.isArray(r.data?.data)) ? r.data.data : []
const unblocked = []
for (const j of deps) {
try {
const up = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(j.name)}`, {
method: 'PUT', body: JSON.stringify({ status: 'open' }),
})
if (up.status < 400) {
unblocked.push(j.name)
require('./sse').broadcast('dispatch', 'job-unblocked', {
job: j.name, subject: j.subject, tech: j.assigned_tech, unblocked_by: jobName,
})
log(` ↳ unblocked ${j.name} (depends_on=${jobName})`)
}
} catch (e) { log(` ! unblock failed for ${j.name}: ${e.message}`) }
}
return unblocked
}
// Chain terminal check — is `jobName` the LAST still-open job in its chain?
// A chain is identified by its root (parent_job === '' or self). A job is
// terminal when every sibling under the same root is Completed or Cancelled.
async function _isChainTerminal (jobName) {
const jRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(jobName)}?fields=${encodeURIComponent(JSON.stringify(['parent_job']))}`)
if (jRes.status !== 200 || !jRes.data?.data) return false
const rootName = jRes.data.data.parent_job || jobName
const stillOpen = ['open', 'Scheduled', 'In Progress', 'On Hold']
const rootRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(rootName)}?fields=${encodeURIComponent(JSON.stringify(['status']))}`)
if (rootRes.status === 200 && stillOpen.includes(rootRes.data?.data?.status)) return false
const childFilter = encodeURIComponent(JSON.stringify([
['parent_job', '=', rootName],
['name', '!=', jobName],
['status', 'in', stillOpen],
]))
const childRes = await erpFetch(`/api/resource/Dispatch%20Job?filters=${childFilter}&fields=${encodeURIComponent(JSON.stringify(['name']))}&limit_page_length=1`)
return !(childRes.status === 200 && Array.isArray(childRes.data?.data) && childRes.data.data.length)
}
// Memoized company default income account — Sales Invoice items require
// an income_account that belongs to the company, and the default line-item
// resolution leaves it as None (→ 417 at validation time). We read the
// Company.default_income_account once per process and reuse it.
let _cachedIncomeAccount = null
async function _defaultIncomeAccount (company) {
if (_cachedIncomeAccount) return _cachedIncomeAccount
try {
const r = await erpFetch(`/api/resource/Company/${encodeURIComponent(company)}?fields=${encodeURIComponent(JSON.stringify(['default_income_account']))}`)
const acc = r.data?.data?.default_income_account
if (acc) { _cachedIncomeAccount = acc; return acc }
} catch (_) { /* fall through */ }
// Hard-coded fallback for TARGO if the lookup failed for some reason
_cachedIncomeAccount = 'Ventes - T'
return _cachedIncomeAccount
}
// Activate every 'En attente' Service Subscription for (customer, service_location)
// belonging to the completed job, and emit ONE CONSOLIDATED prorated Sales Invoice
// (all lines on a single draft) via the proration engine — replacing the previous
// one-invoice-per-subscription behaviour. The invoice is built type-aware (recurring
// prorated, one-time full, discounts follow their line) with largest-remainder rounding.
// Returns { activated, invoice, invoices, written, prorated_total }.
async function activateSubscriptionForJob (jobName) {
const jRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(jobName)}?fields=${encodeURIComponent(JSON.stringify(['customer', 'service_location']))}`)
if (jRes.status !== 200 || !jRes.data?.data) return { activated: [], invoice: null, invoices: [], written: false }
const { customer, service_location } = jRes.data.data
if (!customer || !service_location) return { activated: [], invoice: null, invoices: [], written: false }
// Subscriptions waiting on THIS activation — strictly 'En attente' so we NEVER
// re-bill subscriptions that are already Actif from a prior (partial) activation.
const subFilter = encodeURIComponent(JSON.stringify([
['customer', '=', customer],
['service_location', '=', service_location],
['status', '=', 'En attente'],
]))
const subRes = await erpFetch(`/api/resource/Service%20Subscription?filters=${subFilter}&fields=${encodeURIComponent(JSON.stringify(['name']))}&limit_page_length=50`)
const pending = (subRes.status === 200 && Array.isArray(subRes.data?.data)) ? subRes.data.data : []
if (!pending.length) return { activated: [], invoice: null, invoices: [], written: false }
// 1. ONE consolidated, type-aware, prorated draft invoice for exactly these pending subs.
// Built BEFORE flipping (the engine filters on status='En attente'). Draft-only and
// DOUBLE-GATED (commit:true + env PRORATION_WRITE=on): with the gate off it computes the
// preview but writes NOTHING, so activation never bills until explicitly armed.
let billing = { written: false }
try {
billing = await require('./proration').commitActivationInvoice({
customer, service_location, statuses: ['En attente'], commit: true,
})
} catch (e) { log(` ! consolidated activation invoice failed: ${e.message}`) }
// 2. Flip each pending subscription to Actif (start_date=tomorrow — activation day is free).
const now = new Date()
const tm = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
const tomorrowStr = `${tm.getFullYear()}-${String(tm.getMonth() + 1).padStart(2, '0')}-${String(tm.getDate()).padStart(2, '0')}`
const activated = []
for (const sub of pending) {
const up = await erpFetch(`/api/resource/Service%20Subscription/${encodeURIComponent(sub.name)}`, {
method: 'PUT', body: JSON.stringify({ status: 'Actif', start_date: tomorrowStr }),
})
if (up.status >= 400) { log(` ! activate ${sub.name} returned ${up.status}`); continue }
activated.push(sub.name)
log(` ✓ Service Subscription ${sub.name} → Actif (start_date=${tomorrowStr})`)
}
const invoices = (billing.written && billing.draft_invoice)
? [{ name: billing.draft_invoice, amount: billing.prorated_total, consolidated: true }]
: []
if (billing.written && billing.draft_invoice) {
log(` ✓ Facture consolidée (BROUILLON) ${billing.draft_invoice}${billing.prorated_total}$ · ${billing.billed_days}/${billing.period_days} j · ${activated.length} abonnement(s)`)
} else {
log(`${activated.length} abonnement(s) activé(s) — facture consolidée NON émise (écriture désarmée: ${billing.note || 'PRORATION_WRITE off'}) ; aperçu = ${billing.prorated_total != null ? billing.prorated_total + '$' : 'n/a'}`)
}
require('./sse').broadcast('dispatch', 'subscription-activated', {
subscriptions: activated, invoice: billing.draft_invoice || null,
amount: billing.prorated_total != null ? billing.prorated_total : null,
customer, consolidated: true, preview_only: !billing.written,
})
return { activated, invoice: billing.draft_invoice || null, invoices, written: !!billing.written, prorated_total: billing.prorated_total != null ? billing.prorated_total : null }
}
// Delete a Dispatch Job safely by rewiring its chain neighbours first.
//
// Problem: ERPNext throws LinkExistsError if any other Dispatch Job has
// `depends_on = <victim>` or `parent_job = <victim>`. The UI's generic
// "Supprimer cette tâche" button hits this whenever the user tries to
// drop a middle step from a chain.
//
// Strategy: before DELETE, repoint the chain so the victim is isolated:
// 1. Children (depends_on = victim) → depends_on = victim.depends_on
// (skips the victim in the chain; the successor's status recomputes
// so it unblocks if it was On Hold and its new parent is Completed
// or empty).
// 2. Descendants (parent_job = victim, only fires when victim was the
// chain root) → parent_job = new root. The new root is chosen as the
// lowest-step_order former child (now a chain root itself).
// 3. Finally DELETE the victim — should succeed since no FK references it.
//
// Returns { ok, deleted, rewired: [...], note }.
async function deleteJobSafely (jobName) {
if (!jobName) throw new Error('jobName required')
const vRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(jobName)}`)
if (vRes.status !== 200 || !vRes.data?.data) throw new Error(`job ${jobName} not found`)
const victim = vRes.data.data
const victimDependsOn = victim.depends_on || ''
const victimParentJob = victim.parent_job || ''
// 1. Immediate successors in the chain
const childFilters = encodeURIComponent(JSON.stringify([['depends_on', '=', jobName]]))
const childFields = encodeURIComponent(JSON.stringify(['name', 'status', 'step_order']))
const cRes = await erpFetch(`/api/resource/Dispatch%20Job?filters=${childFilters}&fields=${childFields}&limit_page_length=50`)
const children = (cRes.status === 200 && Array.isArray(cRes.data?.data)) ? cRes.data.data : []
const rewired = []
for (const child of children) {
const patch = { depends_on: victimDependsOn }
// Unblock if the new parent is empty or already Completed — otherwise the
// child stays On Hold waiting on the new parent.
if (child.status === 'On Hold') {
let newParentDone = !victimDependsOn // no parent → chain root → unblock
if (victimDependsOn) {
const pRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(victimDependsOn)}?fields=${encodeURIComponent(JSON.stringify(['status']))}`)
const pStatus = pRes.data?.data?.status
if (pStatus === 'Completed' || pStatus === 'done' || pStatus === 'Cancelled') newParentDone = true
}
if (newParentDone) patch.status = 'open'
}
const up = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(child.name)}`, {
method: 'PUT', body: JSON.stringify(patch),
})
if (up.status < 400) {
rewired.push({ name: child.name, depends_on: victimDependsOn, status: patch.status })
log(`${child.name}: depends_on=${jobName}${victimDependsOn || '(none)'}${patch.status ? ` + status→${patch.status}` : ''}`)
} else {
log(` ! rewire failed for ${child.name}: ${up.status}`)
}
}
// 2. Was the victim a chain root? If so, any job with parent_job=victim
// needs a new root. Pick the former lowest-step child (now the new head).
if (!victimParentJob) {
const descFilters = encodeURIComponent(JSON.stringify([['parent_job', '=', jobName]]))
const descFields = encodeURIComponent(JSON.stringify(['name']))
const dRes = await erpFetch(`/api/resource/Dispatch%20Job?filters=${descFilters}&fields=${descFields}&limit_page_length=100`)
const descendants = (dRes.status === 200 && Array.isArray(dRes.data?.data)) ? dRes.data.data : []
if (descendants.length) {
// Former children became roots; pick the lowest-step one as the new
// canonical root (so a chain with fan-out survives as a single chain).
const sortedKids = [...children].sort((a, b) => (a.step_order || 0) - (b.step_order || 0))
const newRoot = (sortedKids[0] && sortedKids[0].name) || ''
for (const d of descendants) {
if (d.name === newRoot) continue // the new root has parent_job='' (done above via children loop when we rewired depends_on; parent_job still references victim though — set to '')
const up = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(d.name)}`, {
method: 'PUT', body: JSON.stringify({ parent_job: newRoot || '' }),
})
if (up.status < 400) log(`${d.name}: parent_job=${jobName}${newRoot || '(none)'}`)
}
// And blank the new root's parent_job (it's the root now)
if (newRoot) {
await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(newRoot)}`, {
method: 'PUT', body: JSON.stringify({ parent_job: '' }),
}).catch(() => {})
}
}
}
// 3. Delete the victim
const dRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(jobName)}`, { method: 'DELETE' })
if (dRes.status >= 400) {
const msg = dRes.data?.exception || dRes.data?._error_message || `HTTP ${dRes.status}`
throw new Error(msg)
}
require('./sse').broadcast('dispatch', 'job-deleted', { job: jobName, rewired: rewired.map(r => r.name) })
log(` ✗ deleted ${jobName} (rewired ${rewired.length} child${rewired.length === 1 ? '' : 'ren'})`)
return { ok: true, deleted: jobName, rewired, note: children.length ? `Rewired ${children.length} dependent job(s) before deletion.` : 'No dependents — clean delete.' }
}
// Thin wrapper that PUTs the status + runs unblock logic. Used by
// tech-mobile (token auth) and the ops SPA (session auth) through a single
// code path — status changes always walk the chain, no matter who wrote them.
async function setJobStatusWithChain (jobName, status) {
const r = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(jobName)}`, {
method: 'PUT', body: JSON.stringify({ status }),
})
if (r.status >= 400) throw new Error(`ERPNext ${r.status}`)
require('./sse').broadcast('dispatch', 'job-status', { job: jobName, status })
const unblocked = status === 'Completed' ? await unblockDependents(jobName) : []
// Terminal-node detection: if this Completed job is the last open one in
// the chain (no unblocked dependents AND no other siblings pending),
// activate the linked Service Subscription and emit a prorated invoice.
let activation = { activated: [], invoices: [] }
if (status === 'Completed' && unblocked.length === 0) {
if (await _isChainTerminal(jobName)) {
if (process.env.BILLING_APPROVAL_GATE === 'on') {
// P1 : la complétion ne facture/n'active plus directement — elle place l'activation
// « à approuver ». L'activation (puis la facturation, gatée PRORATION_WRITE) se font
// à l'approbation explicite dans Ops (cf docs/design/activation-billing-automation.md).
const pending = require('./billing-approvals').markPending(jobName)
activation = { pending_approval: pending, activated: [], invoices: [] }
log(`${jobName} terminal → EN ATTENTE D'APPROBATION facturation (aucune activation/facture)`)
} else {
activation = await activateSubscriptionForJob(jobName)
}
}
}
return { ok: true, job: jobName, status, unblocked, ...activation }
}
async function handle (req, res, method, path) {
const sub = path.replace('/dispatch/', '')
// POST /dispatch/job-status — update status + auto-unblock dependents
// This is the canonical status-write endpoint. Anything that flips a
// Dispatch Job status (tech SPA, dispatcher SPA, tech-mobile token page)
// should go through here so the chain-walk runs in exactly one place.
if (sub === 'job-status' && method === 'POST') {
try {
const body = await parseBody(req)
if (!body?.job || !body?.status) return json(res, 400, { error: 'job and status required' })
const result = await setJobStatusWithChain(body.job, body.status)
return json(res, 200, result)
} catch (e) {
log('job-status error:', e.message)
return json(res, 500, { error: e.message })
}
}
// POST /dispatch/job-delete — safe delete that rewires chain neighbours.
// The raw ERPNext DELETE fails with LinkExistsError whenever any sibling
// references the victim via depends_on/parent_job. This endpoint unlinks
// descendants first (see deleteJobSafely doc comment) then deletes.
if (sub === 'job-delete' && method === 'POST') {
try {
const body = await parseBody(req)
if (!body?.job) return json(res, 400, { error: 'job required' })
const result = await deleteJobSafely(body.job)
return json(res, 200, result)
} catch (e) {
log('job-delete error:', e.message)
return json(res, 500, { error: e.message })
}
}
// GET /dispatch/history?date=YYYY-MM-DD[&tech=TECH-x] — TOUS les jobs d'une journée, statuts INCLUS (Completed/Cancelled), groupés par technicien.
// Corrige la perte de visibilité : le board n'affiche que open/assigned/in_progress → une fois un job terminé/annulé (ou son ticket fermé/réassigné) on ne voyait plus où le tech était allé.
if (sub.startsWith('history') && method === 'GET') {
try {
const params = require('url').parse(req.url, true).query
const date = params.date || todayET()
const filters = [['scheduled_date', '=', date]]
if (params.tech) filters.push(['assigned_tech', '=', params.tech])
const fields = ['name', 'subject', 'status', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'job_type', 'source_issue', 'customer', 'actual_start', 'actual_end']
const qs = new URLSearchParams({ filters: JSON.stringify(filters), fields: JSON.stringify(fields), limit_page_length: '500', order_by: 'assigned_tech asc, start_time asc, modified asc' })
const r = await erpFetch(`/api/resource/Dispatch Job?${qs}`)
if (r.status !== 200) return json(res, 500, { error: 'ERPNext ' + r.status })
const jobs = r.data?.data || []
const tr = await erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify(['technician_id', 'full_name']))}&limit_page_length=200`)
const names = {}; for (const t of (tr.data?.data || [])) names[t.technician_id] = t.full_name || t.technician_id
const groups = {}
for (const j of jobs) { const k = j.assigned_tech || '__none__'; (groups[k] = groups[k] || { tech: k, tech_name: k === '__none__' ? 'Non assigné' : (names[k] || k), jobs: [] }).jobs.push(j) }
return json(res, 200, { date, count: jobs.length, techs: Object.values(groups).sort((a, b) => String(a.tech_name).localeCompare(String(b.tech_name))) })
} catch (e) { log('dispatch history error: ' + e.message); return json(res, 500, { error: e.message }) }
}
// GET /dispatch/audit?job=<name>[&tech=TECH-x][&from=ISO][&to=ISO] — journal d'audit d'assignation (qui a eu le job, quand, chaque changement horodaté).
if (sub.startsWith('audit') && method === 'GET') {
const q = require('url').parse(req.url, true).query
const rows = require('./dispatch-audit').read({ job: q.job, tech: q.tech, from: q.from, to: q.to, limit: Math.min(2000, parseInt(q.limit || '500', 10)) })
return json(res, 200, { count: rows.length, rows })
}
// GET /dispatch/leaderboard?from=YYYY-MM-DD&to=YYYY-MM-DD — classement des techs par jobs COMPLÉTÉS (reconnaissance / gamification).
if (sub.startsWith('leaderboard') && method === 'GET') {
try {
const params = require('url').parse(req.url, true).query
const to = params.to || todayET(); const from = params.from || dateAddDays(to, -30)
const filters = [['status', '=', 'Completed'], ['scheduled_date', '>=', from], ['scheduled_date', '<=', to]]
const qs = new URLSearchParams({ filters: JSON.stringify(filters), fields: JSON.stringify(['assigned_tech']), limit_page_length: '5000' })
const r = await erpFetch(`/api/resource/Dispatch Job?${qs}`)
if (r.status !== 200) return json(res, 500, { error: 'ERPNext ' + r.status })
const counts = {}; for (const j of (r.data?.data || [])) { if (j.assigned_tech) counts[j.assigned_tech] = (counts[j.assigned_tech] || 0) + 1 }
const tr = await erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify(['technician_id', 'full_name']))}&limit_page_length=200`)
const names = {}; for (const t of (tr.data?.data || [])) names[t.technician_id] = t.full_name || t.technician_id
const board = Object.keys(counts).map(k => ({ tech: k, tech_name: names[k] || k, completed: counts[k] })).sort((a, b) => b.completed - a.completed)
return json(res, 200, { from, to, board })
} catch (e) { log('dispatch leaderboard error: ' + e.message); return json(res, 500, { error: e.message }) }
}
// 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/occupancy — occupation par ressource (tech) sur l'horizon, filtrée par compétence → bandes « Trouver un créneau »
if (sub === 'occupancy' && method === 'POST') {
try {
const body = await parseBody(req)
return json(res, 200, await techOccupancy(body))
} catch (e) {
log('occupancy error:', e.message)
return json(res, 500, { error: e.message })
}
}
// GET /dispatch/group-jobs?group=Tech+Targo&exclude_tech=TECH-001
// List unassigned jobs that a tech can self-claim. The tech PWA uses this
// to render the "Tâches du groupe" subscription feed.
//
// Filters applied server-side:
// - status in ['open', 'Scheduled'] (no 'On Hold' — those are chain-gated)
// - assigned_tech empty/null
// - optional: assigned_group == group (when provided)
// - exclude current tech so the list only shows claimable work
// Mint un numéro de job standardisé YYYYMMDD-XXX (le front crée directement dans ERPNext → il frappe le numéro ici d'abord).
if (sub === 'next-ref' && method === 'GET') {
return json(res, 200, { ref: await nextJobRef() })
}
// Crée une chaîne d'installation (projet + sous-tâches) en un geste depuis un template.
if (sub === 'create-install' && method === 'POST') {
return json(res, 200, await createInstallChain((await parseBody(req)) || {}))
}
if (sub === 'group-jobs' && method === 'GET') {
try {
const params = require('url').parse(req.url, true).query
const group = params.group || ''
const filters = [
['status', 'in', ['open', 'Scheduled']],
// Frappe API requires `is not set` for empty-link queries (not = '').
['assigned_tech', 'is', 'not set'],
]
if (group) filters.push(['assigned_group', '=', group])
// `_name` fields (customer_name, service_location_name) are "fetched"
// (fetch_from → Customer.customer_name) and Frappe blocks them from
// list queries. Pull the base links here and enrich client-side via
// a second batch query below. We also keep `scheduled_time` out of
// the list query — it's not marked queryable on this doctype — and
// re-add it per-job via the enrichment loop.
const fields = ['name', 'subject', 'customer', 'service_location',
'scheduled_date', 'priority', 'assigned_group',
'job_type', 'duration_h', 'source_issue']
const qs = new URLSearchParams({
filters: JSON.stringify(filters),
fields: JSON.stringify(fields),
limit_page_length: '50',
order_by: 'scheduled_date asc, modified desc',
})
const r = await erpFetch(`/api/resource/Dispatch Job?${qs}`)
if (r.status !== 200) return json(res, 500, { error: 'ERPNext ' + r.status, body: r.data?.exception?.slice(0, 200) })
const jobs = r.data?.data || []
// Enrich with customer_name + service_location_name so the tech PWA can
// render address/customer without N extra round-trips. Parallel fetch
// with a small cache in case multiple jobs share the same customer.
const custNames = new Map()
const locNames = new Map()
await Promise.all(jobs.map(async j => {
if (j.customer && !custNames.has(j.customer)) {
try {
const cr = await erpFetch(`/api/resource/Customer/${encodeURIComponent(j.customer)}?fields=["customer_name"]`)
if (cr.status === 200) custNames.set(j.customer, cr.data?.data?.customer_name || j.customer)
} catch { custNames.set(j.customer, j.customer) }
}
if (j.service_location && !locNames.has(j.service_location)) {
try {
const lr = await erpFetch(`/api/resource/Service%20Location/${encodeURIComponent(j.service_location)}?fields=["address_line_1","city"]`)
if (lr.status === 200) {
const d = lr.data?.data || {}
locNames.set(j.service_location, [d.address_line_1, d.city].filter(Boolean).join(', ') || j.service_location)
}
} catch { locNames.set(j.service_location, j.service_location) }
}
}))
for (const j of jobs) {
j.customer_name = custNames.get(j.customer) || ''
j.service_location_name = locNames.get(j.service_location) || ''
}
return json(res, 200, { jobs })
} catch (e) {
log('group-jobs error:', e.message)
return json(res, 500, { error: e.message })
}
}
// POST /dispatch/claim-job { job, tech_id }
// Tech self-assignment. Accepts only jobs that are truly up for grabs
// (status open/Scheduled + no assigned_tech). Idempotent per-tech: a tech
// re-claiming a job they already own returns 200 (no-op). Another tech
// trying to grab it returns 409 so the UI can refresh.
if (sub === 'claim-job' && method === 'POST') {
try {
const body = await parseBody(req)
if (!body.job || !body.tech_id) return json(res, 400, { error: 'job and tech_id required' })
const jobName = body.job
const techId = body.tech_id
const r = await erpFetch(`/api/resource/Dispatch Job/${encodeURIComponent(jobName)}`)
if (r.status !== 200) return json(res, 404, { error: 'Job not found' })
const job = r.data.data
if (job.assigned_tech && job.assigned_tech !== techId) {
return json(res, 409, { error: 'Déjà pris par un autre technicien', assigned_to: job.assigned_tech })
}
if (job.assigned_tech === techId) {
return json(res, 200, { ok: true, job: jobName, tech: techId, note: 'already yours' })
}
if (!['open', 'Scheduled'].includes(job.status)) {
return json(res, 409, { error: `Statut "${job.status}" — ce travail n'est pas disponible` })
}
const u = await erpFetch(`/api/resource/Dispatch Job/${encodeURIComponent(jobName)}`, {
method: 'PUT',
body: JSON.stringify({ assigned_tech: techId, status: 'assigned' }),
})
if (u.status !== 200) return json(res, 500, { error: 'Update failed', erp: u.data })
// Broadcast so other techs' "Tâches du groupe" feeds refresh and the job
// disappears from their claimable list.
try {
require('./sse').broadcast('dispatch', 'job-claimed', {
job: jobName, tech: techId, subject: job.subject || '',
})
} catch { /* sse best-effort */ }
log(`[dispatch] ${techId} claimed ${jobName}`)
return json(res, 200, { ok: true, job: jobName, tech: techId })
} catch (e) {
log('claim-job 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, address: addressIn, subject, priority, job_type, notes, auto_assign }) {
let address = addressIn || '', 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(', ') || address
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, techOccupancy, unblockDependents, setJobStatusWithChain, activateSubscriptionForJob, deleteJobSafely, nextJobRef, createInstallChain }