gigafibre-fsm/services/targo-hub/lib/dispatch.js
louispaulb 13039be301 feat: suivi généralisé, occupation ressources, horaire tech, revert journal
Follow / subscribe (au-delà des tickets)
- hub: store généralisé /conversations/follow {doctype,name} (follows.json,
  migre ticket_follows.json sous Issue) ; ticket-follow = alias Issue
- InterveneDialog: tuile « Suivre » câblée (toggle + état) ; TicketsPage sync

« Trouver un créneau »
- hub: /dispatch/occupancy (techOccupancy) — occupation par tech filtrée skill
- SuggestSlotsDialog: panneau occupation (bandes verticales) + props de
  pré-remplissage (adresse/coords/skill)
- ClientDetailPage: entrée directe « Trouver un créneau » (appel/réparation)
- fix moteur créneaux: suggestSlots + techOccupancy respectent désormais les
  congés (Tech Availability approuvé), « En pause » et « Archivé »

Horaire par technicien (components/planif/TechScheduleDialog)
- écran unique: pause indéfinie + motif · horaire récurrent (réutilise
  WeeklyScheduleEditor) · calendrier du mois (cases carrées, glisser-
  sélectionner + pinceau congé/maladie/indispo/effacer) · archivage réversible
- hub: /roster/technician/:id/archive (status Archivé) + exclusion au fetch
  (_fetchTechniciansRaw) + /roster/technicians?archived=1 (restauration)
- icône « event » de la rangée tech remplace le bouton pause

Journal des changements (icône history)
- chaque changement de quart révertible individuellement (bouton « annuler »),
  à la façon de la liste « Publier »

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 12:40:22 -04:00

1060 lines
57 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 } = 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_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',
]))}&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) => { if (!wantSkill) return true; return String(t.skills || t._user_tags || '').toLowerCase().split(/[,;]/).some(s => { s = s.trim(); return s && (s === wantSkill || (s.length >= 3 && (s.includes(wantSkill) || wantSkill.includes(s)))) }) }
const techs = (techRes.data.data || []).filter(t => t.status !== 'unavailable' && t.status !== 'Archivé' && t.status !== 'En pause' && 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 — 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 &&
!(ignoreReserved && j.job_type === 'Réservation')) // mode urgence/réparation → les blocs RÉSERVÉS (soft) ne bloquent pas
.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 (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(21, parseInt(days, 10) || SLOT_HORIZON_DAYS))
const dates = Array.from({ length: span }, (_, i) => dateAddDays(baseDate, i))
const wantSkill = String(skill || '').trim().toLowerCase()
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=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', '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) => { if (!wantSkill) return true; return String(t.skills || t._user_tags || '').toLowerCase().split(/[,;]/).some(s => { s = s.trim(); return s && (s === wantSkill || (s.length >= 3 && (s.includes(wantSkill) || wantSkill.includes(s)))) }) }
const techs = (techRes.data.data || []).filter(t => t.status !== 'unavailable' && t.status !== 'Archivé' && t.status !== 'En pause' && 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
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), reserved: j.job_type === 'Réservation' } })
.sort((a, b) => a.start_h - b.start_h)
const clamp = (j) => Math.max(0, Math.min(j.end_h, shift.end_h) - Math.max(j.start_h, shift.start_h))
const busyH = dayJobs.reduce((acc, j) => acc + clamp(j), 0)
const blocks = dayJobs.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,
}))
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: dayJobs.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: wantSkill, 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) || [])) { 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, 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, techOccupancy, unblockDependents, setJobStatusWithChain, activateSubscriptionForJob, deleteJobSafely, nextJobRef, createInstallChain }