gigafibre-fsm/services/targo-hub/lib/roster.js
louispaulb cd25fa2504 fix(dispatch): double-assign F + médias terrain dans le ticket + deep links natifs
- capacityByDay compte les jobs assignés SANS heure (untimed_used_h au prorata AM/PM/Soir)
- /roster/assign-job : garde 409 si le ticket F est déjà à un autre vrai tech (force pour écraser) ; SPA = dialog confirm
- mirrorAssign : start_time depuis F due_time (am/pm) + fix due_date (cur.→r.) + backfill fill-only dans watchLegacy (97% des tickets F = 'day' → le spread capacité est le fix porteur)
- pushAssignments : garde anti-clobber (conflits F skippés, force=1) + compteur conflicts
- ticket-collab : courriels de notification → deep link OPS /#/tickets?open= (fini le desk) ; TicketsPage lit ?open= ; Dashboard ouvre le ticket précis
- DetailModal + SupplierInvoices : lien desk gaté can('manage_settings')
- NOUVEAU JobMediaModule (photos tech géo-vérifiées + journal terrain) monté dans IssueDetail onsite + volet Planif ; hub /roster/job-media + /field/photo-file signé ; JobThread poll silencieux 45s
- legacy-sync emailList : strip caractères invisibles (soft hyphen U+00AD…) — fixait un 417 InvalidEmailAddress en boucle (compte 15988)

Déployé prod 2026-07-18 (hub restart + SPA /opt/ops-app), sondes live OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:02:37 -04:00

2233 lines
170 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'
/**
* roster.js — Planification (« Roster AI »).
*
* Orchestre le solveur d'horaires OR-Tools (service roster-solver) avec les
* données réelles de l'ERPNext de facturation :
* - techniciens HUMAINS : Dispatch Technician (resource_type='human')
* - compétences : tags (_user_tags) du tech
* - disponibilité : status ('En pause'), absence_from/until, Tech Availability (Approuvé)
* - modèles de shifts : Shift Template
* - besoins de couverture : Shift Requirement (→ « dispo vs requis »)
* - assignations : Shift Assignment (statut Proposé/Publié)
*
* Le solveur ne fait QUE proposer ; /publish écrit les Shift Assignment.
* Aucune paie : on planifie + approuve, c'est tout.
*
* Routes (préfixe /roster) :
* GET /roster/technicians → techs humains + skills + indispos
* GET /roster/templates → modèles de shifts
* POST /roster/templates → créer un modèle
* GET /roster/requirements?start=&days= → besoins de couverture
* POST /roster/requirements → créer un besoin
* GET /roster/assignments?start=&days= → assignations existantes
* GET /roster/coverage?start=&days= → dispo vs requis (par besoin)
* POST /roster/generate {start,days,weights} → propose un horaire (n'écrit rien)
* POST /roster/publish {assignments} → écrit les Shift Assignment (Publié)
* POST /roster/availability {…} → demande congé/pause (Tech Availability)
* POST /roster/availability/:name/approve → approuve une demande
* POST /roster/technician/:id/pause {paused,reason} → met/retire un tech en pause
*/
const http = require('http')
const crypto = require('crypto')
const { json, parseBody, log, readJsonFile, writeJsonFile } = require('./helpers')
const erp = require('./erp')
const cfg = require('./config')
const fs = require('fs')
const path = require('path')
const holidaysQc = require('./holidays-qc') // fériés QC déterministes — exception AUTO de la matérialisation d'horaire
const POLICY_FILE = path.join(__dirname, '..', 'data', 'dispatch-policy.json')
// Niveau requis PAR JOB, persistant. (L'API Custom Field d'ERPNext v16 échoue sur ce doctype custom → store hub durable.)
const JOB_LEVELS_FILE = path.join(__dirname, '..', 'data', 'job-levels.json')
function getJobLevels () { try { return JSON.parse(fs.readFileSync(JOB_LEVELS_FILE, 'utf8')) || {} } catch { return {} } }
function setJobLevel (name, level) {
const m = getJobLevels(); const lv = Math.max(0, Math.min(5, Math.round(Number(level) || 0)))
if (lv > 0) m[String(name)] = lv; else delete m[String(name)]
try { fs.mkdirSync(path.dirname(JOB_LEVELS_FILE), { recursive: true }) } catch (e) {}
fs.writeFileSync(JOB_LEVELS_FILE, JSON.stringify(m)); return m
}
// Compétences REQUISES par job = LISTE (store hub durable, comme les niveaux). `required_skill` n'est PAS un champ ERPNext (enrichi).
// Le solveur exige que le tech ait TOUTES les compétences de la liste ; la 1re = principale (affichage/couleur/icône). Vide → repli dépt.
const JOB_SKILLS_FILE = path.join(__dirname, '..', 'data', 'job-skills.json')
function getJobSkillsMap () { try { return JSON.parse(fs.readFileSync(JOB_SKILLS_FILE, 'utf8')) || {} } catch { return {} } }
function getJobSkills (name) { const v = getJobSkillsMap()[String(name)]; return Array.isArray(v) ? v.filter(Boolean) : [] }
function setJobSkills (name, list) {
const m = getJobSkillsMap()
const arr = [...new Set((Array.isArray(list) ? list : String(list || '').split(',')).map(s => String(s).trim()).filter(Boolean))]
if (arr.length) m[String(name)] = arr; else delete m[String(name)]
try { fs.mkdirSync(path.dirname(JOB_SKILLS_FILE), { recursive: true }) } catch (e) {}
fs.writeFileSync(JOB_SKILLS_FILE, JSON.stringify(m)); return m
}
// Priorité de job = champ ERPNext standard (low/medium/high), édité via /roster/job/update (comme le pool). Plus de store parallèle.
// 🖥 Job « SANS DÉPLACEMENT » (configurable à distance, ex. boîtier tel via ACS) : exclu des tournées (aucun arrêt), à faire du bureau.
const JOB_REMOTE_FILE = path.join(__dirname, '..', 'data', 'job-remote.json')
function getJobRemote () { try { return JSON.parse(fs.readFileSync(JOB_REMOTE_FILE, 'utf8')) || {} } catch { return {} } }
function setJobRemote (name, remote) {
const m = getJobRemote()
if (remote) m[String(name)] = 1; else delete m[String(name)]
try { fs.mkdirSync(path.dirname(JOB_REMOTE_FILE), { recursive: true }) } catch (e) {}
fs.writeFileSync(JOB_REMOTE_FILE, JSON.stringify(m)); return m
}
const SOLVER_URL = cfg.ROSTER_SOLVER_URL || 'http://roster-solver:8090'
const PAUSE_STATUS = 'En pause'
const AVAIL_STATUS = 'Disponible'
const ARCHIVE_STATUS = 'Archivé' // (legacy) — l'archivage vit désormais dans un store hub (ERPNext v16 refuse une valeur de statut hors liste sur ce doctype custom → 500)
// Archivage RÉVERSIBLE via store hub durable (pas d'écriture ERP — même raison que job-levels : v16 rejette les valeurs
// de statut hors liste Select sur Dispatch Technician). { [technician_id]: true }. Masque le tech partout, historique conservé.
const ARCHIVED_FILE = path.join(__dirname, '..', 'data', 'archived_techs.json')
function loadArchivedSet () { return new Set((readJsonFile(ARCHIVED_FILE, []) || []).map(String)) }
function saveArchivedSet (set) { writeJsonFile(ARCHIVED_FILE, [...set]) }
// ── Date helpers (local, sans dépendance) ──────────────────────────────────
function iso (d) { return d.toISOString().slice(0, 10) }
function parseISO (s) { const [y, m, dd] = s.split('-').map(Number); return new Date(Date.UTC(y, m - 1, dd)) }
function addDays (d, n) { const r = new Date(d); r.setUTCDate(r.getUTCDate() + n); return r }
function rangeDates (start, days) {
const s = parseISO(start); const out = []
for (let i = 0; i < days; i++) out.push(iso(addDays(s, i)))
return out
}
function splitCsv (s) {
return String(s || '').split(',').map(x => x.trim()).filter(Boolean)
}
// POST au solveur via le module http natif (comme erpFetch — fiable dans le
// process long du hub, contrairement au fetch global undici).
function postSolver (path, body) {
const data = JSON.stringify(body)
const u = new URL(SOLVER_URL + path)
return new Promise((resolve, reject) => {
const req = http.request({
hostname: u.hostname, port: u.port || 80, path: u.pathname + u.search, method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
timeout: 30000,
}, (res) => {
let d = ''
res.on('data', c => { d += c })
res.on('end', () => { try { resolve(JSON.parse(d)) } catch { resolve({ status: 'ERROR', message: 'réponse solveur invalide' }) } })
})
req.on('error', reject)
req.on('timeout', () => { req.destroy(); reject(new Error('solveur: timeout')) })
req.write(data); req.end()
})
}
const OSRM_URL = cfg.OSRM_URL || 'http://osrm:5000'
// Matrice de TEMPS DE ROUTE (minutes) via OSRM /table, dans l'ordre attendu par route_solver : jobs d'abord, PUIS domiciles des véhicules.
// null si un nœud n'a pas de coordonnées → le solveur retombe proprement sur le haversine (pas de dépendance dure à OSRM).
async function osrmMatrix (jobs, vehicles) {
const ok = (a, b) => a != null && b != null && isFinite(+a) && isFinite(+b) && (Math.abs(+a) > 0.01 || Math.abs(+b) > 0.01)
// Ordre des nœuds = celui de route_solver : jobs d'abord, PUIS domiciles des véhicules. null = sans coordonnées.
const nodes = []
for (const j of jobs) nodes.push(ok(j.lat, j.lon) ? [+j.lon, +j.lat] : null)
for (const v of vehicles) nodes.push(ok(v.home_lat, v.home_lon) ? [+v.home_lon, +v.home_lat] : null)
const N = nodes.length
const withIdx = []; for (let i = 0; i < N; i++) if (nodes[i]) withIdx.push(i)
if (withIdx.length < 2) return null // pas assez de points géolocalisés → le solveur retombe sur le haversine
const coords = withIdx.map(i => nodes[i])
const path = '/table/v1/driving/' + coords.map(c => c[0].toFixed(6) + ',' + c[1].toFixed(6)).join(';') + '?annotations=duration'
const u = new URL(OSRM_URL + path)
const raw = await new Promise((resolve, reject) => {
const rq = http.request({ hostname: u.hostname, port: u.port || 80, path: u.pathname + u.search, method: 'GET', timeout: 20000 }, (r) => {
let d = ''; r.on('data', c => { d += c }); r.on('end', () => resolve(d))
})
rq.on('error', reject); rq.on('timeout', () => { rq.destroy(); reject(new Error('osrm timeout')) }); rq.end()
})
const j = JSON.parse(raw)
if (j.code !== 'Ok' || !Array.isArray(j.durations)) throw new Error('osrm code ' + (j.code || '?'))
// Matrice NxN complète en MINUTES : temps de route OSRM pour les paires géolocalisées, 0 (neutre) pour tout nœud sans coords.
const M = Array.from({ length: N }, () => new Array(N).fill(0))
for (let a = 0; a < withIdx.length; a++) for (let b = 0; b < withIdx.length; b++) { const s = j.durations[a][b]; M[withIdx[a]][withIdx[b]] = (s == null ? 0 : Math.round(s / 60 * 10) / 10) }
return M
}
// GET brut vers OSRM (helper des proxys /roster/osrm-*) — remplace les appels Mapbox payants du SPA.
function osrmGet (path) {
const u = new URL(OSRM_URL + path)
return new Promise((resolve, reject) => {
const rq = http.request({ hostname: u.hostname, port: u.port || 80, path: u.pathname + u.search, method: 'GET', timeout: 20000 }, (r) => {
let d = ''; r.on('data', c => { d += c }); r.on('end', () => { try { resolve(JSON.parse(d)) } catch (e) { reject(new Error('osrm: réponse invalide')) } })
})
rq.on('error', reject); rq.on('timeout', () => { rq.destroy(); reject(new Error('osrm timeout')) }); rq.end()
})
}
const osrmPts = (b, max) => { const pts = Array.isArray(b.points) ? b.points.slice(0, max) : []; return (pts.length >= 2 && pts.every(p => Array.isArray(p) && isFinite(+p[0]) && isFinite(+p[1]))) ? pts.map(p => (+p[0]).toFixed(6) + ',' + (+p[1]).toFixed(6)).join(';') : null }
// Matrice OSRM (si dispo) + solveur VRP — brique partagée (endpoint /optimize-routes ET optimizePlan).
async function solveVrp (body) {
if (!body.matrix) {
try { const m = await osrmMatrix(body.jobs, body.vehicles); if (m) { body.matrix = m; body.matrix_source = 'osrm' } }
catch (e) { log('osrm matrix skip: ' + (e && e.message)) }
}
return postSolver('/route', body)
}
// ── P4 — Orchestration d'optimisation COMPLÈTE côté hub (réutilisable : SPA, cron auto-plan matinal, app terrain). ──
// Règles métier incluses :
// • MÊME ADRESSE = 1 ARRÊT : les jobs du pool à la même adresse sont fusionnés en 1 nœud solveur (temps sur place additionnés)
// → même tech, arrêt unique (ex. installation + config boîtier tel au même domicile).
// • COORDINATION : un job du pool à l'adresse d'un job DÉJÀ ASSIGNÉ ce jour-là est ÉPINGLÉ sur CE tech (il y va déjà) —
// ex. « Configuration de boîtier tel » placé sur l'arrêt de l'installateur.
const hmToMin = (t) => { const p = String(t || '').split(':'); const h = Number(p[0]); const m = Number(p[1]) || 0; return isFinite(h) ? h * 60 + m : null }
function addrKeyOf (j) { // clé d'adresse : coords arrondies (~10 m) si dispo, sinon adresse normalisée
const la = Number(j.latitude != null ? j.latitude : j.lat); const lo = Number(j.longitude != null ? j.longitude : j.lon)
if (isFinite(la) && isFinite(lo) && Math.abs(la) > 0.01) return la.toFixed(4) + ',' + lo.toFixed(4)
const a = String(j.address || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').replace(/[^a-z0-9]/g, '')
return a.length >= 6 ? 'a:' + a : null
}
async function optimizePlan (body) {
const start = body.start || new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
const dates = (Array.isArray(body.dates) && body.dates.length ? body.dates.slice(0, 7) : rangeDates(start, Math.max(1, Math.min(7, Number(body.days) || 2)))).sort()
const lo = dates[0]; const hi = dates[dates.length - 1]
const span = Math.round((Date.parse(hi) - Date.parse(lo)) / 86400000) + 1
const opts = body.opts || {}
const durOv = body.dur_overrides || {}
const winOv = body.job_windows || {} // {jobName:{tw_start_min,tw_end_min}} (AM/PM)
const wantTech = Array.isArray(body.tech_ids) && body.tech_ids.length ? new Set(body.tech_ids) : null
const wantJobs = Array.isArray(body.job_names) && body.job_names.length ? new Set(body.job_names) : null // ne répartir QUE ces jobs (jours cochés / sélection UI)
const [pool, techsAll, templates, assignments, occ, absences] = await Promise.all([
buildUnassigned(), fetchTechnicians(), fetchTemplates(), fetchAssignments(lo, span), occupancyByTechDay(lo, span), absencesByTechDay(lo, span),
])
const tplBy = Object.fromEntries(templates.map(t => [t.name, t]))
const winBy = {} // techId|iso → fenêtre de quart RÉELLE (min)
for (const a of assignments) {
const tp = tplBy[a.shift]; if (!tp || tp.on_call) continue
const s = hmToMin(tp.start_time); let e = hmToMin(tp.end_time); if (s == null || e == null) continue
if (e <= s) e = 24 * 60
const k = a.tech + '|' + a.date
const w = winBy[k]; winBy[k] = w ? { start: Math.min(w.start, s), end: Math.max(w.end, e) } : { start: s, end: e }
}
let policy = {}; try { policy = JSON.parse(fs.readFileSync(POLICY_FILE, 'utf8')) } catch (e) {}
const homes = policy.tech_homes || {}; const depot = policy.depot || null
const originOf = (tid) => { const h = homes[tid]; if (h && isFinite(+h.lat) && isFinite(+h.lon)) return { lat: +h.lat, lon: +h.lon }; if (depot && isFinite(+depot.lat) && isFinite(+depot.lon)) return { lat: +depot.lat, lon: +depot.lon }; return null }
const isWE = iso => { const d = new Date(iso + 'T12:00:00Z').getUTCDay(); return d === 0 || d === 6 }
const durOf = j => { const o = Number(durOv[j.name]); if (o > 0) return o; return j.est_min ? j.est_min / 60 : (Number(j.duration_h) || 1) }
const prBoost = p => (p === 'urgent' || p === 'high') ? 400000 : (p === 'low' ? -60000 : 0)
const prEarly = p => (p === 'urgent' || p === 'high') ? 10 : 0
const techs = techsAll.filter(t => !wantTech || wantTech.has(t.id))
// Compétences requises d'un job (LISTE) ; `required_skill` = principale (1re). techCovers = le tech les a TOUTES.
const skillsOf = j => (Array.isArray(j.required_skills) && j.required_skills.length) ? j.required_skills.filter(Boolean) : (j.required_skill ? [j.required_skill] : [])
const techCovers = (t, j) => { const req = skillsOf(j); return !req.length || (t && req.every(s => (t.skills || []).includes(s))) }
const entryOf = (j, iso, dur) => ({
jobName: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || j.location_label || '',
iso, dur: Math.round(dur * 100) / 100, skill: j.required_skill || '', skills: skillsOf(j), reqLevel: Number(j.required_level) || 1,
lat: (j.latitude != null && isFinite(+j.latitude)) ? +j.latitude : null, lon: (j.longitude != null && isFinite(+j.longitude)) ? +j.longitude : null,
priority: String(j.priority || '').toLowerCase(), remote: !!j.remote, parent: j.parent_job || null,
})
// Bucket par jour : daté dans la fenêtre → son jour ; daté APRÈS → laissé pour plus tard ; en retard/sans date → 1er jour (déborde vers le suivant si non casé).
// 🖥 Les jobs SANS DÉPLACEMENT (remote) sont EXCLUS des tournées : aucun arrêt, aucun épinglage d'adresse — à faire du bureau.
const dset = new Set(dates); const byDay = {}; const skipped = []; const remote = []
for (const j of pool) {
if (wantJobs && !wantJobs.has(j.name)) continue // restreint aux jobs demandés (chips de date / sélection)
const sched = (j.scheduled_date && /^\d{4}-\d{2}-\d{2}$/.test(String(j.scheduled_date))) ? j.scheduled_date : null
if (sched && sched > hi) { skipped.push(j.name); continue }
const iso = (sched && dset.has(sched)) ? sched : dates[0]
if (j.remote) { remote.push(entryOf(j, iso, durOf(j))); continue }
;(byDay[iso] = byDay[iso] || []).push(j)
}
const plan = []; const unassigned = []; const assumedShift = {}
let spill = []
for (const iso of dates) {
const dayJobs = [...(byDay[iso] || []), ...spill]; spill = []
if (!dayJobs.length) continue
// Occupation existante : minutes déjà prises par tech + index adresse→tech (coordination)
const usedMin = {}; const addrTech = {}
for (const t of techs) {
const o = occ[t.id + '|' + iso]; if (!o) continue
let mins = 0
for (const ej of (o.jobs || [])) {
if (ej.cancelled) continue
mins += Math.round((Number(ej.dur) || (ej.est_min ? ej.est_min / 60 : 1)) * 60)
const k = addrKeyOf(ej); if (k && !addrTech[k]) addrTech[k] = { techId: t.id, techName: t.name, subject: ej.subject }
}
if (mins) usedMin[t.id] = mins
}
// ÉPINGLAGE coordination : même adresse qu'un job DÉJÀ assigné ce jour → ce tech (capable ou pas : on SIGNALE, on ne bloque pas — même camion)
const rest = []
for (const j of dayJobs) {
const k = addrKeyOf(j); const hit = k && addrTech[k]
if (hit) {
const t = techs.find(x => x.id === hit.techId)
const dur = durOf(j)
plan.push({ ...entryOf(j, iso, dur), techId: hit.techId, techName: hit.techName, pinned: true, coordWith: hit.subject, capable: techCovers(t, j) })
usedMin[hit.techId] = (usedMin[hit.techId] || 0) + Math.round(dur * 60)
} else rest.push(j)
}
// Véhicules : quart réel, sinon 8-16 assumé en SEMAINE (créé au Publier côté UI) ; jamais le week-end ; absents exclus.
const vehicles = []
for (const t of techs) {
if (absences[t.id + '|' + iso]) continue
let w = winBy[t.id + '|' + iso] || null
if (!w) { if (isWE(iso)) continue; w = { start: 480, end: 960 }; assumedShift[t.id + '|' + iso] = true }
const used = usedMin[t.id] || 0
const o = originOf(t.id)
vehicles.push({ id: t.id, name: t.name, skills: t.skills || [], skill_eff: t.skill_eff || {}, efficiency: Number(t.efficiency) || 1, home_lat: o ? o.lat : null, home_lon: o ? o.lon : null, shift_start_min: Math.min(w.end - 15, w.start + used), shift_end_min: w.end, overtime_min: Math.round(0.2 * (w.end - w.start)) })
}
if (!vehicles.length) { for (const j of rest) unassigned.push(entryOf(j, iso, durOf(j))); continue }
// MÊME ADRESSE = 1 ARRÊT : fusion des jobs du pool par clé d'adresse → 1 nœud (temps additionnés, skill = 1re non vide du groupe)
const gmap = new Map(); const units = []
for (const j of rest) { const k = addrKeyOf(j); if (!k) { units.push([j]); continue } const g = gmap.get(k); if (g) g.push(j); else { const ng = [j]; gmap.set(k, ng); units.push(ng) } }
const sJobs = units.map((g, i) => {
const j0 = g[0]
const w = winOv[(g.find(x => winOv[x.name]) || {}).name]
const prios = g.map(j => String(j.priority || '').toLowerCase())
return {
id: 'u' + i,
lat: (j0.latitude != null && isFinite(+j0.latitude)) ? +j0.latitude : null,
lon: (j0.longitude != null && isFinite(+j0.longitude)) ? +j0.longitude : null,
service_min: Math.max(5, Math.round(g.reduce((s, j) => s + durOf(j), 0) * 60)),
skill: (g.find(j => j.required_skill) || {}).required_skill || '', // principale (rang spécialiste)
skills: [...new Set(g.flatMap(j => skillsOf(j)))], // UNION des compétences requises du groupe → le tech doit TOUTES les avoir
priority_boost: Math.max(...prios.map(prBoost)),
urgent_weight: Math.max(...prios.map(prEarly)),
...(w ? { tw_start_min: w.tw_start_min, tw_end_min: w.tw_end_min } : {}),
}
})
const res = await solveVrp({ jobs: sJobs, vehicles, max_seconds: Number(opts.max_seconds) || 8, rank_weight: opts.rank_weight != null ? Number(opts.rank_weight) : 20, versatility_weight: opts.versatility_weight != null ? Number(opts.versatility_weight) : 3, speed_kmh: Number(opts.speed_kmh) || 45, overtime_coef: opts.overtime_coef != null ? Number(opts.overtime_coef) : 20 })
if (!res || res.status !== 'OK') { for (const j of rest) unassigned.push(entryOf(j, iso, durOf(j))); continue }
const placed = new Set()
for (const rt of (res.routes || [])) {
const t = techs.find(x => x.id === rt.vehicle) || { id: rt.vehicle, name: rt.vehicle_name, skills: [] }
for (const st of (rt.stops || [])) {
const ui = Number(String(st.job_id).slice(1)); const g = units[ui]; if (!g) continue
placed.add(st.job_id)
for (const j of g) plan.push({ ...entryOf(j, iso, durOf(j)), techId: t.id, techName: t.name, capable: techCovers(t, j), sameAddrN: g.length })
}
}
for (const uid of (res.unassigned || [])) {
if (placed.has(uid)) continue
const g = units[Number(String(uid).slice(1))]; if (!g) continue
if (iso !== hi) spill.push(...g) // retenté le jour suivant
else for (const j of g) unassigned.push(entryOf(j, iso, durOf(j)))
}
}
for (const j of spill) unassigned.push(entryOf(j, dates[dates.length - 1], durOf(j))) // sécurité (spill après le dernier jour)
return { ok: true, dates, plan, unassigned, remote, assumed_shift: assumedShift, skipped_later: skipped.length }
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
// Réessai des écritures ERPNext. Le shim frappe_pg tourne en SERIALIZABLE → sur
// les lignes chaudes (Dispatch Technician maj en continu par le GPS/dispatch) un
// SELECT…FOR UPDATE peut lever "could not serialize access due to concurrent
// update" (HTTP 500). La requête a été rollback → réessayer est sûr (idempotent).
async function retryWrite (fn, tries = 5) {
let r
for (let i = 0; i < tries; i++) {
r = await fn()
if (r.ok || (r.status && r.status < 500)) return r
await sleep(120 * (i + 1))
}
return r
}
// ── Lecture des techniciens humains + compétences + indisponibilités ────────
// Cache court de la liste des techs : appelée souvent (endpoint /roster/technicians, generate, materialize, tech-positions
// polling 25 s…) pour des données quasi statiques. TTL 30 s → coupe les hits ERPNext répétés. Staleness acceptable
// (les éditions inline sont optimistes côté UI, pas de re-fetch immédiat). invalidateTechCache() dispo après écriture.
let _techCache = { ts: 0, data: null }
function invalidateTechCache () { _techCache.ts = 0 }
async function fetchTechnicians () {
if (_techCache.data && (nowMs() - _techCache.ts) < 30000) return _techCache.data
const data = await _fetchTechniciansRaw()
_techCache = { ts: nowMs(), data }
return data
}
async function _fetchTechniciansRaw () {
const rows = await erp.list('Dispatch Technician', {
filters: [['resource_type', '=', 'human']],
fields: ['name', 'technician_id', 'full_name', 'status', 'color_hex', 'tech_group', 'efficiency', 'skills',
'cost_salary_h', 'cost_charges_pct', 'cost_other_h', 'traccar_device_id',
'absence_from', 'absence_until', 'employee', 'phone', '_user_tags', 'skill_levels', 'skill_eff', 'weekly_schedule'],
limit: 500,
})
const arch = loadArchivedSet() // ids archivés (store hub) → masqués partout
return rows.map(t => ({
id: t.technician_id || t.name,
name: t.full_name || t.technician_id,
status: t.status,
group: t.tech_group || '',
efficiency: Number(t.efficiency) || 1,
cost_salary_h: Number(t.cost_salary_h) || 0,
cost_charges_pct: Number(t.cost_charges_pct) || 0,
cost_other_h: Number(t.cost_other_h) || 0,
cost_h: Math.round(((Number(t.cost_salary_h) || 0) * (1 + (Number(t.cost_charges_pct) || 0) / 100) + (Number(t.cost_other_h) || 0)) * 100) / 100,
color: t.color_hex || '#1976d2',
phone: t.phone,
traccar_device_id: t.traccar_device_id || '', // appareil Traccar associé (pour GPS live + tracé) — éditable inline
employee: t.employee,
skills: splitCsv(t.skills || t._user_tags), // champ skills (ou tags Frappe)
skill_levels: (() => { try { return JSON.parse(t.skill_levels || '{}') } catch { return {} } })(), // {compétence: niveau 15}
skill_eff: (() => { try { return JSON.parse(t.skill_eff || '{}') } catch { return {} } })(), // {compétence: facteur d'efficacité (vitesse)}
absence_from: t.absence_from,
absence_until: t.absence_until,
weekly_schedule: (() => { try { return JSON.parse(t.weekly_schedule || 'null') } catch { return null } })(), // patron récurrent {mon:{start,end}|null,…} — source des quarts matérialisés (hybride)
})).filter(t => !arch.has(t.id)) // techs archivés (store hub) = masqués partout (planif, solveur, stats) ; restaurables via /roster/technicians?archived=1
}
// Techs ARCHIVÉS uniquement (pour l'UI de restauration) — tous les humains dont l'id est dans le store d'archivage.
async function fetchArchivedTechnicians () {
const arch = loadArchivedSet(); if (!arch.size) return []
const rows = await erp.list('Dispatch Technician', {
filters: [['resource_type', '=', 'human']],
fields: ['name', 'technician_id', 'full_name', 'status', 'absence_reason'], limit: 500,
})
return rows.map(t => ({ id: t.technician_id || t.name, name: t.full_name || t.technician_id, status: t.status, reason: t.absence_reason || '' }))
.filter(t => arch.has(t.id))
}
// Construit, pour chaque tech, la liste des dates indisponibles dans l'horizon.
async function buildUnavailability (techs, dateList) {
const start = dateList[0]
const end = dateList[dateList.length - 1]
const byTech = {}
for (const t of techs) byTech[t.id] = new Set()
// 1) status « En pause » → indispo sur tout l'horizon (pause active)
for (const t of techs) {
if (t.status === PAUSE_STATUS) dateList.forEach(d => byTech[t.id].add(d))
// 2) fenêtre d'absence du Dispatch Technician
if (t.absence_from && t.absence_until) {
for (const d of dateList) if (d >= t.absence_from && d <= t.absence_until) byTech[t.id].add(d)
}
}
// 3) Tech Availability approuvées qui chevauchent l'horizon
const avs = await erp.list('Tech Availability', {
filters: [['status', '=', 'Approuvé'], ['from_date', '<=', end], ['to_date', '>=', start]],
fields: ['technician', 'from_date', 'to_date', 'availability_type'],
limit: 500,
})
for (const a of avs) {
if (!byTech[a.technician]) continue
for (const d of dateList) if (d >= a.from_date && d <= a.to_date) byTech[a.technician].add(d)
}
return byTech
}
// ── Modèles + besoins ───────────────────────────────────────────────────────
async function fetchTemplates () {
const rows = await erp.list('Shift Template', {
filters: [['active', '=', 1]],
fields: ['name', 'template_name', 'start_time', 'end_time', 'hours', 'color', 'zone', 'default_required', 'required_skills', 'on_call'],
limit: 100,
})
return rows
}
async function fetchRequirements (start, days) {
const dates = rangeDates(start, days)
return erp.list('Shift Requirement', {
filters: [['requirement_date', 'in', dates]],
fields: ['name', 'requirement_date', 'shift_template', 'zone', 'required_count', 'required_skills'],
limit: 1000,
})
}
async function fetchAssignments (start, days) {
const dates = rangeDates(start, days)
const rows = await erp.list('Shift Assignment', {
filters: [['assignment_date', 'in', dates]],
fields: ['name', 'technician', 'technician_name', 'assignment_date', 'shift_template', 'zone', 'hours', 'status', 'source'],
limit: 2000,
})
// Normaliser vers la forme canonique {tech, date, shift} (= sortie du solveur + UI)
return rows.map(r => ({
name: r.name, tech: r.technician, tech_name: r.technician_name, date: r.assignment_date,
shift: r.shift_template, zone: r.zone, hours: r.hours, status: r.status, source: r.source,
}))
}
// ── HYBRIDE : matérialisation des quarts à partir du PATRON récurrent (weekly_schedule) de chaque tech ──
// Le patron est la SOURCE ; on génère des Shift Assignment (source='pattern') sur l'horizon. Exceptions AUTO :
// fériés (holidays-qc) + vacances (Tech Availability) → pas de quart (et retrait d'un quart pattern existant).
// PRÉSERVE les quarts manuels (source≠'pattern') = overrides (ex. semaine de nuit). Idempotent (re-lançable / cron).
const DOW_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
const _timeH = (s) => { const [h, m] = String(s || '').split(':').map(Number); return (h || 0) + (m || 0) / 60 }
const _timeStr = (s) => (String(s || '').length === 5 ? s + ':00' : String(s || ''))
async function ensureShiftTemplate (start, end, cache) { // start/end 'HH:MM' → nom de doc Shift Template (créé si absent)
const key = start + '-' + end; if (cache[key]) return cache[key]
const st = _timeStr(start), et = _timeStr(end)
try { const rows = await erp.list('Shift Template', { filters: [['start_time', '=', st], ['end_time', '=', et], ['on_call', '=', 0]], fields: ['name'], limit: 1 }); if (rows && rows[0]) { cache[key] = rows[0].name; return rows[0].name } } catch (e) {}
const hours = Math.round((_timeH(end) - _timeH(start)) * 100) / 100
const r = await retryWrite(() => erp.create('Shift Template', { template_name: start + 'h' + end + 'h', start_time: st, end_time: et, hours, on_call: 0, default_required: 1, color: '#1976d2' }))
const name = (r && (r.name || (r.data && r.data.name))) || null
if (name) cache[key] = name
else log('[materialize] ensureShiftTemplate FAIL ' + key + ' → ' + JSON.stringify(r).slice(0, 220))
return name
}
async function materializeShifts ({ dryRun = true, weeks = 4, techId = null } = {}) {
const weeksN = Math.max(1, Math.min(12, Number(weeks) || 4))
const start = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
const days = weeksN * 7
const dates = rangeDates(start, days)
const [techsAll, existing, absences] = await Promise.all([fetchTechnicians(), fetchAssignments(start, days), absencesByTechDay(start, days)])
const techs = techsAll.filter(t => t.status !== PAUSE_STATUS && t.weekly_schedule && (!techId || t.id === techId))
const rowsByKey = {}; for (const a of existing) { (rowsByKey[a.tech + '|' + a.date] = rowsByKey[a.tech + '|' + a.date] || []).push(a) }
const tplCache = {}
let created = 0, updated = 0, deleted = 0, keptManual = 0, skipHoliday = 0, skipVacation = 0, tplNull = 0, createFail = 0
log('[materialize] start=' + start + ' days=' + days + ' techs=' + techs.length + '/' + techsAll.length + ' avec_patron dryRun=' + dryRun)
for (const t of techs) {
const sched = t.weekly_schedule || {}
for (const iso of dates) {
const day = sched[DOW_KEYS[new Date(iso + 'T12:00:00Z').getUTCDay()]] // {start,end} ou null/absent
const key = t.id + '|' + iso; const rows = rowsByKey[key] || []
if (rows.some(r => r.source !== 'pattern')) { keptManual++; continue } // override manuel présent → intouchable
const pat = rows.filter(r => r.source === 'pattern')
const holiday = holidaysQc.isHoliday(iso); const vacation = !!absences[key]
const wants = !!(day && day.start && day.end) && !holiday && !vacation
if (!wants) { // férié / vacances / jour OFF du patron → retirer le quart pattern
if (holiday) skipHoliday++; else if (vacation) skipVacation++
for (const r of pat) { if (dryRun) deleted++; else { const rr = await erp.remove('Shift Assignment', r.name); if (rr && rr.ok) deleted++ } }
continue
}
const tplName = await ensureShiftTemplate(day.start, day.end, tplCache); if (!tplName) { tplNull++; continue }
if (pat.some(r => r.shift === tplName)) continue // déjà bon (idempotent)
const hours = Math.round((_timeH(day.end) - _timeH(day.start)) * 100) / 100
if (pat.length) { // patron changé → mettre à jour le quart pattern (+ nettoyer doublons)
if (dryRun) updated++; else { const rr = await erp.update('Shift Assignment', pat[0].name, { shift_template: tplName, hours }); if (rr && rr.ok) updated++ }
for (const extra of pat.slice(1)) { if (!dryRun) await erp.remove('Shift Assignment', extra.name) }
} else if (dryRun) created++
else { const rr = await retryWrite(() => erp.create('Shift Assignment', { technician: t.id, technician_name: t.name, assignment_date: iso, shift_template: tplName, hours, status: 'Publié', source: 'pattern' })); if (rr && rr.ok) created++; else { createFail++; if (createFail <= 3) log('[materialize] SA create FAIL ' + t.name + ' ' + iso + ' → ' + JSON.stringify(rr).slice(0, 220)) } }
}
}
const result = { ok: true, dryRun, weeks: weeksN, techs: techs.length, created, updated, deleted, kept_manual: keptManual, skipped_holiday: skipHoliday, skipped_vacation: skipVacation, tpl_null: tplNull, create_fail: createFail }
log('[materialize] done ' + JSON.stringify(result))
return result
}
// CRON : matérialise les quarts récurrents (weekly_schedule → Shift Assignment) sur un HORIZON GLISSANT.
// Sans ça, l'horaire récurrent n'existe que pour la fenêtre capturée au clic (= le bug « n'apparaît pas dans le roster »).
// Idempotent (saute les lignes pattern déjà bonnes, respecte les overrides manuels). Désactivable : SHIFT_MATERIALIZE_CRON=off.
let _matTimer = null
function startShiftMaterializer () {
if (String(process.env.SHIFT_MATERIALIZE_CRON || 'on').toLowerCase() === 'off') { log('[materialize] cron désactivé (SHIFT_MATERIALIZE_CRON=off)'); return }
const run = () => materializeShifts({ dryRun: false, weeks: 6 })
.then(r => log('[materialize] cron ' + JSON.stringify({ techs: r.techs, created: r.created, updated: r.updated, deleted: r.deleted, fail: r.create_fail })))
.catch(e => log('[materialize] cron error: ' + e.message))
setTimeout(run, 90 * 1000) // 1er passage ~90 s après le boot (ERPNext prêt)
_matTimer = setInterval(run, 24 * 60 * 60 * 1000) // quotidien → horizon 6 semaines qui avance
log('[materialize] cron armé (quotidien, horizon 6 semaines)')
}
// ── Construit le payload du solveur + l'appelle ─────────────────────────────
async function generate (start, days, weights) {
const dateList = rangeDates(start, days)
// Séquentiel volontaire : le backend frappe (peu de workers) reset des
// connexions sous rafale concurrente → erp.list renvoie [] par intermittence.
const techs = await fetchTechnicians()
const templates = await fetchTemplates()
const requirements = await fetchRequirements(start, days)
const unavail = await buildUnavailability(techs, dateList)
const shift_templates = templates.map(t => ({
id: t.name, name: t.template_name || t.name,
hours: Number(t.hours) || 8,
}))
const technicians = techs.map(t => ({
id: t.id, name: t.name, skills: t.skills,
max_hours_week: 40, max_days: 5, cost_per_h: t.cost_h || 0,
zone_home: null, preferred_off: [], time_factor: t.efficiency || 1,
unavailable: [...unavail[t.id]],
}))
const coverage = requirements.map(r => ({
date: r.requirement_date, shift: r.shift_template, zone: r.zone || '—',
required: Number(r.required_count) || 1,
required_skills: splitCsv(r.required_skills),
}))
const payload = { horizon: { start, days }, shift_templates, technicians, coverage, weights: weights || undefined, max_seconds: 12 }
const result = await postSolver('/solve', payload)
// enrichir avec le nom + couleur pour l'UI
const nameById = Object.fromEntries(techs.map(t => [t.id, t.name]))
const colorByTpl = Object.fromEntries(templates.map(t => [t.name, t.color || '#1976d2']))
for (const a of (result.assignments || [])) {
a.tech_name = nameById[a.tech] || a.tech
a.color = colorByTpl[a.shift] || '#1976d2'
}
return { ...result, counts: { technicians: technicians.length, templates: shift_templates.length, requirements: coverage.length } }
}
// Écrit les assignations retenues comme Shift Assignment (Publié).
async function publish (assignments) {
const created = []; const errors = []
for (const a of assignments || []) {
const r = await retryWrite(() => erp.create('Shift Assignment', {
technician: a.tech, technician_name: a.tech_name || '',
assignment_date: a.date, shift_template: a.shift, zone: a.zone || '',
hours: Number(a.hours) || 0, status: 'Publié', source: a.source || 'solveur',
}))
if (r.ok) created.push(r.name); else errors.push({ a, error: r.error })
}
return { ok: errors.length === 0, created: created.length, errors }
}
// dispo vs requis : pour chaque besoin, compte les assignations publiées correspondantes
async function coverage (start, days) {
const reqs = await fetchRequirements(start, days)
const asgs = await fetchAssignments(start, days)
const key = (d, s, z) => `${d}|${s}|${z || '—'}`
const counts = {}
for (const a of asgs) {
if (a.status === 'Annulé') continue
counts[key(a.date, a.shift, a.zone)] = (counts[key(a.date, a.shift, a.zone)] || 0) + 1
}
return reqs.map(r => {
const assigned = counts[key(r.requirement_date, r.shift_template, r.zone)] || 0
const required = Number(r.required_count) || 0
return { date: r.requirement_date, shift: r.shift_template, zone: r.zone || '—', required, assigned, shortfall: Math.max(0, required - assigned) }
})
}
// ── Prise de RDV : disponibilité consciente du roster ──────────────────────
// Renvoie les fenêtres libres où un tech EN SHIFT publié ce jour-là, avec la
// compétence requise, est disponible (trous dans son shift moins les jobs déjà
// pointés). Sert aux 2 canaux : on propose au client, ou on valide son choix.
function timeToH (t) { if (!t) return 0; const [h, m] = String(t).split(':').map(Number); return (h || 0) + (m || 0) / 60 }
function nowFrappe () { return new Date().toLocaleString('sv-SE', { timeZone: 'America/Toronto' }).replace('T', ' ') } // "YYYY-MM-DD HH:MM:SS"
function minutesBetween (a, b) { if (!a || !b) return null; const ms = Date.parse(String(b).replace(' ', 'T')) - Date.parse(String(a).replace(' ', 'T')); return isFinite(ms) ? Math.max(0, Math.round(ms / 60000)) : null }
function hToTime (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') }
// ── DURÉE D'UNE JOB = modèle ADDITIF par caractéristiques (hors transport) ───────────────────────────────
// Base (1) + Σ caractéristiques cochées (×qté), × vitesse tech. Table ÉDITABLE (tableur inline Ops), persistée en JSON.
// `minutes` = seed (réglé à la main, inspiré du tableur dispatch) ; `learned_min`+`samples` = calibration future par
// régression sur les durées RÉELLES (geofence/checkpoints) — « triangulation » des temps par caractéristique.
const JOBCHAR_PATH = '/app/data/job-characteristics.json'
const JOBCHAR_SEED = { items: [
{ id: 'install_resid', cat: 'base', label: 'Installation fibre résidentielle', minutes: 120, per_qty: false, keywords: 'installation,raccordement' },
{ id: 'install_comm', cat: 'base', label: 'Installation commerciale', minutes: 240, per_qty: false, keywords: 'commercial' },
{ id: 'predrop', cat: 'base', label: 'Pré-drop (pose)', minutes: 120, per_qty: false, keywords: 'pre-drop,predrop,pré-drop' },
{ id: 'repar_fibre', cat: 'base', label: 'Réparation / bris de fibre', minutes: 105, per_qty: false, keywords: 'réparation,reparation,bris,fibre' },
{ id: 'depan_wifi', cat: 'base', label: 'Dépannage Wifi / réseau', minutes: 90, per_qty: false, keywords: 'wifi,sans-fil,internet' },
{ id: 'changer_secteur', cat: 'base', label: 'Changer de secteur', minutes: 75, per_qty: false, keywords: 'secteur' },
{ id: 'retrait', cat: 'base', label: 'Retrait / désinstallation', minutes: 60, per_qty: false, keywords: 'retrait,désinstall,desinstall' },
{ id: 'chgmt_equip', cat: 'base', label: "Changement d'équipement (ONU/modem/Hx)", minutes: 45, per_qty: false, keywords: 'changement,onu,modem,équipement,equipement,hx' },
{ id: 'migration_tv', cat: 'base', label: 'Migration TV / boîtier', minutes: 45, per_qty: false, keywords: 'migration,tv,boîtier,boitier' },
{ id: 'ata', cat: 'base', label: 'Activation téléphonie (ATA)', minutes: 45, per_qty: false, keywords: 'ata,téléphonie,telephonie' },
{ id: 'enfoui', cat: 'addon', label: 'Drop enfoui (au lieu daérien)', minutes: 75, per_qty: false, keywords: 'enfoui,enterré,enterre,souterrain' },
{ id: 'echelle', cat: 'addon', label: 'Aérien avec échelle / poteau', minutes: 30, per_qty: false, keywords: 'aérien,aerien,poteau,échelle,echelle' },
{ id: 'dist_poteau', cat: 'addon', label: 'Distance poteau → maison > 60 m', minutes: 30, per_qty: false, keywords: '' },
{ id: 'boitier_tv', cat: 'addon', label: 'Boîtier TV / STB supplémentaire', minutes: 30, per_qty: true, keywords: 'stb,boîtier tv,boitier tv' },
{ id: 'mesh', cat: 'addon', label: "Point d'accès mesh supplémentaire", minutes: 20, per_qty: true, keywords: 'mesh,borne' },
{ id: 'tel_ata', cat: 'addon', label: 'Ligne téléphonie / ATA ajoutée', minutes: 30, per_qty: true, keywords: '' },
{ id: 'epissure', cat: 'addon', label: 'Épissure / fusion (par connecteur)', minutes: 20, per_qty: true, keywords: 'épissure,epissure,fusion' },
{ id: 'wifi_complexe', cat: 'addon', label: 'Config Wifi complexe (multi-SSID/VLAN)', minutes: 30, per_qty: false, keywords: '' },
{ id: 'tel_complexe', cat: 'addon', label: 'Téléphonie complexe (PBX/multi-lignes)', minutes: 30, per_qty: false, keywords: 'pbx' },
{ id: 'test_otdr', cat: 'addon', label: 'Test / certification (OTDR, photos)', minutes: 20, per_qty: false, keywords: 'otdr,certification' },
{ id: 'acces_difficile', cat: 'addon', label: 'Accès difficile (hauteur, vide sanitaire)', minutes: 30, per_qty: false, keywords: '' },
{ id: 'tampon_client', cat: 'modifier', label: 'Tampon coordination client', minutes: 15, per_qty: false, keywords: '' },
{ id: 'junior_install', cat: 'modifier', label: 'Tech junior sur install (pénalité)', minutes: 90, per_qty: false, keywords: '' },
] }
function readJobChar () { try { return JSON.parse(require('fs').readFileSync(JOBCHAR_PATH, 'utf8')) } catch (e) { return JSON.parse(JSON.stringify(JOBCHAR_SEED)) } }
function writeJobChar (items) { try { require('fs').writeFileSync(JOBCHAR_PATH, JSON.stringify({ items, updated: nowFrappe() }, null, 2)); return true } catch (e) { return false } }
// Durée estimée (minutes, hors transport) à partir des caractéristiques cochées : picks = [{id, qty}].
function estimateMinutes (picks, items) {
const by = {}; for (const i of (items || readJobChar().items)) by[i.id] = i
let m = 0; for (const p of (picks || [])) { const it = by[p.id]; if (!it) continue; const mn = (it.learned_min != null ? it.learned_min : it.minutes) || 0; m += mn * (it.per_qty ? (Number(p.qty) || 1) : 1) }
return Math.round(m)
}
// Auto-détection DÉTERMINISTE (mots-clés / regex, AUCUNE IA) des caractéristiques d'un job depuis son texte.
function detectCharacteristics (job, items) {
const strip = s => String(s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '')
const text = strip([job.subject, job.legacy_detail, job.legacy_dept, job.job_type, job.service_type].join(' '))
const hit = kw => String(kw || '').split(',').map(x => strip(x).trim()).filter(Boolean).some(k => new RegExp('\\b' + k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b').test(text)) // limites de mots → pas de faux positif « ap » dans « après »
const bases = items.filter(i => i.cat === 'base')
let base = bases.find(b => hit(b.keywords))
if (!base) { const jt = strip(job.job_type); base = bases.find(b => (jt.includes('install') && b.id === 'install_resid') || (jt.includes('repar') && b.id === 'repar_fibre') || (jt.includes('retrait') && b.id === 'retrait')) }
const picks = base ? [{ id: base.id, qty: 1 }] : []
for (const a of items.filter(i => i.cat === 'addon')) if (hit(a.keywords)) picks.push({ id: a.id, qty: 1 })
return picks
}
// Sites PRÉ-CÂBLÉS : une INSTALLATION y prend ~¼ du temps normal (infra déjà en place → branchement rapide).
const FAST_INSTALL_SITES = ['lac des pins', 'camping', 'domaine dauphinais']
function estimateForJob (job, items) {
items = items || readJobChar().items
const picks = detectCharacteristics(job, items); const by = {}; for (const i of items) by[i.id] = i
let minutes = estimateMinutes(picks, items)
const labels = picks.map(p => (by[p.id] || {}).label).filter(Boolean)
// Installation à un site pré-câblé → temps /4 (Lac des pins / Camping / Domaine Dauphinais).
const isInstall = picks.some(p => String(p.id).startsWith('install')) || String(job.required_skill || '').toLowerCase().includes('install')
if (isInstall && minutes > 0) {
const loc = [job.address, job.location_label, job.service_location, job.subject, job.municipalite].join(' ').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '')
if (FAST_INSTALL_SITES.some(s => loc.includes(s))) { minutes = Math.max(15, Math.round(minutes / 4)); labels.push('site pré-câblé ×¼') }
}
return { minutes, picks, labels }
}
// ── #56 Politique de créneaux offerts + holds temporaires ────────────────────
// Persistée dans le même fichier que la politique de reprise (sous-objet `booking`),
// éditée via /roster/policy (lib/roster-assistant.js). Appliquée ici à TOUTE source
// de créneaux (page /book, vue agent, fit) → comportement cohérent partout.
const BOOKING_DEFAULTS = { lead_hours: 24, day_start: 8, day_end: 18, days_offered: [1, 2, 3, 4, 5], horizon_days: 21, max_per_day: 0, hold_minutes: 10, skill_by_type: {} }
function getBookingPolicy () {
try { const p = JSON.parse(fs.readFileSync(POLICY_FILE, 'utf8')); return { ...BOOKING_DEFAULTS, ...(p.booking || {}) } } catch { return { ...BOOKING_DEFAULTS } }
}
// Compétence REQUISE pour un job → seuls les techs qui l'ont produisent des créneaux (techGaps filtre).
// Un ajout TV/borne WiFi peut mapper à '' (n'importe qui) ; une installation/réparation à 'installateur'.
// Source : champ explicite Dispatch Job.required_skill, sinon table service_type → tag (politique booking).
function skillForJob (job) {
if (!job) return ''
if (job.required_skill) return String(job.required_skill).trim()
const map = getBookingPolicy().skill_by_type || {}
return String(map[job.service_type] || '').trim()
}
// Repli : déduit une COMPÉTENCE (parmi les skills réels des techs) depuis le département/type legacy.
// Sert à colorer les tickets par la couleur de leur compétence (éditable via le gestionnaire de tags).
// SOURCE UNIQUE : la logique vit dans lib/skill-resolver.js (partagée avec le résolveur raison→compétence
// et le prédicat de disponibilité) ; ré-exportée ici pour ne rien casser des appelants existants.
const { deptToSkill, techHasSkills } = require('./skill-resolver')
// Enrichit des jobs avec une adresse LISIBLE (le champ service_location est un code « LOC-… »).
// Batch : 1 seule requête sur Service Location pour tous les codes distincts.
async function attachLocations (jobs) {
const codes = [...new Set((jobs || []).map(j => j.service_location).filter(Boolean))]
const m = {}
if (codes.length) { try { const locs = await erp.list('Service Location', { filters: [['name', 'in', codes]], fields: ['name', 'address_line', 'city', 'location_name'], limit: codes.length + 10 }); for (const l of locs) m[l.name] = l } catch (e) {} }
// Résolveur municipalité (best-effort : si F injoignable, on n'enrichit pas → OPS retombe sur la ville brute).
let muni = null; try { muni = require('./municipality'); await muni.load() } catch (e) { muni = null }
const cityFromAddr = (s) => { const p = String(s || '').split(',').map(x => x.trim()).filter(Boolean); return p.length >= 2 ? p[p.length - 1] : '' }
for (const j of jobs) {
const l = m[j.service_location]
if (l) j.location_label = [l.address_line, l.city].filter(Boolean).join(', ') || l.location_name || ''
if (muni) {
const rawCity = (l && l.city) || cityFromAddr(j.location_label || j.address) || ''
if (rawCity) { try { const r = muni.resolveSync(rawCity); if (r && r.matched) { j.municipalite = r.canonical; j.municipalite_code = r.code; j.mrc = r.mrc || '' } } catch (e) {} }
}
}
// Couverture par CODE POSTAL : jobs sans municipalité résolue (adresse finissant par un code postal, sans ville texte)
// → ville dominante du code postal (RQA, code_postal indexé) → canonique. = « organiser par localisation si dispo ».
const residual = jobs.filter(j => !j.municipalite && (j.address || j.location_label))
if (residual.length) {
let adb = null; try { adb = require('./address-db') } catch (e) { adb = null }
if (adb) {
const POSTAL = /([A-Za-z]\d[A-Za-z])\s?(\d[A-Za-z]\d)/
for (const j of residual.slice(0, 150)) {
const mp = String(j.address || j.location_label || '').match(POSTAL); if (!mp) continue
try {
const ville = await adb.villeForPostal(mp[1] + mp[2])
if (ville) { const r = muni ? muni.resolveSync(ville) : null; if (r && r.matched) { j.municipalite = r.canonical; j.municipalite_code = r.code; j.mrc = r.mrc || '' } else { j.municipalite = ville } }
} catch (e) { /* non bloquant */ }
}
}
}
return jobs
}
function nowMs () { return new Date().getTime() }
// ── Cache du pool « jobs à assigner » (lecture CHAUDE rejouée à chaque ouverture de Planif) ──
// La requête Dispatch Job + enrichissement est lourde sur cet hôte chargé. Stale-while-revalidate :
// frais (<TTL) = instantané ; périmé = sert le périmé + rafraîchit en arrière-plan ; vide (à froid) = build live.
// Repli SÛR : toute erreur retombe sur le build live (= comportement actuel). Invalidé par les écritures.
const POOL_TTL_MS = 30000
let _poolCache = { ts: 0, jobs: null, building: null }
async function buildUnassigned () {
const t0 = nowMs()
const rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'creation', 'subject', 'customer_name', 'service_location', 'service_type', 'job_type', 'assigned_group', 'legacy_dept', 'legacy_detail', 'legacy_ticket_id', 'legacy_activation_url', 'priority', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech', 'latitude', 'longitude', 'address'], orderBy: 'modified desc', limit: 400 })
const jobs = (rows || []).filter(j => !j.assigned_tech)
const chars = readJobChar().items
const jlv = getJobLevels(); const jrm = getJobRemote(); const jsk = getJobSkillsMap()
for (const j of jobs) {
const ov = jsk[j.name]; const derived = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject)
j.required_skills = (Array.isArray(ov) && ov.length) ? ov.filter(Boolean) : (derived ? [derived] : []) // LISTE (override store, sinon dérivé)
j.required_skill = j.required_skills[0] || '' // principale = 1re (affichage/couleur/icône + rétro-compat)
j.required_level = jlv[j.name] || 0; j.remote = !!jrm[j.name]; const est = estimateForJob(j, chars); j.est_min = est.minutes; j.est_labels = est.labels
}
await attachLocations(jobs)
log(`pool: construit ${jobs.length} jobs en ${nowMs() - t0}ms`) // mesure réelle (requête + enrichissement)
return jobs
}
function refreshPoolBg () { if (_poolCache.building) return; _poolCache.building = buildUnassigned().then(j => { _poolCache = { ts: nowMs(), jobs: j, building: null } }).catch(e => { _poolCache.building = null; log('pool refresh err: ' + (e && e.message)) }) }
function invalidatePool () { _poolCache.ts = 0; refreshPoolBg() } // après une écriture pool → forcer le rafraîchissement
// Holds en mémoire : quand un client/agent sélectionne une fenêtre, on la réserve
// quelques minutes pour éviter qu'un autre la prenne pendant la confirmation.
// Transitoire (perdu au redémarrage du hub = acceptable pour un blocage de ~10 min).
const bookingHolds = new Map() // clé 'YYYY-MM-DD|HH:MM' → [expiryMs, …]
function holdCount (key) {
const now = nowMs(); const arr = (bookingHolds.get(key) || []).filter(t => t > now)
if (arr.length) bookingHolds.set(key, arr); else bookingHolds.delete(key)
return arr.length
}
function addHold (key, minutes) {
const arr = (bookingHolds.get(key) || []).filter(t => t > nowMs())
arr.push(nowMs() + Math.max(1, minutes || 10) * 60000); bookingHolds.set(key, arr)
return arr.length
}
function releaseHold (key) { bookingHolds.delete(key) }
async function loadBookingData (start, days) {
const dates = rangeDates(start, days)
const asgs = await fetchAssignments(start, days)
const techs = await fetchTechnicians()
const templates = await fetchTemplates()
const unavail = await buildUnavailability(techs, dates) // En pause + absence_from/until + Tech Availability approuvées (par jour)
const techById = Object.fromEntries(techs.map(t => [t.id, t]))
const tplByName = Object.fromEntries(templates.map(t => [t.name, t]))
const jobs = await erp.list('Dispatch Job', {
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned']]],
fields: ['assigned_tech', 'scheduled_date', 'start_time', 'duration_h'], limit: 2000,
})
const booked = {}
for (const j of jobs) {
if (!j.start_time) continue
const k = j.assigned_tech + '|' + j.scheduled_date
;(booked[k] || (booked[k] = [])).push({ s: timeToH(j.start_time), e: timeToH(j.start_time) + (Number(j.duration_h) || 1) })
}
return { asgs, techById, tplByName, booked, unavail }
}
// Trous libres d'un tech (dans son shift, moins jobs pointés), filtrés compétence/zone.
function techGaps (a, d, skill, zone) {
const t = d.techById[a.tech]; if (!t || t.status === PAUSE_STATUS) return null
if (d.unavail && d.unavail[a.tech] && d.unavail[a.tech].has(a.date)) return null // absence/congé approuvé ce jour-là → pas de créneaux
if (skill && !(t.skills || []).includes(skill)) return null
if (zone && a.zone && a.zone !== zone) return null
const tpl = d.tplByName[a.shift]; if (!tpl) return null
if (tpl.on_call) return null // garde (sur appel) = capacité d'urgence, JAMAIS offerte au booking client
const sh = timeToH(tpl.start_time) || 8; const eh = timeToH(tpl.end_time) || (sh + (Number(tpl.hours) || 8))
const day = (d.booked[a.tech + '|' + a.date] || []).slice().sort((x, y) => x.s - y.s)
let cursor = sh; const gaps = []
for (const b of day) { if (b.s > cursor) gaps.push([cursor, b.s]); cursor = Math.max(cursor, b.e) }
if (cursor < eh) gaps.push([cursor, eh])
return { tech: t, gaps }
}
// Premier créneau libre (en heures, ex 8.5) d'un tech un jour donné pour une durée `dur`.
// Aucun trou assez large → empile après le dernier bloc (surbook visible) ; pas de shift régulier → null.
// Partagé par /roster/assign-job (drag-drop) et /roster/backfill-start-times.
function firstFitStart (d, techId, date, dur) {
const mine = d.asgs.filter(a => a.tech === techId && a.date === date && !(d.tplByName[a.shift] && d.tplByName[a.shift].on_call))
if (!mine.length) return null
let best = null
for (const a of mine) { const g = techGaps(a, d, null, null); if (!g) continue; for (const [gs, ge] of g.gaps) if (ge - gs >= dur - 1e-6) { if (best == null || gs < best) best = gs; break } }
if (best == null) { const tpl = d.tplByName[mine[0].shift]; const sh = timeToH(tpl && tpl.start_time) || 8; const day = (d.booked[techId + '|' + date] || []).slice().sort((x, y) => x.e - y.e); best = day.length ? day[day.length - 1].e : sh }
return best
}
async function bookingSlots ({ skill, zone, duration = 1, start, days = 7, limit = 24, aggregate = false, ignorePolicy = false } = {}) {
const dur = Number(duration) || 1
const pol = getBookingPolicy()
const d = await loadBookingData(start, days)
const leadCut = ignorePolicy ? 0 : nowMs() + (pol.lead_hours || 0) * 3600000
const dayStart = ignorePolicy ? 0 : (Number.isFinite(pol.day_start) ? pol.day_start : 0)
const dayEnd = ignorePolicy ? 24 : (Number.isFinite(pol.day_end) ? pol.day_end : 24)
const offered = !ignorePolicy && Array.isArray(pol.days_offered) && pol.days_offered.length ? pol.days_offered : null
const out = []
for (const a of d.asgs) {
if (a.status === 'Annulé') continue
if (offered && !offered.includes(parseISO(a.date).getUTCDay())) continue // jour non offert par la politique
const g = techGaps(a, d, skill, zone); if (!g) continue
for (const [gs, ge] of g.gaps) {
let s = Math.max(gs, dayStart); const end = Math.min(ge, dayEnd) // borné à la plage horaire offerte
while (s + dur <= end) {
const slotMs = parseISO(a.date).getTime() + s * 3600000 // approx UTC — suffisant pour un délai exprimé en heures
if (slotMs >= leadCut) out.push({ date: a.date, start: hToTime(s), end: hToTime(s + dur), start_h: s, tech: a.tech, tech_name: g.tech.name, zone: a.zone || '', shift: a.shift })
s += dur
}
}
}
if (aggregate) { // client : 1 fenêtre par (date,heure) + nb de techs dispo, moins les holds actifs
const byWin = {}
for (const s of out) { const k = s.date + '|' + s.start; (byWin[k] || (byWin[k] = { date: s.date, start: s.start, end: s.end, start_h: s.start_h, available: 0 })).available++ }
let arr = Object.values(byWin).map(w => ({ ...w, available: w.available - holdCount(w.date + '|' + w.start) })).filter(w => w.available > 0)
arr.sort((x, y) => x.date.localeCompare(y.date) || x.start_h - y.start_h)
if (!ignorePolicy && pol.max_per_day > 0) { const c = {}; arr = arr.filter(w => { c[w.date] = (c[w.date] || 0) + 1; return c[w.date] <= pol.max_per_day }) } // plafond/jour
return arr.slice(0, limit)
}
out.sort((x, y) => x.date.localeCompare(y.date) || x.start_h - y.start_h)
return out.slice(0, limit)
}
// Fit : le client fournit 3 dispos classées → on place dans le 1er choix tenable,
// sinon 2e, sinon 3e. Si aucune ne tient → on PROPOSE nos créneaux (fallback).
async function fitBooking ({ skill, zone, duration = 1, prefs = [] } = {}) {
const dur = Number(duration) || 1
const dates = [...new Set((prefs || []).map(p => p.date).filter(Boolean))].sort()
if (!dates.length) return { chosen: null, proposed: [] }
const span = Math.max(1, Math.round((parseISO(dates[dates.length - 1]) - parseISO(dates[0])) / 86400000) + 1)
const d = await loadBookingData(dates[0], span)
const byDate = {}; for (const a of d.asgs) (byDate[a.date] || (byDate[a.date] = [])).push(a)
for (let i = 0; i < prefs.length; i++) {
const p = prefs[i]; const ps = timeToH(p.start); const pe = ps + dur
for (const a of (byDate[p.date] || [])) {
if (a.status === 'Annulé') continue
const g = techGaps(a, d, skill, zone); if (!g) continue
if (g.gaps.some(([gs, ge]) => gs <= ps && ge >= pe)) {
return { chosen: { rank: i + 1, date: p.date, start: p.start, end: hToTime(pe), tech: a.tech, tech_name: g.tech.name, zone: a.zone || '', shift: a.shift }, proposed: [] }
}
}
}
const proposed = await bookingSlots({ skill, zone, duration: dur, start: dates[0], days: 14, limit: 6, aggregate: true })
return { chosen: null, proposed }
}
// ── Portail public de prise de RDV (staging — PAS sur Lovable tant que non validé) ──
function todayET () { return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) }
async function jobByToken (token) {
if (!token) return null
const rows = await erp.list('Dispatch Job', { filters: [['booking_token', '=', token]], fields: ['name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'start_time', 'booking_status'], limit: 1 })
return rows[0] || null
}
async function confirmWindow (jobName, date, start, duration, skill) {
// À la confirmation on veut juste vérifier que le tech est ENCORE physiquement libre
// (pas re-filtrer par la politique d'offre) → ignorePolicy. MAIS on garde le filtre COMPÉTENCE
// (skill) sinon on pourrait assigner un tech non qualifié au créneau choisi.
const day = await bookingSlots({ skill, duration, start: date, days: 1, limit: 300, ignorePolicy: true })
const slot = day.find(s => s.start === start)
if (!slot) return { ok: false, message: 'Ce créneau vient d\'être pris — choisissez-en un autre.' }
const st = start.length === 5 ? start + ':00' : start
const r = await retryWrite(() => erp.update('Dispatch Job', jobName, { scheduled_date: date, start_time: st, assigned_tech: slot.tech, status: 'assigned', booking_status: 'Confirmé' }))
if (r.ok) releaseHold(date + '|' + start)
return r.ok ? { ok: true, confirmed: true, date, start, tech: slot.tech_name } : { ok: false, message: r.error || 'échec' }
}
const BOOK_HTML = `<!doctype html><html lang=fr><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>Prendre rendez-vous — Gigafibre</title>
<style>body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;margin:0;background:#f4f6f8;color:#1a1a1a}.wrap{max-width:560px;margin:0 auto;padding:16px}.card{background:#fff;border-radius:12px;box-shadow:0 1px 4px rgba(0,0,0,.08);padding:20px;margin-bottom:12px}h1{font-size:20px;margin:0 0 4px}.sub{color:#666;font-size:13px;margin-bottom:8px}.brand{color:#1565c0;font-weight:800}.day{font-weight:700;margin:14px 0 6px;font-size:13px;color:#444}.slots{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:8px}.slot{border:1px solid #d0d7de;border-radius:8px;padding:10px 6px;text-align:center;cursor:pointer;font-size:14px;position:relative}.slot:hover{border-color:#1565c0}.slot.sel{background:#e3f2fd;border-color:#1565c0;font-weight:700}.rank{position:absolute;top:-8px;right:-8px;background:#1565c0;color:#fff;width:20px;height:20px;border-radius:50%;font-size:12px;line-height:20px}.btn{background:#1565c0;color:#fff;border:0;border-radius:8px;padding:13px 18px;font-size:15px;font-weight:600;cursor:pointer;width:100%;margin-top:12px}.btn:disabled{background:#b0bec5}.hint{font-size:12px;color:#666;margin:8px 0}.ok{background:#e8f5e9;color:#1b5e20;padding:16px;border-radius:8px;text-align:center}.err{background:#ffebee;color:#b71c1c;padding:12px;border-radius:8px}.week{background:#1565c0;color:#fff;font-weight:700;font-size:13px;padding:8px 12px;border-radius:8px;margin:18px 0 4px}.ampm{font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.5px;margin:10px 0 4px;font-weight:700}.empty{color:#999;font-size:12px;font-style:italic}</style></head>
<body><div class=wrap><div class=card><h1>Prendre <span class=brand>rendez-vous</span></h1><div class=sub id=jobinfo>Chargement…</div><div id=content></div></div><div class=sub style=text-align:center>Gigafibre · propulsé par Targo</div></div>
<script>
const token=new URLSearchParams(location.search).get('token')||'';
const FR=['dimanche','lundi','mardi','mercredi','jeudi','vendredi','samedi'];
const MO=['janv.','févr.','mars','avr.','mai','juin','juil.','août','sept.','oct.','nov.','déc.'];
let picks=[];
function d2(iso){const a=iso.split('-').map(Number);return new Date(Date.UTC(a[0],a[1]-1,a[2]))}
function dayLabel(iso){const dt=d2(iso);return FR[dt.getUTCDay()]+' '+dt.getUTCDate()+' '+MO[dt.getUTCMonth()]}
function wkMon(iso){const dt=d2(iso);const off=(dt.getUTCDay()+6)%7;dt.setUTCDate(dt.getUTCDate()-off);return dt.toISOString().slice(0,10)}
function wkLabel(m){const a=d2(m);const b=new Date(a);b.setUTCDate(b.getUTCDate()+6);return 'Semaine du '+a.getUTCDate()+' '+MO[a.getUTCMonth()]+' '+b.getUTCDate()+' '+MO[b.getUTCMonth()]}
async function load(){const r=await fetch('/book/api/options?token='+encodeURIComponent(token)).then(x=>x.json()).catch(()=>({ok:false}));const info=document.getElementById('jobinfo'),c=document.getElementById('content');
if(!r.ok){info.textContent='';c.innerHTML='<div class=err>Lien invalide ou expiré. Contactez-nous.</div>';return}
if(r.job.scheduled){info.innerHTML='Votre rendez-vous est déjà confirmé : <b>'+dayLabel(r.job.scheduled)+'</b>.';c.innerHTML='';return}
info.innerHTML='Choisissez vos disponibilités <b>en ordre de préférence</b> (jusquà 3). On confirme le 1er créneau possible.';
if(!r.windows.length){c.innerHTML='<div class=err>Aucune disponibilité pour le moment — nous vous contacterons.</div>';return}
const weeks={};r.windows.forEach(w=>{const mk=wkMon(w.date);(weeks[mk]=weeks[mk]||{});(weeks[mk][w.date]=weeks[mk][w.date]||[]).push(w)});let h='';
Object.keys(weeks).sort().forEach(mk=>{h+='<div class=week>'+wkLabel(mk)+'</div>';Object.keys(weeks[mk]).sort().forEach(d=>{const ws=weeks[mk][d].sort((a,b)=>a.start_h-b.start_h);const am=ws.filter(w=>w.start_h<12),pm=ws.filter(w=>w.start_h>=12);h+='<div class=day>'+dayLabel(d)+'</div>';const sect=(lab,arr)=>arr.length?('<div class=ampm>'+lab+'</div><div class=slots>'+arr.map(w=>'<div class=slot data-d="'+w.date+'" data-s="'+w.start+'">'+w.start+'</div>').join('')+'</div>'):'';h+=sect('Matin',am)+sect('Après-midi',pm)})});
h+='<div class=hint id=hint>Touchez 1 à 3 créneaux.</div><button class=btn id=go disabled>Confirmer mes disponibilités</button>';c.innerHTML=h;
c.querySelectorAll('.slot').forEach(el=>el.onclick=()=>toggle(el));document.getElementById('go').onclick=submit}
function toggle(el){const k=el.dataset.d+'|'+el.dataset.s,i=picks.indexOf(k);if(i>=0)picks.splice(i,1);else if(picks.length<3)picks.push(k);render()}
function render(){document.querySelectorAll('.slot').forEach(el=>{const k=el.dataset.d+'|'+el.dataset.s,i=picks.indexOf(k);el.classList.toggle('sel',i>=0);let b=el.querySelector('.rank');if(i>=0){if(!b){b=document.createElement('div');b.className='rank';el.appendChild(b)}b.textContent=i+1}else if(b)b.remove()});document.getElementById('hint').textContent=picks.length?picks.length+' choix sélectionné(s) — le 1er sera priorisé.':'Touchez 1 à 3 créneaux.';document.getElementById('go').disabled=!picks.length}
async function submit(){const go=document.getElementById('go');go.disabled=true;go.textContent='Envoi…';const prefs=picks.map(k=>({date:k.split('|')[0],start:k.split('|')[1]}));const r=await fetch('/book/api/submit?token='+encodeURIComponent(token),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode:'rank',prefs})}).then(x=>x.json()).catch(()=>({ok:false}));const c=document.getElementById('content');
if(r.ok&&r.confirmed)c.innerHTML='<div class=ok>✅ Rendez-vous confirmé : <b>'+dayLabel(r.date)+' à '+r.start+'</b>.<br>Merci !</div>';
else if(r.ok)c.innerHTML='<div class=ok>Merci ! '+(r.message||'Nous vous confirmerons sous peu.')+'</div>';
else c.innerHTML='<div class=err>'+(r.message||r.error||'Une erreur est survenue.')+'</div>'}
load();
</script></body></html>`
async function handlePublicBooking (req, res, method, path, url) {
if (path === '/book' && method === 'GET') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(BOOK_HTML) }
const token = url.searchParams.get('token') || ''
if (path === '/book/api/options' && method === 'GET') {
const job = await jobByToken(token); if (!job) return json(res, 404, { ok: false, error: 'lien invalide' })
const dur = Number(job.duration_h) || 1; const skill = skillForJob(job) // seuls les techs qualifiés
const windows = await bookingSlots({ skill, duration: dur, start: todayET(), days: getBookingPolicy().horizon_days || 21, aggregate: true, limit: 60 })
return json(res, 200, { ok: true, job: { location: job.service_location || '', service_type: job.service_type || '', required_skill: skill, duration: dur, scheduled: job.scheduled_date || '' }, windows })
}
if (path === '/book/api/submit' && method === 'POST') {
const job = await jobByToken(token); if (!job) return json(res, 404, { ok: false, error: 'lien invalide' })
const b = await parseBody(req); const dur = Number(job.duration_h) || 1; const skill = skillForJob(job)
if (b.mode === 'rank' && Array.isArray(b.prefs) && b.prefs.length) {
const fit = await fitBooking({ skill, duration: dur, prefs: b.prefs })
if (fit.chosen) { const r = await confirmWindow(job.name, fit.chosen.date, fit.chosen.start, dur, skill); if (r.ok) return json(res, 200, { ...r, rank: fit.chosen.rank }) }
await retryWrite(() => erp.update('Dispatch Job', job.name, { booking_prefs: JSON.stringify(b.prefs), booking_status: 'Proposé' }))
return json(res, 200, { ok: true, confirmed: false, message: 'Vos disponibilités sont enregistrées — nous vous confirmerons sous peu.' })
}
return json(res, 400, { ok: false, error: 'requête invalide' })
}
return json(res, 404, { error: 'not found' })
}
// ── APP TECHNICIEN : capture passive du temps via « checkpoints » (GPS geofence / scan / etc.) ──────────────
// Token signé par job (HMAC, stateless, sans login ni champ DB). Voir docs/field-tech-app.md.
const FIELD_SECRET = cfg.INTERNAL_TOKEN || cfg.ERP_SERVICE_TOKEN || 'targo-field-v1'
const ON_SITE_M = Number(process.env.FIELD_ONSITE_RADIUS_M) || 250 // rayon « sur place » autour de l'adresse
function fieldSign (name) { const h = crypto.createHmac('sha256', FIELD_SECRET).update(String(name)).digest('hex').slice(0, 12); return Buffer.from(String(name)).toString('base64url') + '.' + h }
function fieldVerify (token) { try { const [b, h] = String(token || '').split('.'); if (!b || !h) return null; const name = Buffer.from(b, 'base64url').toString('utf8'); const exp = crypto.createHmac('sha256', FIELD_SECRET).update(name).digest('hex').slice(0, 12); return crypto.timingSafeEqual(Buffer.from(h), Buffer.from(exp)) ? name : null } catch (e) { return null } }
function haversineM (a1, o1, a2, o2) { const R = 6371000, t = Math.PI / 180; const dA = (a2 - a1) * t, dO = (o2 - o1) * t; const x = Math.sin(dA / 2) ** 2 + Math.cos(a1 * t) * Math.cos(a2 * t) * Math.sin(dO / 2) ** 2; return Math.round(R * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x))) }
function fmtDistM (m) { return m < 1000 ? (m + ' m') : ((m / 1000).toFixed(m < 10000 ? 1 : 0) + ' km') }
// Token PAR TECH (préfixe 'T:') → l'app charge les jobs du jour du tech. Token Mapbox public (pk) pour la carte de l'app.
const FIELD_MAPBOX = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg'
function techFieldSign (name) { return fieldSign('T:' + name) }
function techFieldVerify (t) { const v = fieldVerify(t); return (v && v.startsWith('T:')) ? v.slice(2) : null }
function serveFieldApp (res) {
try { let html = require('fs').readFileSync('/app/public/field-app.html', 'utf8'); html = html.split('__MAPBOX_TOKEN__').join(FIELD_MAPBOX).split('__HUB__').join(cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca'); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(html) } catch (e) { res.writeHead(500); return res.end('app terrain indisponible') }
}
function savePhoto (jobName, dataUrl) {
const m = /^data:image\/(\w+);base64,(.+)$/.exec(String(dataUrl || '')); if (!m) return null
const buf = Buffer.from(m[2], 'base64'); if (!buf.length || buf.length > 6 * 1024 * 1024) return null
const fs = require('fs'); const dir = '/app/uploads/field'; try { fs.mkdirSync(dir, { recursive: true }) } catch (e) {}
const fn = 'f_' + String(jobName).replace(/[^a-zA-Z0-9_-]/g, '') + '_' + Date.now() + '.' + (m[1] === 'png' ? 'png' : 'jpg')
try { fs.writeFileSync(dir + '/' + fn, buf); return fn } catch (e) { return null }
}
async function handleFieldTech (req, res, method, path, url) {
const token = url.searchParams.get('t') || url.searchParams.get('token') || ''
if (path === '/field/job' && method === 'GET') {
const name = fieldVerify(token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['name', 'subject', 'customer_name', 'address', 'latitude', 'longitude', 'scheduled_date', 'actual_start', 'actual_end', 'status'], limit: 1 })
const j = r && r[0]; if (!j) return json(res, 404, { ok: false, error: 'job introuvable' })
return json(res, 200, { ok: true, job: { ...j, actual_minutes: minutesBetween(j.actual_start, j.actual_end) } })
}
if (path === '/field/checkpoint' && method === 'POST') {
const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['name', 'latitude', 'longitude', 'actual_start', 'actual_end', 'completion_notes', 'status'], limit: 1 })
const j = r && r[0]; if (!j) return json(res, 404, { ok: false, error: 'job introuvable' })
const ts = nowFrappe()
const lat = Number(b.lat), lon = Number(b.lon); const hasGps = isFinite(lat) && isFinite(lon)
let dist = null
if (hasGps && j.latitude != null && j.longitude != null && (Math.abs(+j.latitude) > 0.01)) dist = haversineM(+j.latitude, +j.longitude, lat, lon)
const onSite = (b.type === 'geo_exit') || dist == null || dist <= ON_SITE_M // hors-zone ignoré pour la durée, gardé en note
const patch = {}
if (onSite) { if (!j.actual_start) patch.actual_start = ts; patch.actual_end = ts; if (j.status === 'open' || j.status === 'assigned') patch.status = 'In Progress' }
const note = `${ts} ${b.type || 'checkpoint'}${b.ref ? ' ' + String(b.ref).slice(0, 40) : ''}${hasGps ? ` · GPS ${lat.toFixed(5)},${lon.toFixed(5)}${b.acc ? ' ±' + Math.round(b.acc) + 'm' : ''}${dist != null ? ` · à ${dist}m` : ''}` : ''}${onSite ? '' : ' · HORS ZONE (ignoré durée)'}`
patch.completion_notes = ((j.completion_notes || '') + '\n' + note).slice(-4000)
const up = await retryWrite(() => erp.update('Dispatch Job', name, patch))
const startTs = j.actual_start || (onSite ? ts : null)
return json(res, up.ok ? 200 : 500, { ok: !!up.ok, on_site: onSite, distance_m: dist, minutes: minutesBetween(startTs, onSite ? ts : j.actual_end), actual_start: patch.actual_start || j.actual_start || '', actual_end: patch.actual_end || j.actual_end || '' })
}
// COMMENTAIRE du tech depuis l'app terrain → fil du ticket. Reste sur OPS (plus de renvoi vers store.targo.ca).
// Par défaut = NOTE INTERNE (jamais envoyée au client). Envoyée au client SEULEMENT si public=true (coché).
if (path === '/field/comment' && method === 'POST') {
const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
const text = String(b.text || '').trim(); if (!text) return json(res, 400, { ok: false, error: 'texte requis' })
const isPublic = !!b.public
let posted = false, sentToCustomer = false
try {
const j = await erp.get('Dispatch Job', name, { fields: ['legacy_ticket_id', 'assigned_tech'] })
const lid = j && j.legacy_ticket_id
// Note au nom du TECHNICIEN (pas « Tech Targo »). Courriel → compte staff osTicket si présent (auteur = son nom) ;
// nom → repli dans le corps si aucun compte legacy. Le technicien = celui assigné au job.
let techEmail = '', techName = ''
if (j && j.assigned_tech) { try { const tr = await erp.list('Dispatch Technician', { filters: [['name', '=', j.assigned_tech]], fields: ['full_name', 'email'], limit: 1 }); if (tr && tr[0]) { techEmail = tr[0].email || ''; techName = tr[0].full_name || '' } } catch (e) {} }
if (lid) {
// Fil legacy (osTicket) = ce que le volet job affiche. Corps = le texte brut du tech (aucune étiquette « [Note tech] » — bruit inutile) :
// note interne par défaut, publique (courriel client) si coché. L'attribution se fait par l'auteur, pas par un préfixe.
const r = await require('./legacy-dispatch-sync').postTicketLegacy(lid, text, isPublic, techEmail, techName)
posted = !!(r && r.ok !== false); sentToCustomer = isPublic && posted
} else {
// Job natif (pas de ticket legacy) → Comment ERPNext sur le Dispatch Job, préfixé du nom du tech (l'auteur ERPNext = compte service).
await erp.create('Comment', { comment_type: 'Comment', reference_doctype: 'Dispatch Job', reference_name: name, content: (techName ? techName + ' : ' : '') + text })
posted = true
}
} catch (e) { return json(res, 500, { ok: false, error: e.message }) }
return json(res, 200, { ok: posted, public: isPublic, sentToCustomer })
}
// Lire le FIL du ticket depuis l'app terrain (near-realtime : le client poll pendant que le panneau est ouvert).
if (path === '/field/thread' && method === 'GET') {
const name = fieldVerify(token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
try {
const j = await erp.get('Dispatch Job', name, { fields: ['legacy_ticket_id'] })
const lid = j && j.legacy_ticket_id
if (!lid) return json(res, 200, { ok: true, messages: [] })
const th = await require('./legacy-dispatch-sync').ticketThread(lid)
return json(res, 200, (th && th.ok) ? { ok: true, subject: th.subject, status: th.status, messages: th.messages || [] } : { ok: false, error: (th && th.error) || 'fil indisponible' })
} catch (e) { return json(res, 500, { ok: false, error: e.message }) }
}
if ((path === '/field' || path === '/field/app') && method === 'GET') return serveFieldApp(res) // PWA technicien (carte + tickets + photos + scan)
if (path === '/field/tech' && method === 'GET') { // jobs du jour (+ demain) du tech, via token PAR TECH
const tech = techFieldVerify(token); if (!tech) return json(res, 403, { ok: false, error: 'lien invalide' })
const dates = rangeDates(todayET(), 2) // aujourd'hui + demain
const rows = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', tech], ['scheduled_date', 'in', dates]], fields: ['name', 'subject', 'customer_name', 'address', 'latitude', 'longitude', 'scheduled_date', 'start_time', 'status', 'actual_start', 'actual_end', 'legacy_ticket_id', 'legacy_activation_url', 'notes', 'required_skill', 'duration_h'], orderBy: 'scheduled_date asc, start_time asc', limit: 200 })
const staffId = (String(tech).match(/(\d+)$/) || [])[1] || ''
const jobs = (rows || []).map(j => ({ name: j.name, subject: j.subject, customer: j.customer_name || '', address: j.address || '', lat: j.latitude, lon: j.longitude, date: j.scheduled_date, start: j.start_time ? String(j.start_time).slice(0, 5) : '', status: j.status, started: !!j.actual_start, ended: !!j.actual_end, minutes: minutesBetween(j.actual_start, j.actual_end), token: fieldSign(j.name), reply: j.legacy_ticket_id ? `https://store.targo.ca/targo/reply_ticket.php?ticket=${j.legacy_ticket_id}&staff=${staffId}` : '', activation: j.legacy_activation_url || '', notes: j.notes || '', skill: j.required_skill || '', dur: j.duration_h || null }))
return json(res, 200, { ok: true, tech, jobs })
}
if (path === '/field/photo' && method === 'POST') { // photo (base64) → /app/uploads/field + note sur le job (+ géoloc → confirme la présence au bon endroit)
const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
const fn = savePhoto(name, b.image); if (!fn) return json(res, 400, { ok: false, error: 'image invalide' })
const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['completion_notes', 'latitude', 'longitude'], limit: 1 }); const j = r && r[0]
// Position de l'appareil AU MOMENT de la photo → distance à l'adresse du job = preuve que la photo est prise au bon endroit.
let geoNote = '', distM = null, onsite = null
if (isFinite(+b.lat) && isFinite(+b.lon) && Math.abs(+b.lat) > 0.01) {
if (j && isFinite(+j.latitude) && isFinite(+j.longitude) && Math.abs(+j.latitude) > 0.01) {
distM = haversineM(+b.lat, +b.lon, +j.latitude, +j.longitude); onsite = distM <= 200
geoNote = onsite ? ` · 📍 ✓ sur place (≈${distM} m)` : ` · 📍 ⚠ à ${fmtDistM(distM)} de l'adresse`
} else geoNote = ` · 📍 ${(+b.lat).toFixed(5)},${(+b.lon).toFixed(5)}` // job sans coords : on note quand même la position
}
const note = `\n${nowFrappe()} 📷 photo ${fn}${geoNote}${b.note ? ' — ' + String(b.note).slice(0, 60) : ''}`
await retryWrite(() => erp.update('Dispatch Job', name, { completion_notes: (((j && j.completion_notes) || '') + note).slice(-4000) }))
return json(res, 200, { ok: true, file: fn, distM, onsite, distLabel: distM != null ? fmtDistM(distM) : null })
}
if (path === '/field/photo-file' && method === 'GET') { // sert UNE photo terrain — URL signée (fieldSign du job), anti-traversal strict
const name = fieldVerify(token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
const f = String(url.searchParams.get('f') || '')
const prefix = 'f_' + String(name).replace(/[^a-zA-Z0-9_-]/g, '') + '_'
if (!/^f_[a-zA-Z0-9_-]+_\d+\.(jpg|png)$/.test(f) || !f.startsWith(prefix)) return json(res, 400, { ok: false, error: 'fichier invalide' })
try {
const buf = require('fs').readFileSync('/app/uploads/field/' + f)
res.writeHead(200, { 'Content-Type': f.endsWith('.png') ? 'image/png' : 'image/jpeg', 'Cache-Control': 'private, max-age=86400', 'Content-Length': buf.length })
return res.end(buf)
} catch (e) { return json(res, 404, { ok: false, error: 'photo introuvable' }) }
}
if (path === '/field/device' && method === 'POST') { // appareil scané (série/MAC) → checkpoint présence + note (GenieACS à brancher)
const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
const serial = String(b.serial || '').slice(0, 60), mac = String(b.mac || '').slice(0, 40), kind = String(b.kind || 'appareil').slice(0, 30)
if (!serial && !mac) return json(res, 400, { ok: false, error: 'série ou MAC requis' })
const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['completion_notes', 'actual_start', 'status'], limit: 1 }); const j = r && r[0]
const ts = nowFrappe(); const patch = { completion_notes: (((j && j.completion_notes) || '') + `\n${ts} 📦 ${kind}${serial ? ' SN ' + serial : ''}${mac ? ' MAC ' + mac : ''}`).slice(-4000), actual_end: ts }
if (j && !j.actual_start) patch.actual_start = ts // un scan sur place = preuve de présence (démarre le chrono)
if (j && (j.status === 'open' || j.status === 'assigned')) patch.status = 'In Progress'
await retryWrite(() => erp.update('Dispatch Job', name, patch))
return json(res, 200, { ok: true, serial, mac, kind, genieacs: 'à brancher par MAC' })
}
if (path === '/field/ts' && method === 'POST') { // webhook geofence natif Transistorsoft (autoSync) → checkpoints. Auto-authentifié : identifier = token signé du job.
const b = await parseBody(req)
const locs = Array.isArray(b.location) ? b.location : (b.location ? [b.location] : (Array.isArray(b) ? b : []))
let applied = 0
for (const L of locs) {
const g = L && L.geofence; if (!g || !g.identifier) continue
const name = fieldVerify(g.identifier); if (!name) continue
const type = String(g.action).toUpperCase() === 'EXIT' ? 'geo_exit' : 'geo_enter'
const c = (L.coords) || {}; const ts = nowFrappe()
const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['name', 'actual_start', 'completion_notes', 'status'], limit: 1 }); const j = r && r[0]; if (!j) continue
const patch = { actual_end: ts, completion_notes: (((j.completion_notes) || '') + `\n${ts} ${type} (geofence natif)${c.latitude ? ` · GPS ${(+c.latitude).toFixed(5)},${(+c.longitude).toFixed(5)}` : ''}`).slice(-4000) }
if (type === 'geo_enter' && !j.actual_start) patch.actual_start = ts
if (j.status === 'open' || j.status === 'assigned') patch.status = 'In Progress'
await retryWrite(() => erp.update('Dispatch Job', name, patch)).catch(() => {}); applied++
}
return json(res, 200, { ok: true, applied })
}
if (path === '/field/vision' && method === 'POST') { // photo d'étiquette → proxy IA Gemini → {brand,model,serial_number,mac_address}
const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
if (!b.image) return json(res, 400, { ok: false, error: 'image requise' })
try { const eq = await require('./vision').extractEquipment(String(b.image).replace(/^data:image\/[^;]+;base64,/, '')); return json(res, 200, { ok: true, ...eq }) }
catch (e) { return json(res, 500, { ok: false, error: String(e.message || e) }) }
}
return json(res, 404, { ok: false, error: 'not found' })
}
// Génère le lien terrain d'un job (pour SMS/app/QR) — exposé via une route interne.
function fieldLink (name) { return (cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca') + '/field?t=' + fieldSign(name) }
function techFieldLink (techName) { return (cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca') + '/field?t=' + techFieldSign(techName) }
// ── Notifier les techs de leur tournée (SMS/Email) : un LIEN token par tech (app terrain = ses billets du jour) +
// les infos cruciales par intervention (adresse + AM/PM · URGENT · Appeler avant). Aperçu (preview) OU envoi. ──
function _esc (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])) }
function jobFlags (j) {
const t = ((j.subject || '') + ' ' + (j.notes || '')).toLowerCase(); const prio = String(j.priority || '').toLowerCase(); const f = []
if (prio === 'urgent' || prio === 'high' || /\burgent\b/.test(t)) f.push('URGENT')
if (/\bpm\b|apr[eè]s.?midi/.test(t)) f.push('PM'); else if (/\bam\b|avant.?midi|\bmatin\b/.test(t)) f.push('AM')
if (/appeler? avant|appel avant|t[ée]l[ée]phoner avant|call before/.test(t)) f.push('Appeler avant')
return f
}
async function composeDispatchNotifs (dates, techIds) {
const techs = await fetchTechnicians(); const techById = {}; for (const t of techs) techById[t.id] = t
const emailById = {}
try { const er = await erp.list('Dispatch Technician', { filters: [['resource_type', '=', 'human']], fields: ['technician_id', 'name', 'email'], limit: 500 }); for (const r of er) emailById[r.technician_id || r.name] = r.email || '' } catch (e) {}
const rows = await erp.list('Dispatch Job', { filters: [['assigned_tech', 'in', techIds], ['scheduled_date', 'in', dates]], fields: ['name', 'assigned_tech', 'subject', 'customer_name', 'address', 'scheduled_date', 'start_time', 'priority', 'required_skill', 'notes', 'legacy_ticket_id'], orderBy: 'scheduled_date asc, start_time asc', limit: 1000 })
const byTech = {}; for (const j of (rows || [])) (byTech[j.assigned_tech] || (byTech[j.assigned_tech] = [])).push(j)
const out = []
for (const tid of techIds) {
const jobs = byTech[tid] || []; if (!jobs.length) continue
const t = techById[tid] || { name: tid }; const link = techFieldLink(tid)
const dLbl = [...new Set(jobs.map(j => j.scheduled_date))].join(', ')
const line = (j) => { const f = jobFlags(j); const tm = j.start_time ? String(j.start_time).slice(0, 5) + ' ' : ''; const loc = j.address || j.customer_name || j.subject || j.name; return { tm, loc, flags: f } }
const sms = `Targo — votre tournée ${dLbl} (${jobs.length}):\n` + jobs.map(j => { const l = line(j); return '• ' + l.tm + l.loc + (l.flags.length ? ' [' + l.flags.join(' · ') + ']' : '') }).join('\n') + `\nVos billets : ${link}`
const emailHtml = `<div style="font-family:Arial,sans-serif;font-size:14px;color:#1e293b"><p>Bonjour ${_esc(t.name)},</p><p>Votre tournée du <b>${_esc(dLbl)}</b> — ${jobs.length} intervention(s) :</p><ul style="padding-left:18px">` + jobs.map(j => { const l = line(j); return `<li style="margin:4px 0">${l.tm ? '<b>' + _esc(l.tm) + '</b>' : ''}${_esc(l.loc)}${l.flags.length ? ' <b style="color:#c0392b">[' + _esc(l.flags.join(' · ')) + ']</b>' : ''}</li>` }).join('') + `</ul><p style="margin-top:16px"><a href="${link}" style="background:#16a34a;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none;font-weight:600">Ouvrir mes billets</a></p><p style="color:#94a3b8;font-size:12px">Lien personnel — ne pas partager.</p></div>`
out.push({ techId: tid, name: t.name, phone: t.phone || '', email: emailById[tid] || '', jobCount: jobs.length, link, sms, emailSubject: `Targo — votre tournée du ${dLbl} (${jobs.length} intervention(s))`, emailHtml })
}
return out
}
// Stats par jour : effectif (techs distincts), heures TRAVAILLÉES, tickets dispatch.
// La garde (on_call) = mise en disponibilité → exclue des heures travaillées.
async function statsByDay (start, days) {
const dates = rangeDates(start, days)
const asgs = await fetchAssignments(start, days)
const templates = await fetchTemplates()
const onCall = new Set(templates.filter(t => t.on_call).map(t => t.name))
const jobs = await erp.list('Dispatch Job', {
filters: [['scheduled_date', 'in', dates]],
fields: ['name', 'scheduled_date'], limit: 3000,
})
const by = {}
for (const d of dates) by[d] = { date: d, staff: new Set(), hours: 0, oncall: new Set(), tickets: 0 }
for (const a of asgs) {
if (a.status === 'Annulé') continue; const x = by[a.date]; if (!x) continue
if (onCall.has(a.shift)) { x.oncall.add(a.tech) } else { x.staff.add(a.tech); x.hours += Number(a.hours) || 0 }
}
for (const j of jobs) { const x = by[j.scheduled_date]; if (x) x.tickets++ }
return dates.map(d => ({ date: d, staff: by[d].staff.size, hours: by[d].hours, on_call: by[d].oncall.size, tickets: by[d].tickets }))
}
// Occupation par (technicien, jour) : Σ heures + blocs horaires des Dispatch Jobs planifiés.
// → la grille Planification affiche la fenêtre du shift ET les blocs pris (donc les trous offrables).
async function occupancyByTechDay (start, days) {
const dates = rangeDates(start, days)
const TODAY_ET = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) // aujourd'hui (Est)
const jobs = await erp.list('Dispatch Job', {
// Inclut Completed/Cancelled : la grille montre AUSSI le travail fait/annulé de la journée (trace du tech),
// pas seulement le planifiable. (Filtrés par assigned_tech ci-dessous : les annulés « non assigné » n'apparaissent pas.)
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress', 'In Progress', 'Completed', 'Cancelled']]],
fields: ['name', 'subject', 'customer', 'customer_name', 'service_type', 'job_type', 'legacy_dept', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'route_order', 'latitude', 'longitude', 'booking_status', 'legacy_detail', 'legacy_ticket_id', 'address', 'service_location', 'actual_start', 'actual_end', 'status'], limit: 5000,
})
const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 } // ordre d'affichage
const occChars = readJobChar().items // table additive (1 lecture) → durée EFFECTIVE estimée par job
const m = {}
for (const j of jobs) {
if (!j.assigned_tech || !j.scheduled_date) continue
const k = j.assigned_tech + '|' + j.scheduled_date
const o = m[k] || (m[k] = { h: 0, blocks: [], jobs: [] })
const estMin = estimateForJob(j, occChars).minutes
const dur = estMin ? estMin / 60 : (Number(j.duration_h) || 0) // estimation additive (sinon duration_h)
const done = j.status === 'Completed'
const cancelled = j.status === 'Cancelled'
// Job FAIT/ANNULÉ daté dans le FUTUR = artefact (ticket F fermé mais due_date future via le pont legacy) → JAMAIS du travail à venir.
// On le retire du tableau (les 2 plateformes) ; la trace du travail passé/du jour (fait/annulé) reste affichée.
if ((done || cancelled) && j.scheduled_date && j.scheduled_date > TODAY_ET) continue
if (!cancelled) o.h += dur // les annulés n'ont pas consommé de temps → hors capacité
const skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject) // compétence → couleur du bloc (palette skills)
const s = j.start_time ? timeToH(j.start_time) : null
if (s != null && !cancelled) o.blocks.push({ s, e: s + dur, skill, job: j.name, done }) // 1 bloc = 1 job, coloré par sa compétence
const actualMin = (j.actual_start && j.actual_end) ? Math.max(0, Math.round((Date.parse(j.actual_end.replace(' ', 'T')) - Date.parse(j.actual_start.replace(' ', 'T'))) / 60000)) : null
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', customer_id: j.customer || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: estMin, priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', status: j.status || '', done, cancelled, legacy_id: j.legacy_ticket_id || '', legacy_dept: j.legacy_dept || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: j.actual_start || '', actual_end: j.actual_end || '', actual_min: actualMin })
}
// ── ÉQUIPE : reporte la charge des assistants ÉPINGLÉS sur LEUR propre lane (mirror) ──
// 1 requête enfant UNIQUE (pas de N+1). Le lead reste compté via assigned_tech ci-dessus ;
// l'assistant épinglé voit un bloc miroir à l'heure du lead, dimensionné à SA durée → anti double-booking.
const jobNames = jobs.map(j => j.name)
if (jobNames.length) {
const jByName = {}; for (const j of jobs) jByName[j.name] = j
let assists = []
try {
assists = await erp.list('Dispatch Job Assistant', {
filters: [['parent', 'in', jobNames], ['pinned', '=', 1]],
fields: ['parent', 'tech_id', 'tech_name', 'duration_h'], limit: 5000,
})
} catch (e) { assists = [] } // table absente / aucun assistant → silencieux (0 impact)
for (const a of assists) {
const j = jByName[a.parent]; if (!j || !a.tech_id || !j.scheduled_date) continue
const k = a.tech_id + '|' + j.scheduled_date
const o = m[k] || (m[k] = { h: 0, blocks: [], jobs: [] })
const skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject)
const jDur = (estimateForJob(j, occChars).minutes / 60) || Number(j.duration_h) || 0
const dur = Number(a.duration_h) || jDur || 1 // durée propre de l'assistant (≤ job)
o.h += dur
const s = j.start_time ? timeToH(j.start_time) : null
if (s != null) o.blocks.push({ s, e: s + dur, skill, job: j.name, assist: true })
o.jobs.push({ name: j.name, subject: '👥 ' + (j.subject || j.service_type || j.name), customer: j.customer_name || '', customer_id: j.customer || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: Math.round(dur * 60), priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', legacy_id: j.legacy_ticket_id || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: '', actual_end: '', actual_min: null, assist: true, lead_tech: j.assigned_tech || '' })
}
}
// ordre = route_order manuel s'il existe, sinon priorité puis heure
for (const k in m) m[k].jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || (PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3) || ((a.start_h ?? 99) - (b.start_h ?? 99)))
return m
}
// Absences par (technicien, jour) : En pause (global) + Tech Availability approuvées.
// Sert à hachurer la grille (absent ≠ garde).
async function absencesByTechDay (start, days) {
const dates = rangeDates(start, days); const lo = dates[0]; const hi = dates[dates.length - 1]
const techs = await fetchTechnicians()
const m = {}
for (const t of techs) if (t.status === PAUSE_STATUS) for (const d of dates) m[t.id + '|' + d] = 'En pause'
const avs = await erp.list('Tech Availability', {
filters: [['status', '=', 'Approuvé'], ['from_date', '<=', hi], ['to_date', '>=', lo]],
fields: ['technician', 'from_date', 'to_date', 'availability_type', 'long_term'], limit: 1000,
})
for (const a of avs) for (const d of dates) if (d >= a.from_date && d <= a.to_date) m[a.technician + '|' + d] = (a.availability_type || 'Absent') + (a.long_term ? ' (longue durée)' : '')
return m
}
// ── Routeur ──────────────────────────────────────────────────────────────────
// technician_id n'est pas le docname → résoudre le docname Dispatch Technician.
async function resolveTechName (techId) {
const f = await erp.list('Dispatch Technician', { filters: [['technician_id', '=', techId]], fields: ['name'], limit: 1 })
return f.length ? f[0].name : null
}
// ── CAPACITÉ par jour, découpée en segments AM / PM / Soir ──────────────────
// Capacité d'un segment = Σ (recouvrement du quart de chaque tech assigné ce jour-là avec la plage du segment).
// Utilisé = Σ (recouvrement des jobs PLACÉS (start_time) ce jour-là avec le segment).
// Libre = capacité utilisé (heures-tech restantes). due_h = Σ durée estimée des jobs DUS ce jour (placés ou non) ;
// unplaced_h = part des dus non encore placés. overbooked = due_h > capacité totale du jour.
const DAY_SEGMENTS = [
{ key: 'am', label: 'AM', from: 8, to: 12 },
{ key: 'pm', label: 'PM', from: 12, to: 17 },
{ key: 'soir', label: 'Soir', from: 17, to: 21 },
]
const segOverlap = (s, e, a, b) => Math.max(0, Math.min(e, b) - Math.max(s, a))
const r1 = (x) => Math.round(x * 10) / 10
async function capacityByDay (start, days, skills = null) {
const dates = rangeDates(start, days)
const templates = await fetchTemplates()
const tpl = {}; for (const t of templates) tpl[t.name] = { s: timeToH(t.start_time), e: timeToH(t.end_time) }
const asgs = await fetchAssignments(start, days) // Proposé + Publié = présence planifiée du tech
// Capacité RÉELLE : un quart matérialisé subsiste même quand le tech est en congé/pause/archivé → on retranche ces
// (tech, jour) sinon le dénominateur gonfle (ex. « 24/150 » alors que plusieurs techs sont absents). Aligné sur le
// moteur de créneaux + le solveur (buildUnavailability = En pause + absence_from/until + Tech Availability approuvé).
let techsActive = await fetchTechnicians() // exclut les techs archivés
// FILTRE COMPÉTENCE (optionnel) : le dénominateur ne compte QUE les techs qui ont la/les compétence(s) demandée(s).
// Ex. « installation » → seuls les installateurs comptent (fini « 150 h dispo » alors que la moitié ne peut pas installer).
const wantSkills = (Array.isArray(skills) ? skills : (skills ? [skills] : [])).map(s => String(s || '').trim()).filter(Boolean)
if (wantSkills.length) techsActive = techsActive.filter(t => techHasSkills(t.skills, wantSkills))
const validTech = new Set(techsActive.map(t => t.id))
const unavail = await buildUnavailability(techsActive, dates)
const isUnavailable = (tech, date) => !validTech.has(tech) || (unavail[tech] && unavail[tech].has(date))
const jobs = await erp.list('Dispatch Job', {
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress', 'On Hold']]],
fields: ['name', 'scheduled_date', 'start_time', 'duration_h', 'assigned_tech'], limit: 5000,
})
const out = {}
for (const d of dates) out[d] = { date: d, segments: DAY_SEGMENTS.map(s => ({ key: s.key, label: s.label, cap: 0, used: 0, free: 0 })), cap_h: 0, used_h: 0, free_h: 0, due_h: 0, jobs_due: 0, unplaced_h: 0, techs: 0 }
const techSeen = {}
for (const a of asgs) {
const o = out[a.date]; if (!o) continue
if (isUnavailable(a.tech, a.date)) continue // congé / pause / archivé → ne compte pas dans la capacité du jour
const w = tpl[a.shift]; if (!w || !(w.e > w.s)) continue
DAY_SEGMENTS.forEach((seg, i) => { o.segments[i].cap += segOverlap(w.s, w.e, seg.from, seg.to) })
const tk = a.date + '|' + a.tech; if (!techSeen[tk]) { techSeen[tk] = 1; o.techs++ }
}
for (const j of jobs) {
const o = out[j.scheduled_date]; if (!o) continue
// Sous filtre compétence : ne compter que ce qui occupe VRAIMENT un tech qualifié (assigné à un tech du dénominateur).
// On ignore les jobs assignés à un tech non qualifié + les jobs non placés (compétence inconnue ici) → used/due
// restent cohérents avec la capacité réduite (sinon due_h > cap_h ⇒ « surbooké » faux systématique).
if (wantSkills.length && (!j.assigned_tech || !validTech.has(j.assigned_tech))) continue
const dur = Number(j.duration_h) || 0
o.due_h += dur; o.jobs_due++
const sh = j.start_time ? timeToH(j.start_time) : null
if (sh != null && dur > 0) DAY_SEGMENTS.forEach((seg, i) => { o.segments[i].used += segOverlap(sh, sh + dur, seg.from, seg.to) })
else if (!j.assigned_tech) o.unplaced_h += dur // dû mais pas encore placé sur l'horaire
// Job ASSIGNÉ sans heure (miroir F due_time 'day', legacy) : il occupe QUAND MÊME le tech ce jour-là.
// Avant : ni used ni unplaced → la journée paraissait libre et on suggérait le même tech (double-booking).
else if (dur > 0) o.untimed_used_h = (o.untimed_used_h || 0) + dur
}
for (const d of dates) {
const o = out[d]
// Répartir la charge assignée SANS heure au prorata de la capacité de chaque segment (on ne sait pas QUAND,
// mais on sait que ces heures ne sont plus libres).
const unt = o.untimed_used_h || 0
if (unt > 0) { const capTot = o.segments.reduce((x, s) => x + s.cap, 0); if (capTot > 0) o.segments.forEach(s => { s.used += unt * (s.cap / capTot) }) }
o.untimed_used_h = r1(unt)
o.segments.forEach(s => { s.cap = r1(s.cap); s.used = r1(s.used); s.free = r1(s.cap - s.used) })
o.cap_h = r1(o.segments.reduce((x, s) => x + s.cap, 0))
o.used_h = r1(o.segments.reduce((x, s) => x + s.used, 0))
o.free_h = r1(o.cap_h - o.used_h)
o.due_h = r1(o.due_h); o.unplaced_h = r1(o.unplaced_h)
o.overbooked = o.cap_h > 0 && o.due_h > o.cap_h
}
return out
}
async function handle (req, res, method, path, url) {
const qs = url.searchParams
const start = qs.get('start')
const days = parseInt(qs.get('days') || '7', 10)
if (path === '/roster/technicians' && method === 'GET') {
if (url && url.searchParams && url.searchParams.get('archived') === '1') {
const arch = await fetchArchivedTechnicians()
return json(res, 200, { technicians: arch, count: arch.length, archived: true })
}
const techs = await fetchTechnicians()
return json(res, 200, { technicians: techs, count: techs.length })
}
if (path === '/roster/templates' && method === 'GET') {
return json(res, 200, { templates: await fetchTemplates() })
}
if (path === '/roster/templates' && method === 'POST') {
const b = await parseBody(req)
const r = await erp.create('Shift Template', {
template_name: b.template_name, start_time: b.start_time, end_time: b.end_time,
hours: b.hours, color: b.color || '#1976d2', zone: b.zone || '',
default_required: b.default_required || 1, required_skills: b.required_skills || '', active: 1,
on_call: b.on_call ? 1 : 0,
})
return json(res, r.ok ? 200 : 500, r)
}
if (path === '/roster/requirements' && method === 'GET') {
if (!start) return json(res, 400, { error: 'start requis (YYYY-MM-DD)' })
return json(res, 200, { requirements: await fetchRequirements(start, days) })
}
if (path === '/roster/requirements' && method === 'POST') {
const b = await parseBody(req)
const r = await erp.create('Shift Requirement', {
requirement_date: b.requirement_date, shift_template: b.shift_template, zone: b.zone || '',
required_count: b.required_count || 1, required_skills: b.required_skills || '',
})
return json(res, r.ok ? 200 : 500, r)
}
if (path === '/roster/assignments' && method === 'GET') {
if (!start) return json(res, 400, { error: 'start requis' })
return json(res, 200, { assignments: await fetchAssignments(start, days) })
}
if (path === '/roster/coverage' && method === 'GET') {
if (!start) return json(res, 400, { error: 'start requis' })
return json(res, 200, { coverage: await coverage(start, days) })
}
if (path === '/roster/capacity' && method === 'GET') { // dispo restante par jour × segment (AM/PM/Soir)
if (!start) return json(res, 400, { error: 'start requis' })
// ?skill=installation (ou ?skill=a,b) → dénominateur filtré : seuls les techs qui ont la/les compétence(s).
const skills = (qs.get('skill') || '').split(',').map(s => s.trim()).filter(Boolean)
return json(res, 200, { capacity: await capacityByDay(start, days, skills), segments: DAY_SEGMENTS, skill: skills })
}
// Résolveur « raison → compétence(s) » — appelé par <AvailabilityByReason> (ticket / courriel / fiche client).
// Body : { text?, department?, jobType?, useAI? } → { skills, primary, confidence, source, reason }.
if (path === '/roster/resolve-skills' && method === 'POST') {
const b = await parseBody(req)
return json(res, 200, await require('./skill-resolver').resolveSkills(b || {}))
}
// Proxys OSRM pour le SPA (points = [[lon,lat],…]) — remplacent Mapbox Matrix/Directions (payants) par l'OSRM auto-hébergé.
if (path === '/roster/osrm-table' && method === 'POST') {
const b = await parseBody(req); const q = osrmPts(b, 60)
if (!q) return json(res, 400, { error: 'points [[lon,lat],…] requis (2-60)' })
try {
const j = await osrmGet('/table/v1/driving/' + q + '?annotations=duration,distance')
if (j.code !== 'Ok') throw new Error('code ' + (j.code || '?'))
return json(res, 200, { durations: j.durations || [], distances: j.distances || [] }) // même forme que Mapbox Matrix (secondes/mètres)
} catch (e) { return json(res, 502, { error: 'osrm: ' + (e && e.message) }) }
}
if (path === '/roster/osrm-route' && method === 'POST') {
const b = await parseBody(req); const q = osrmPts(b, 60)
if (!q) return json(res, 400, { error: 'points [[lon,lat],…] requis (2-60)' })
try {
// continue_straight=false : autorise le demi-tour aux waypoints intermédiaires. Sinon OSRM INTERDIT le U-turn à l'arrêt
// → il DÉPASSE l'adresse et tourne dans une rue pour revenir (bug « s'éloigne et tourne » signalé sur la job d'Anthony).
const j = await osrmGet('/route/v1/driving/' + q + '?overview=full&geometries=geojson&steps=false&continue_straight=false')
const r0 = (j.code === 'Ok' && j.routes && j.routes[0]) || null
if (!r0) throw new Error('code ' + (j.code || 'vide'))
return json(res, 200, {
km: Math.round(r0.distance / 100) / 10, mins: Math.round(r0.duration / 60),
geometry: (r0.geometry && r0.geometry.coordinates) || [], // LineString [[lon,lat],…] (tracé routier réel)
legs: (r0.legs || []).map(l => ({ km: Math.round(l.distance / 100) / 10, mins: Math.round(l.duration / 60) })), // temps de route ENTRE chaque arrêt
})
} catch (e) { return json(res, 502, { error: 'osrm: ' + (e && e.message) }) }
}
if (path === '/roster/osrm-trip' && method === 'POST') { // TSP OSRM (remplace Mapbox optimized-trips) : ordre de visite optimal depuis le 1er point
const b = await parseBody(req); const q = osrmPts(b, 12)
if (!q) return json(res, 400, { error: 'points [[lon,lat],…] requis (2-12)' })
try {
const j = await osrmGet('/trip/v1/driving/' + q + '?source=first&roundtrip=false&destination=last&overview=false')
if (j.code !== 'Ok') throw new Error('code ' + (j.code || '?'))
return json(res, 200, { code: 'Ok', waypoints: (j.waypoints || []).map(w => ({ waypoint_index: w.waypoint_index })) }) // même forme que Mapbox optimized-trips
} catch (e) { return json(res, 502, { code: 'Error', error: 'osrm: ' + (e && e.message) }) }
}
// Optimisation de TOURNÉES (VRPTW + compétences + temps sur place) — proxy vers le solveur OR-Tools /route.
// Le SPA construit le payload {jobs, vehicles, ...} (il a coords/skills/durées/domiciles/quarts) ; on relaie au solveur interne.
if (path === '/roster/optimize-plan' && method === 'POST') { // P4 — orchestration COMPLÈTE hub (pool+quarts+occupation+règles d'adresse) → plan prêt pour la revue / cron auto-plan
const b = await parseBody(req)
try { return json(res, 200, await optimizePlan(b)) }
catch (e) { log('optimize-plan err: ' + ((e && e.stack) || e)); return json(res, 500, { ok: false, error: String((e && e.message) || e) }) }
}
if (path === '/roster/optimize-routes' && method === 'POST') {
const body = await parseBody(req)
if (!Array.isArray(body.jobs) || !Array.isArray(body.vehicles)) return json(res, 400, { error: 'jobs[] et vehicles[] requis' })
try { return json(res, 200, await solveVrp(body)) } // matrice OSRM auto si absente ; repli haversine dans le solveur
catch (e) { return json(res, 502, { status: 'ERROR', message: 'solveur injoignable: ' + (e && e.message) }) }
}
// Jours fériés QC (déterministe) — sert le badge « férié » du day-strip + la réduction de capacité par défaut.
if (path === '/roster/holidays' && method === 'GET') {
const from = url.searchParams.get('from') || todayET()
const to = url.searchParams.get('to') || iso(addDays(new Date(), 120))
return json(res, 200, { holidays: require('./holidays-qc').holidaysBetween(from, to) })
}
// Préavis de disponibilité AVANT un férié (~1 mois) : GET = aperçu (qui reste à notifier, AUCUN envoi) ; POST {date} = envoie le SMS aux techs non encore notifiés (idempotent).
if (path === '/roster/holiday-notice' && (method === 'GET' || method === 'POST')) {
const days = Math.min(120, Math.max(1, Number(url.searchParams.get('days')) || 35))
const hols = require('./holidays-qc').holidaysBetween(todayET(), iso(addDays(new Date(), days)))
const sent = readJsonFile('/app/data/holiday_notices.json', {}) // { 'YYYY-MM-DD': [techId,…] } — anti-doublon
const techs = (await fetchTechnicians()).filter(t => t.phone && /\d/.test(String(t.phone)))
if (method === 'GET') {
return json(res, 200, {
window_days: days, techs_with_phone: techs.length,
holidays: hols.map(h => ({ date: h.date, name: h.name, notified: (sent[h.date] || []).length, to_notify: techs.filter(t => !(sent[h.date] || []).includes(t.id)).length })),
})
}
const b = await parseBody(req); const date = String(b.date || (hols[0] && hols[0].date) || '')
const hol = hols.find(h => h.date === date) || require('./holidays-qc').isHoliday(date)
if (!hol) return json(res, 200, { ok: false, error: 'date non fériée ou hors fenêtre' })
const already = new Set(sent[date] || []); const sendSms = require('./twilio').sendSmsInternal
let count = 0; const failed = []
for (const t of techs) {
if (already.has(t.id)) continue
const msg = `Targo — ${hol.name} (${date}) est un jour férié. Travailles-tu cette journée ? Sinon, indique ta disponibilité (et des dates de remplacement) dans ton app Targo. Merci de répondre tôt.`
try { await sendSms(t.phone, msg, null); already.add(t.id); count++ } catch (e) { failed.push(t.id) }
}
sent[date] = [...already]; writeJsonFile('/app/data/holiday_notices.json', sent)
return json(res, 200, { ok: true, date, name: hol.name, sent: count, failed })
}
// CROISEMENT temps estimé ↔ geofencing (rec C) : compare l'estimé (duration_h) au RÉEL sur site (actual_end actual_start,
// écrit par le webhook geofence /field/ts). Agrège par tech et par catégorie → calibrer les estimés + mémoire de fit.
if (path === '/roster/geofence-stats' && method === 'GET') {
const days = Math.min(365, Math.max(1, Number(url.searchParams.get('days')) || 90))
const since = iso(addDays(new Date(), -days))
let jobs = []
// ⚠️ FILTRER un datetime (actual_start) plante en v16 (HTTP 500) → on filtre par date + en CODE (minutesBetween skip les vides).
try { jobs = await erp.list('Dispatch Job', { filters: [['scheduled_date', '>', since]], fields: ['name', 'assigned_tech', 'job_type', 'duration_h', 'actual_start', 'actual_end', 'scheduled_date'], limit: 5000 }) } catch (e) { return json(res, 200, { ok: false, error: e.message }) }
const perTech = {}, perCat = {}, rows = []
for (const j of (jobs || [])) {
const actual = minutesBetween(j.actual_start, j.actual_end); if (!actual || actual <= 0) continue
const est = j.duration_h ? Math.round(j.duration_h * 60) : 0
const cat = j.job_type || 'Autre'; const tech = j.assigned_tech || '—'
rows.push({ job: j.name, tech, cat, est_min: est, actual_min: actual, variance_pct: est ? Math.round((actual - est) / est * 100) : null })
for (const [map, key] of [[perTech, tech], [perCat, cat]]) { const m = map[key] || (map[key] = { n: 0, est: 0, actual: 0 }); m.n++; m.est += est; m.actual += actual }
}
const agg = (map) => Object.entries(map).map(([k, v]) => ({ key: k, n: v.n, avg_est_min: v.n ? Math.round(v.est / v.n) : 0, avg_actual_min: v.n ? Math.round(v.actual / v.n) : 0, avg_variance_pct: v.est ? Math.round((v.actual - v.est) / v.est * 100) : null })).sort((a, b) => b.n - a.n)
return json(res, 200, { ok: true, window_days: days, geofenced_jobs: rows.length, per_tech: agg(perTech), per_category: agg(perCat), sample: rows.slice(0, 50) })
}
// Validation « service livré » d'un job : tire le statut service+modem (en ligne + signal) pour le client/adresse du
// job, via le MÊME résolveur que la Boîte (serviceStatus). Croise tech-présent (geofence) + service-en-ligne.
if (path === '/roster/job-service-status' && method === 'GET') {
const jobName = url.searchParams.get('job')
if (!jobName) return json(res, 400, { ok: false, error: 'job requis' })
let job; try { job = await erp.get('Dispatch Job', jobName) } catch (e) { return json(res, 200, { ok: false, error: 'job introuvable' }) }
const arg = job.customer ? { customer: job.customer, location: job.service_location || '' } : { q: job.address || job.customer_name || '' }
try { return json(res, 200, await require('./ticket-collab').serviceStatus(arg)) }
catch (e) { log('job-service-status ' + jobName + ': ' + e.message); return json(res, 200, { ok: false, error: e.message }) }
}
if (path === '/roster/job-characteristics' && method === 'GET') return json(res, 200, { ok: true, ...readJobChar() }) // table additive de durées (éditable)
if (path === '/roster/job-characteristics' && method === 'POST') { // sauvegarde du tableur inline
const b = await parseBody(req); if (!Array.isArray(b.items)) return json(res, 400, { ok: false, error: 'items requis' })
const clean = b.items.filter(i => i && i.id && i.label).map(i => ({ id: String(i.id), cat: ['base', 'addon', 'modifier'].includes(i.cat) ? i.cat : 'addon', label: String(i.label).slice(0, 80), minutes: Math.max(0, Math.round(Number(i.minutes) || 0)), per_qty: !!i.per_qty, keywords: String(i.keywords || '').slice(0, 160), learned_min: i.learned_min != null ? Math.round(Number(i.learned_min)) : null, samples: Number(i.samples) || 0 }))
const ok = writeJobChar(clean); return json(res, ok ? 200 : 500, { ok, count: clean.length })
}
if (path === '/roster/tech-link' && method === 'GET') { // lien app PWA par tech (à copier / SMS) + son téléphone
const tech = qs.get('tech'); if (!tech) return json(res, 400, { ok: false, error: 'tech requis' })
const rows = await erp.list('Dispatch Technician', { filters: [['name', '=', tech]], fields: ['name', 'full_name', 'phone'], limit: 1 })
const t = rows && rows[0]
return json(res, 200, { ok: true, tech, name: (t && t.full_name) || tech, phone: (t && t.phone) || '', url: techFieldLink(tech) })
}
if (path === '/roster/stats' && method === 'GET') {
if (!start) return json(res, 400, { error: 'start requis' })
return json(res, 200, { stats: await statsByDay(start, days) })
}
if (path === '/roster/occupancy' && method === 'GET') {
if (!start) return json(res, 400, { error: 'start requis' })
return json(res, 200, { occupancy: await occupancyByTechDay(start, days) })
}
if (path === '/roster/absences' && method === 'GET') {
if (!start) return json(res, 400, { error: 'start requis' })
return json(res, 200, { absences: await absencesByTechDay(start, days) })
}
// Absence d'UN JOUR depuis la grille (approuvée → hachurée tout de suite) ou retrait (jour unique seulement).
if (path === '/roster/absence/set' && method === 'POST') {
const b = await parseBody(req)
if (!b.tech || !b.date) return json(res, 400, { error: 'tech + date requis' })
if (b.remove) {
const rows = await erp.list('Tech Availability', { filters: [['technician', '=', b.tech], ['from_date', '=', b.date], ['to_date', '=', b.date]], fields: ['name'], limit: 20 })
let removed = 0; for (const r of rows) { const x = await retryWrite(() => erp.remove('Tech Availability', r.name)); if (x.ok) removed++ }
return json(res, 200, { ok: true, removed })
}
const r = await retryWrite(() => erp.create('Tech Availability', { technician: b.tech, from_date: b.date, to_date: b.date, availability_type: b.type || 'Congé', status: 'Approuvé', reason: 'Grille' }))
return json(res, r.ok ? 200 : 500, r)
}
// Prise de RDV : créneaux dispo (roster + compétence + zone) pour proposer/valider
if (path === '/roster/book/slots' && method === 'GET') {
if (!start) return json(res, 400, { error: 'start requis' })
return json(res, 200, { slots: await bookingSlots({ skill: qs.get('skill') || '', zone: qs.get('zone') || '', duration: qs.get('duration') || 1, start, days, limit: parseInt(qs.get('limit') || '24', 10), aggregate: qs.get('aggregate') === '1' }) })
}
// Jobs à planifier (worklist du répartiteur)
if (path === '/roster/book/jobs' && method === 'GET') {
const rows = await erp.list('Dispatch Job', {
filters: [['status', 'in', ['open', 'assigned']]],
fields: ['name', 'customer_name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'start_time', 'assigned_tech', 'booking_status', 'status'],
orderBy: 'modified desc', limit: 100,
})
for (const j of rows) j.required_skill = skillForJob(j) // tag requis (table service_type → compétence)
await attachLocations(rows) // adresse lisible (service_location = code)
return json(res, 200, { jobs: rows })
}
// Méta pour l'éditeur de la table « type de job → compétence requise » (#56 booking)
if (path === '/roster/book/meta' && method === 'GET') {
const tk = await fetchTechnicians()
const skills = [...new Set(tk.flatMap(t => t.skills || []))].sort()
let types = []
try { const jb = await erp.list('Dispatch Job', { fields: ['service_type'], limit: 3000 }); types = [...new Set(jb.map(j => j.service_type).filter(Boolean))].sort() } catch (e) {}
return json(res, 200, { service_types: types, skills, skill_by_type: getBookingPolicy().skill_by_type || {}, level_by_type: getBookingPolicy().level_by_type || {}, level_by_skill: getBookingPolicy().level_by_skill || {} })
}
// Générer le lien client (token) pour un job → URL publique /book?token=
if (path === '/roster/book/link' && method === 'POST') {
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
const job = await erp.get('Dispatch Job', b.job, { fields: ['name', 'booking_token'] })
if (!job) return json(res, 404, { error: 'job introuvable' })
let token = job.booking_token
if (!token) { token = crypto.randomBytes(12).toString('hex'); const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { booking_token: token })); if (!r.ok) return json(res, 500, r) }
return json(res, 200, { ok: true, token, url: (cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca') + '/book?token=' + token })
}
// Aviser le client d'un report : lien /book + SMS Twilio + statut « À reporter »
if (path === '/roster/job/notify-reschedule' && method === 'POST') {
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
const job = await erp.get('Dispatch Job', b.job, { fields: ['name', 'booking_token', 'customer', 'customer_name'] })
if (!job) return json(res, 404, { error: 'job introuvable' })
let token = job.booking_token
if (!token) { token = crypto.randomBytes(12).toString('hex'); await retryWrite(() => erp.update('Dispatch Job', b.job, { booking_token: token })) }
const url = (cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca') + '/book?token=' + token
// Désassigner (retour au pool) : on vide le créneau pour que /book repropose des options
await retryWrite(() => erp.update('Dispatch Job', b.job, { booking_status: 'À reporter', scheduled_date: null, start_time: null, assigned_tech: null, status: 'open' }))
let phone = b.phone
if (!phone && job.customer) { try { const c = await erp.get('Customer', job.customer, { fields: ['mobile_no'] }); phone = c && c.mobile_no } catch (e) {} }
if (!phone) return json(res, 200, { ok: true, url, sms: false, note: 'Statut « À reporter » posé. Aucun téléphone trouvé — fournir "phone" pour envoyer le SMS.' })
const msg = b.message || `Bonjour, votre rendez-vous Gigafibre doit être reporté. Choisissez un nouveau créneau qui vous convient : ${url} Merci de votre compréhension.`
let sid = null
try { sid = await require('./twilio').sendSmsInternal(phone, msg, job.customer) } catch (e) { return json(res, 200, { ok: true, url, sms: false, error: String(e.message || e) }) }
return json(res, 200, { ok: true, url, sms: !!sid, sid, phone })
}
// File « À reporter » (jobs à recontacter) — pour le superviseur
if (path === '/roster/jobs-to-reschedule' && method === 'GET') {
const rows = await erp.list('Dispatch Job', { filters: [['booking_status', '=', 'À reporter']], fields: ['name', 'customer_name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'assigned_tech', 'booking_token'], limit: 100 })
await attachLocations(rows || []) // adresse lisible
return json(res, 200, { jobs: rows || [] })
}
// Impact d'un retrait de compétence : jobs assignés à un tech qui EXIGENT cette compétence (devenus invalides).
if (path === '/roster/skill-impact' && method === 'GET') {
const tech = qs.get('tech'); const skill = qs.get('skill')
if (!tech || !skill) return json(res, 400, { error: 'tech + skill requis' })
const rows = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', tech], ['status', 'in', ['open', 'assigned']]], fields: ['name', 'customer_name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'start_time'], limit: 500 })
const impacted = (rows || []).filter(j => skillForJob(j) === skill)
await attachLocations(impacted)
return json(res, 200, { jobs: impacted })
}
// Impact d'une ABSENCE : jobs assignés à un tech sur des dates données (devenus à redistribuer).
if (path === '/roster/absence-impact' && method === 'GET') {
const tech = qs.get('tech'); const dates = (qs.get('dates') || '').split(',').filter(Boolean)
if (!tech || !dates.length) return json(res, 400, { error: 'tech + dates requis' })
const rows = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', tech], ['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned']]], fields: ['name', 'customer_name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'start_time'], limit: 500 })
await attachLocations(rows || [])
return json(res, 200, { jobs: rows || [] })
}
// Candidats CLASSÉS pour reprendre un job : techs qualifiés + LIBRES au même créneau (≠ l'impacté).
if (path === '/roster/job-candidates' && method === 'GET') {
const jobName = qs.get('job'); const exclude = qs.get('exclude') || ''
if (!jobName) return json(res, 400, { error: 'job requis' })
let job = null
try { job = await erp.get('Dispatch Job', jobName, { fields: ['name', 'scheduled_date', 'start_time', 'duration_h', 'service_type'] }) } catch (e) {}
if (!job || !job.scheduled_date || !job.start_time) return json(res, 200, { candidates: [], date: (job && job.scheduled_date) || '', start: '' })
const skill = skillForJob(job); const start = String(job.start_time).slice(0, 5); const dur = Number(job.duration_h) || 1
const slots = await bookingSlots({ skill, duration: dur, start: job.scheduled_date, days: 1, limit: 500, ignorePolicy: true })
const seen = new Set(); const cands = []
for (const s of slots) { if (s.start !== start || s.tech === exclude || seen.has(s.tech)) continue; seen.add(s.tech); cands.push({ tech: s.tech, tech_name: s.tech_name }) }
return json(res, 200, { candidates: cands.slice(0, 6), date: job.scheduled_date, start, skill })
}
// Redistribuer les jobs impactés. 3 voies : b.plan (réassign explicite par tech / requeue) · b.mode 'auto' (re-match
// auto au même créneau, compétence b.skill ou par job) · 'requeue' (À recontacter).
if (path === '/roster/skill-impact/redistribute' && method === 'POST') {
const b = await parseBody(req); const mode = b.mode || 'requeue'
if (Array.isArray(b.plan)) { // plan explicite : { job, tech } (réassigner) OU { job, requeue:true }
let reassigned = 0; let requeued = 0; let errors = 0
for (const p of b.plan) {
if (!p || !p.job) { errors++; continue }
if (p.tech && !p.requeue) { const r = await retryWrite(() => erp.update('Dispatch Job', p.job, { assigned_tech: p.tech, status: 'assigned', booking_status: 'Confirmé' })); if (r.ok) reassigned++; else errors++ }
else { const r = await retryWrite(() => erp.update('Dispatch Job', p.job, { booking_status: 'À reporter', scheduled_date: null, start_time: null, assigned_tech: null, status: 'open' })); if (r.ok) requeued++; else errors++ }
}
return json(res, 200, { ok: errors === 0, reassigned, requeued, errors })
}
let reassigned = 0; let requeued = 0; let errors = 0; const details = []
for (const jobName of (b.jobs || [])) {
let job = null
try { job = await erp.get('Dispatch Job', jobName, { fields: ['name', 'scheduled_date', 'start_time', 'duration_h', 'customer_name', 'service_type'] }) } catch (e) {}
if (!job) { errors++; continue }
if (mode === 'auto' && job.scheduled_date && job.start_time) { // re-match au même créneau, tech qualifié ≠ l'impacté
const sk = b.skill || skillForJob(job) // compétence requise (globale si fournie, sinon par job → cas absence)
const r = await confirmWindow(jobName, job.scheduled_date, String(job.start_time).slice(0, 5), Number(job.duration_h) || 1, sk)
if (r.ok) { reassigned++; details.push({ job: jobName, customer: job.customer_name, tech: r.tech, action: 'réassigné' }); continue }
}
const r = await retryWrite(() => erp.update('Dispatch Job', jobName, { booking_status: 'À reporter', scheduled_date: null, start_time: null, assigned_tech: null, status: 'open' }))
if (r.ok) { requeued++; details.push({ job: jobName, customer: job.customer_name, action: 'à recontacter' }) } else errors++
}
return json(res, 200, { ok: errors === 0, reassigned, requeued, errors, details })
}
// Jobs À ASSIGNER (non assignés) avec leur groupe/dépendances (parent_job, depends_on, step_order, chaîne On Hold).
if (path === '/roster/unassigned-jobs' && method === 'GET') {
const age = _poolCache.jobs ? (nowMs() - _poolCache.ts) : Infinity
if (_poolCache.jobs && age < POOL_TTL_MS) return json(res, 200, { jobs: _poolCache.jobs, cached: true }) // frais → instantané
if (_poolCache.jobs) { refreshPoolBg(); return json(res, 200, { jobs: _poolCache.jobs, cached: 'stale' }) } // périmé → sert + rafraîchit en fond
try { const jobs = await buildUnassigned(); _poolCache = { ts: nowMs(), jobs, building: null }; return json(res, 200, { jobs }) } // à froid → build live
catch (e) { log('pool build err: ' + (e && e.message)); return json(res, 200, { jobs: [] }) }
}
// Assigner un job à un tech (depuis le panneau flottant glisser-déposer) — date = case déposée.
// On pose AUSSI un start_time (premier trou libre du shift) : sans heure, le job compte dans
// les heures occupées mais n'affiche AUCUN bloc sur la timeline → la barre d'occupation semble figée.
if (path === '/roster/assign-job' && method === 'POST') {
const b = await parseBody(req); if (!b.job || !b.tech) return json(res, 400, { error: 'job + tech requis' })
let dur = 1; let legacyId = ''
try { const jb = await erp.get('Dispatch Job', b.job, { fields: ['duration_h', 'legacy_ticket_id'] }); dur = Number(jb && jb.duration_h) || 1; legacyId = (jb && jb.legacy_ticket_id) || '' } catch (e) {}
// GARDE-FOU F (fenêtre aveugle du miroir ~3 min) : si le ticket legacy est DÉJÀ assigné dans F à un AUTRE vrai
// tech, on refuse (409 conflict) sauf force=true — le SPA affiche « Déjà assigné dans F à X — réassigner ? ».
// Best-effort : F injoignable ⇒ on n'empêche PAS l'assignation.
if (legacyId && !b.force) {
try {
const st = await require('./legacy-dispatch-sync').ticketAssignState(legacyId)
const sidTarget = (String(b.tech).match(/(\d{2,})$/) || [])[1] || ''
if (st && st.assigned && String(st.staff_id) !== sidTarget) {
return json(res, 409, { ok: false, conflict: true, ticket: legacyId, f_staff_id: st.staff_id, f_staff_name: st.staff_name || '', f_status: st.status || '', error: 'Déjà assigné dans F à ' + (st.staff_name || ('staff #' + st.staff_id)) })
}
} catch (e) {}
}
const patch = { assigned_tech: b.tech, status: 'assigned', duration_h: dur } // duration_h garanti → occupation comptée
if (b.date) patch.scheduled_date = b.date
let placed = null
if (b.start) patch.start_time = (String(b.start).length === 5 ? b.start + ':00' : b.start)
else if (b.date) { // premier trou libre dans le shift du tech ce jour-là
try { const d = await loadBookingData(b.date, 1); const h = firstFitStart(d, b.tech, b.date, dur); if (h != null) { placed = hToTime(h); patch.start_time = placed } } catch (e) {}
}
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
if (r.ok) invalidatePool() // le job quitte le pool → rafraîchir le cache
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, tech: b.tech, start_time: placed, duration_h: dur })
}
if (path === '/roster/job-level' && method === 'POST') { // niveau requis PERSISTANT par job (store hub durable)
const b = await parseBody(req); if (!b.name) return json(res, 400, { error: 'name requis' })
setJobLevel(b.name, b.level); invalidatePool() // le pool porte required_level → rafraîchir
return json(res, 200, { ok: true, name: b.name, level: Math.max(0, Math.min(5, Math.round(Number(b.level) || 0))) })
}
if (path === '/roster/job-skills' && method === 'POST') { // compétences requises PERSISTANTES par job (LISTE, store hub) → dispatch auto
const b = await parseBody(req); if (!b.name) return json(res, 400, { error: 'name requis' })
const m = setJobSkills(b.name, b.skills); invalidatePool() // le pool porte required_skills/required_skill → rafraîchir
return json(res, 200, { ok: true, name: b.name, skills: m[String(b.name)] || [] })
}
// MÉDIAS TERRAIN d'un job : photos prises par le tech (app terrain, /field/photo) + journal completion_notes
// (arrivées géofence, scans d'appareil, notes). Sert le module « Photos & activité terrain » du détail ticket/job.
// Les <img> passent par /field/photo-file (URL signée fieldSign — même modèle de jeton que l'app terrain).
if (path === '/roster/job-media' && method === 'GET') {
const name = qs.get('job') || ''
if (!name) return json(res, 400, { ok: false, error: 'job requis' })
let notes = ''
try { const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['completion_notes'], limit: 1 }); notes = (r && r[0] && r[0].completion_notes) || '' } catch (e) {}
const fs = require('fs'); const dir = '/app/uploads/field'
const prefix = 'f_' + String(name).replace(/[^a-zA-Z0-9_-]/g, '') + '_'
let photos = []
try {
photos = fs.readdirSync(dir).filter(f => f.startsWith(prefix) && /\.(jpg|png)$/.test(f))
.map(f => { const ts = Number((f.match(/_(\d{10,})\./) || [])[1]) || 0; return { file: f, at: ts ? new Date(ts).toISOString() : null, ts } })
.sort((a, b) => b.ts - a.ts).slice(0, 40)
} catch (e) {}
const base = (cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca').replace(/\/$/, '')
const tok = fieldSign(name)
for (const ph of photos) {
ph.url = base + '/field/photo-file?t=' + encodeURIComponent(tok) + '&f=' + encodeURIComponent(ph.file)
// Contexte de la photo depuis le journal (sur place / distance / note) — la ligne contient le nom de fichier.
const line = (notes.split('\n') || []).find(l => l.includes(ph.file))
if (line) { ph.onsite = line.includes('✓ sur place'); ph.offsite = line.includes('⚠ à '); const m = line.match(/📍 (✓ sur place \(≈[^)]+\)|⚠ à [^·—]+)/); ph.geo = m ? m[1].trim() : ''; const n = line.match(/—\s*(.+)$/); ph.note = n ? n[1].trim() : '' }
}
return json(res, 200, { ok: true, job: name, photos, notes })
}
// Répondre au fil du billet DEPUIS LE VOLET JOB (dispatcher authentifié) — même mécanique que l'app terrain, mais l'acteur = l'agent connecté.
// Note INTERNE par défaut (jamais au client) ; publique (courriel client) SEULEMENT si public=true.
// #5 — Bloc RÉSERVÉ : réserve le temps d'un tech pour une tâche/projet = Dispatch Job job_type='Réservation', priorité MOYENNE,
// assigné, avec heure+durée → occupe l'occupation ⇒ dé-priorise le tech au dispatch auto ET soustrait des créneaux. « Soft » :
// la recherche de créneau en mode urgence/réparation IGNORE ces blocs (dispatch.suggestSlots ignoreReserved) → réparations/urgences passent.
if (path === '/roster/reserve' && method === 'POST') {
const b = await parseBody(req)
if (!b.tech || !b.date) return json(res, 400, { ok: false, error: 'tech + date requis' })
try {
const ref = await require('./dispatch').nextJobRef()
const payload = {
ticket_id: ref, subject: b.reason ? ('Réservé — ' + String(b.reason).slice(0, 80)) : 'Réservé (indisponible au dispatch)',
job_type: 'Réservation', priority: 'medium', status: 'assigned',
assigned_tech: b.tech, scheduled_date: b.date,
start_time: b.start_time || '08:00', duration_h: Number(b.duration_h) || 2,
}
const r = await retryWrite(() => erp.create('Dispatch Job', payload))
if (r && r.ok !== false) { invalidatePool(); return json(res, 200, { ok: true, name: r.name || (r.data && r.data.name), ref }) }
return json(res, 500, { ok: false, error: (r && r.error) || 'création échouée' })
} catch (e) { return json(res, 500, { ok: false, error: e.message }) }
}
// Job « bouche-trou » (filler) : job GÉNÉRIQUE de priorité STANDARD assigné à un tech pour occuper sa journée
// (formation, réunion, admin, entretien véhicule…) → il compte dans l'occupation ⇒ RETIRÉ du dispatch régulier
// (contrairement à /reserve = « soft », les urgences le traversent). Optionnel : génère aussi un TICKET (Issue) lié.
if (path === '/roster/filler-job' && method === 'POST') {
const b = await parseBody(req)
if (!b.tech || !b.date) return json(res, 400, { ok: false, error: 'tech + date requis' })
try {
const subject = String(b.subject || 'Tâche interne — indisponible au dispatch').slice(0, 140)
// Ticket (Issue) best-effort : un échec de billet ne doit PAS empêcher la création du job (le job = le cœur).
let issueName = ''
if (b.create_ticket) {
try {
const prio = { low: 'Low', medium: 'Medium', high: 'High' }[String(b.priority || 'medium')] || 'Medium'
const iss = await retryWrite(() => erp.create('Issue', { subject, status: 'Open', priority: prio }))
issueName = (iss && (iss.name || (iss.data && iss.data.name))) || ''
} catch (e) { log('filler: ticket create failed — ' + e.message) }
}
const ref = await require('./dispatch').nextJobRef()
const payload = {
ticket_id: ref, subject,
job_type: b.job_type || 'Interne', priority: b.priority || 'medium', status: 'assigned',
assigned_tech: b.tech, scheduled_date: b.date,
start_time: b.start_time || '08:00', duration_h: Number(b.duration_h) || 8,
source_issue: issueName, notes: b.notes || '',
}
const r = await retryWrite(() => erp.create('Dispatch Job', payload))
if (r && r.ok !== false) { invalidatePool(); return json(res, 200, { ok: true, name: r.name || (r.data && r.data.name), ref, issue: issueName }) }
return json(res, 500, { ok: false, error: (r && r.error) || 'création échouée' })
} catch (e) { return json(res, 500, { ok: false, error: e.message }) }
}
if (path === '/roster/job-comment' && method === 'POST') {
const b = await parseBody(req)
const text = String(b.text || '').trim(); if (!text) return json(res, 400, { ok: false, error: 'texte requis' })
const isPublic = !!b.public
const actorEmail = req.headers['x-authentik-email'] || b.agent || '' // attribution : l'agent connecté (→ compte staff osTicket = son nom)
const actorName = b.agentName || ''
try {
let lid = b.lid || null
if (!lid && b.job) { const j = await erp.get('Dispatch Job', b.job, { fields: ['legacy_ticket_id'] }); lid = j && j.legacy_ticket_id }
if (lid) {
const r = await require('./legacy-dispatch-sync').postTicketLegacy(lid, text, isPublic, actorEmail, actorName)
return json(res, 200, { ok: !!(r && r.ok !== false), public: isPublic, sentToCustomer: isPublic })
}
if (b.job) { // job natif sans billet legacy → Comment ERPNext (préfixé du nom de l'agent, l'auteur ERPNext = compte service)
await erp.create('Comment', { comment_type: 'Comment', reference_doctype: 'Dispatch Job', reference_name: b.job, content: (actorName ? actorName + ' : ' : '') + text })
return json(res, 200, { ok: true, public: isPublic })
}
return json(res, 400, { ok: false, error: 'job ou lid requis' })
} catch (e) { return json(res, 500, { ok: false, error: e.message }) }
}
if (path === '/roster/job-remote' && method === 'POST') { // 🖥 « sans déplacement » (configurable à distance, ex. boîtier tel via ACS) — exclu des tournées
const b = await parseBody(req); if (!b.name) return json(res, 400, { error: 'name requis' })
setJobRemote(b.name, !!b.remote); invalidatePool() // le pool porte j.remote → rafraîchir
return json(res, 200, { ok: true, name: b.name, remote: !!b.remote })
}
if (path === '/roster/job-group' && method === 'POST') { // 📦 grouper des tâches (même adresse) en « projet » : parent_job ERPNext sur les enfants
const b = await parseBody(req); const kids = (Array.isArray(b.children) ? b.children : []).filter(c => c && c !== b.parent)
if (!b.parent || !kids.length) return json(res, 400, { error: 'parent + children[] requis' })
const results = []
for (const c of kids) { const r = await retryWrite(() => erp.update('Dispatch Job', c, { parent_job: b.parent })); results.push({ job: c, ok: !!r.ok }) }
invalidatePool()
return json(res, 200, { ok: results.every(x => x.ok), parent: b.parent, results })
}
// Persister UN quart immédiatement (ex. « Créer un quart et assigner » sur un tech sans quart) — sinon `addShift` reste LOCAL
// (status Proposé en mémoire) et le quart disparaît au rechargement. Idempotent par clé tech|date|template (pas de doublon vs Publier).
if (path === '/roster/shift' && method === 'POST') {
const b = await parseBody(req); if (!b.tech || !b.date || !b.shift) return json(res, 400, { error: 'tech + date + shift requis' })
const existing = await fetchAssignments(b.date, 1)
if (existing.some(a => a.tech === b.tech && a.date === b.date && a.shift === b.shift)) return json(res, 200, { ok: true, existed: true })
const r = await retryWrite(() => erp.create('Shift Assignment', {
technician: b.tech, technician_name: b.tech_name || '', assignment_date: b.date,
shift_template: b.shift, zone: b.zone || '', hours: Number(b.hours) || 0,
status: 'Publié', source: 'manuel',
}))
return json(res, r.ok ? 200 : 500, { ...r, name: r.name })
}
// Réordonner / re-prioriser les jobs d'un tech×jour (depuis le menu de la cellule).
// body.updates = [{ job, route_order, priority? }] — SÉQUENTIEL (frappe_pg).
if (path === '/roster/reorder-jobs' && method === 'POST') {
const b = await parseBody(req); const ups = b.updates || []
let ok = 0; let errors = 0
for (const u of ups) {
if (!u.job) continue
const patch = {}
if (u.route_order != null) patch.route_order = Number(u.route_order) || 0
if (u.priority) patch.priority = u.priority
if (u.duration_h != null && Number(u.duration_h) > 0) patch.duration_h = Number(u.duration_h) // durée éditée dans le timeline
if (u.start_time) patch.start_time = (String(u.start_time).length === 5 ? u.start_time + ':00' : u.start_time) // heure recalculée par le planificateur de tournée
if (!Object.keys(patch).length) continue
const r = await retryWrite(() => erp.update('Dispatch Job', u.job, patch))
if (r.ok) ok++; else errors++
}
return json(res, 200, { ok: true, updated: ok, errors })
}
// Retirer un job d'un tech (depuis l'éditeur de journée) → retour au pool (non assigné).
if (path === '/roster/unassign-job' && method === 'POST') {
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { assigned_tech: null, status: 'open', start_time: null }))
if (r.ok) invalidatePool() // le job revient au pool → rafraîchir le cache
return json(res, r.ok ? 200 : 500, { ...r, job: b.job })
}
// Lire / éditer l'ÉQUIPE d'un job (assistants en renfort) — le LEAD (assigned_tech) reste INCHANGÉ.
// GET ?job=X → liste actuelle des assistants
// POST {job, add:{tech_id,...}} → AJOUTE (dédup sur tech_id), sans clobber des autres
// POST {job, remove:tech_id} → RETIRE
// POST {job, assistants:[...]} → REMPLACE toute la table-enfant
// PUT partiel ⇒ assigned_tech / scheduled_date / etc. préservés. Chemin d'écriture UNIQUE (features/workforce).
if (path === '/roster/job/team' && (method === 'GET' || method === 'POST')) {
const b = method === 'POST' ? await parseBody(req) : {}
const job = b.job || qs.get('job')
if (!job) return json(res, 400, { error: 'job requis' })
const norm = (a) => ({ tech_id: String(a.tech_id), tech_name: String(a.tech_name || ''), duration_h: Number(a.duration_h) || 0, note: String(a.note || '').slice(0, 140), pinned: a.pinned ? 1 : 0 })
// ⚠️ Lire les assistants VIA LE DOC PARENT (child table embarquée), PAS via erp.list('Dispatch Job Assistant') :
// le compte de service ERP n'a pas la permission de LIRE le doctype-enfant directement → erp.list lève
// PermissionError → renvoyait [] en silence. Symptômes : chips assistants jamais affichées, tech non exclu de
// l'autosuggest, et surtout POST add/remove partait de [] → ÉCRASAIT les assistants existants. Le doc parent
// embarque `assistants` sans vérif de perm sur l'enfant.
let cur = []
try { const doc = await erp.get('Dispatch Job', job); cur = Array.isArray(doc && doc.assistants) ? doc.assistants : [] } catch (e) { cur = [] }
if (method === 'GET') return json(res, 200, { job, assistants: cur.map(norm) })
let rows
if (b.add && b.add.tech_id) { rows = cur.filter(a => String(a.tech_id) !== String(b.add.tech_id)).map(norm); rows.push(norm(b.add)) }
else if (b.remove) { rows = cur.filter(a => String(a.tech_id) !== String(b.remove)).map(norm) }
else { rows = (b.assistants || []).filter(a => a && a.tech_id).map(norm) }
const r = await retryWrite(() => erp.update('Dispatch Job', job, { assistants: rows }))
return json(res, r.ok ? 200 : 500, { ...r, job, assistants: rows.length, team: rows })
}
// Éditer le CONTACT d'un technicien (téléphone/courriel) — édition inline depuis Ops (ex. dialogue « Notifier »).
if (path === '/roster/technician/contact' && method === 'POST') {
const b = await parseBody(req)
const tid = String(b.tech_id || '').trim(); if (!tid) return json(res, 400, { error: 'tech_id requis' })
let name = tid
try { const r = await erp.list('Dispatch Technician', { filters: [['technician_id', '=', tid]], fields: ['name'], limit: 1 }); if (r && r[0]) name = r[0].name } catch (e) {}
const patch = {}
if (b.phone != null && String(b.phone).trim()) patch.phone = String(b.phone).trim().slice(0, 40)
if (b.email != null && String(b.email).trim()) patch.email = String(b.email).trim().slice(0, 140)
if (!Object.keys(patch).length) return json(res, 400, { error: 'phone ou email requis' })
const r = await retryWrite(() => erp.update('Dispatch Technician', name, patch))
if (r && r.ok) invalidateTechCache() // le prochain fetchTechnicians relira le contact à jour
return json(res, r.ok ? 200 : 500, { ...r, tech: name, ...patch })
}
// Situer manuellement un job « hors carte » : pose latitude/longitude (+ adresse) choisis via le sélecteur Mapbox Ops.
if (path === '/roster/job/set-location' && method === 'POST') {
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
const lat = Number(b.lat), lon = Number(b.lon)
if (!isFinite(lat) || !isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) return json(res, 400, { error: 'lat/lon invalides' })
const patch = { latitude: lat, longitude: lon }
if (b.address != null && String(b.address).trim() !== '') patch.address = String(b.address).slice(0, 140)
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, latitude: lat, longitude: lon })
}
// Actions rapides du pool (mobile, façon Gmail) : modifier des champs « dispatchables » d'un Dispatch Job — LISTE BLANCHE stricte.
// priority (urgent|high|medium|low) · status (open|On Hold|Cancelled|assigned) · required_skill · scheduled_date (YYYY-MM-DD, '' = effacer) ·
// notes = note importante du RÉPARTITEUR (ex. « appeler 30 min avant », « préfère AM ») — DISTINCTE de completion_notes (journal du technicien).
if (path === '/roster/job/update' && method === 'POST') {
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
const src = b.patch || b; const patch = {}
if (src.priority !== undefined) { const p = String(src.priority || ''); if (p && !['low', 'medium', 'high'].includes(p)) return json(res, 400, { error: 'priority invalide (low|medium|high)' }); patch.priority = p }
if (src.status !== undefined) { const s = String(src.status || ''); if (s && !['open', 'On Hold', 'Cancelled', 'assigned'].includes(s)) return json(res, 400, { error: 'status invalide' }); patch.status = s }
if (src.required_skill !== undefined) patch.required_skill = String(src.required_skill || '').slice(0, 80)
if (src.scheduled_date !== undefined) { const d = String(src.scheduled_date || ''); if (d && !/^\d{4}-\d{2}-\d{2}$/.test(d)) return json(res, 400, { error: 'date invalide' }); patch.scheduled_date = d || null }
if (src.notes !== undefined) patch.notes = String(src.notes || '').slice(0, 2000)
if (src.duration_h !== undefined) { const dh = Number(src.duration_h); if (!isFinite(dh) || dh <= 0 || dh > 24) return json(res, 400, { error: 'duration_h invalide (0-24)' }); patch.duration_h = Math.round(dh * 100) / 100 } // temps révisé par le dispatcher
if (!Object.keys(patch).length) return json(res, 400, { error: 'aucun champ modifiable' })
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
if (r.ok) invalidatePool() // priorité/statut/date/compétence/note changés → rafraîchir le cache du pool
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, patch })
}
// CHRONO (boucle de capture) : début/fin réels → durée réelle (carburant de l'apprentissage des durées par type×tech).
if (path === '/roster/job/start' && method === 'POST') {
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
const now = nowFrappe()
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { actual_start: now, actual_end: '', status: 'In Progress' }))
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, actual_start: now })
}
if (path === '/roster/job/finish' && method === 'POST') {
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
const cur = await erp.list('Dispatch Job', { filters: [['name', '=', b.job]], fields: ['actual_start'], limit: 1 })
const startTs = (cur && cur[0] && cur[0].actual_start) || null
const now = nowFrappe()
const patch = { actual_end: now, status: 'Completed' }; if (!startTs) patch.actual_start = now // borne si jamais démarré
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, actual_minutes: minutesBetween(startTs || now, now) })
}
// Backfill : pose un start_time (premier trou libre) sur les jobs DÉJÀ assignés mais SANS heure
// → leurs blocs d'occupation apparaissent enfin sur la grille. Idempotent (ne touche que start_time vide).
if (path === '/roster/backfill-start-times' && method === 'POST') {
const b = await parseBody(req)
const start = b.start || todayET(); const days = Number(b.days) || 14
const dates = rangeDates(start, days)
const rows = await erp.list('Dispatch Job', {
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress']]],
fields: ['name', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h'], limit: 5000,
})
const todo = (rows || []).filter(j => j.assigned_tech && j.scheduled_date && !j.start_time)
const d = await loadBookingData(start, days) // chargé UNE fois ; on mute d.booked au fil de l'eau pour empiler
let placed = 0; const details = []
for (const j of todo) { // SÉQUENTIEL (frappe_pg ne supporte pas la concurrence)
const dur = Number(j.duration_h) || 1
const k = j.assigned_tech + '|' + j.scheduled_date
if (!d.booked[k]) d.booked[k] = []
const h = firstFitStart(d, j.assigned_tech, j.scheduled_date, dur)
if (h == null) { details.push({ job: j.name, skipped: 'pas de shift régulier ce jour-là' }); continue }
const r = await retryWrite(() => erp.update('Dispatch Job', j.name, { start_time: hToTime(h), duration_h: dur }))
if (r.ok) { d.booked[k].push({ s: h, e: h + dur }); placed++; details.push({ job: j.name, tech: j.assigned_tech, date: j.scheduled_date, start: hToTime(h) }) }
else details.push({ job: j.name, error: r.error || 'update failed' })
}
return json(res, 200, { ok: true, candidates: todo.length, placed, details })
}
// Hold : réserver temporairement une fenêtre (agent au tél. ou client qui sélectionne)
// → la fenêtre est retirée des dispos des autres pendant `minutes` (défaut politique).
if (path === '/roster/book/hold' && method === 'POST') {
const b = await parseBody(req)
if (!b.date || !b.start) return json(res, 400, { error: 'date + start requis' })
const key = b.date + '|' + (String(b.start).length >= 5 ? String(b.start).slice(0, 5) : b.start)
if (b.release) { releaseHold(key); return json(res, 200, { ok: true, released: true, key }) }
const minutes = b.minutes || getBookingPolicy().hold_minutes
return json(res, 200, { ok: true, key, holds: addHold(key, minutes), minutes })
}
// Fit : 3 dispos classées du client → 1er choix tenable, sinon proposer
if (path === '/roster/book/fit' && method === 'POST') {
const b = await parseBody(req)
return json(res, 200, await fitBooking({ skill: b.skill || '', zone: b.zone || '', duration: b.duration || 1, prefs: b.prefs || [] }))
}
// Confirmer un RDV sur un Dispatch Job existant
if (path === '/roster/book/confirm' && method === 'POST') {
const b = await parseBody(req)
if (!b.job) return json(res, 400, { error: 'job requis' })
const st = (b.start || '').length === 5 ? b.start + ':00' : b.start
const patch = { scheduled_date: b.date, start_time: st, status: 'assigned', booking_status: 'Confirmé', booking_prefs: JSON.stringify(b.prefs || []) }
if (b.tech) patch.assigned_tech = b.tech
if (b.duration) patch.duration_h = b.duration
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
if (r.ok && b.date && b.start) releaseHold(b.date + '|' + (String(b.start).slice(0, 5)))
return json(res, r.ok ? 200 : 500, r)
}
// Créer plusieurs besoins d'un coup (depuis l'éditeur de demande)
if (path === '/roster/requirements/bulk' && method === 'POST') {
const b = await parseBody(req); const errors = []; let created = 0
for (const rq of b.requirements || []) {
const r = await retryWrite(() => erp.create('Shift Requirement', {
requirement_date: rq.requirement_date, shift_template: rq.shift_template,
zone: rq.zone || '', required_count: rq.required_count || 1, required_skills: rq.required_skills || '',
}))
if (r.ok) created++; else errors.push(rq)
}
return json(res, 200, { ok: errors.length === 0, created, errors: errors.length })
}
// Vider les besoins d'une période (avant de ré-appliquer la demande)
if (path === '/roster/requirements/clear' && method === 'POST') {
const b = await parseBody(req)
const reqs = await fetchRequirements(b.start, b.days || 7)
let deleted = 0
for (const rq of reqs) { const r = await retryWrite(() => erp.remove('Shift Requirement', rq.name)); if (r.ok) deleted++ }
return json(res, 200, { ok: true, deleted })
}
if (path === '/roster/generate' && method === 'POST') {
const b = await parseBody(req)
if (!b.start) return json(res, 400, { error: 'start requis' })
try {
return json(res, 200, await generate(b.start, b.days || 7, b.weights))
} catch (e) {
return json(res, 502, { error: 'solveur injoignable ou erreur: ' + e.message })
}
}
if (path === '/roster/publish' && method === 'POST') {
const b = await parseBody(req)
return json(res, 200, await publish(b.assignments))
}
// Notifier les techs de leur tournée : preview (compose sans envoi) OU envoi SMS/Email avec lien token + infos.
if (path === '/roster/notify-dispatch' && method === 'POST') {
const b = await parseBody(req)
const dates = Array.isArray(b.dates) ? b.dates.filter(Boolean) : []
const techIds = Array.isArray(b.tech_ids) ? b.tech_ids.filter(Boolean) : []
if (!dates.length || !techIds.length) return json(res, 400, { ok: false, error: 'dates et tech_ids requis' })
const msgs = await composeDispatchNotifs(dates, techIds)
if (b.preview) return json(res, 200, { ok: true, preview: true, techs: msgs.map(m => ({ techId: m.techId, name: m.name, phone: m.phone, email: m.email, jobCount: m.jobCount, link: m.link, sms: m.sms, emailSubject: m.emailSubject })) })
const sendSms = require('./twilio').sendSmsInternal; const { sendEmail } = require('./email')
let sms = 0, email = 0; const errors = []
for (const m of msgs) {
if (b.sms && m.phone) { try { await sendSms(m.phone, m.sms, null); sms++ } catch (e) { errors.push(m.name + ' SMS: ' + (e.message || e)) } }
if (b.email && m.email) { try { const ok = await sendEmail({ to: m.email, subject: m.emailSubject, html: m.emailHtml }); if (ok) email++; else errors.push(m.name + ' email: échec envoi') } catch (e) { errors.push(m.name + ' email: ' + (e.message || e)) } }
}
return json(res, 200, { ok: true, techs: msgs.length, sms, email, errors })
}
// Publier = réécrire la semaine (efface tout sur la période, recrée la grille).
// Idempotent + anti-doublons (contrairement au diff par case).
if (path === '/roster/publish-week' && method === 'POST') {
const b = await parseBody(req)
const existing = await fetchAssignments(b.start, b.days || 7)
const desired = b.assignments || []
// DIFF par clé tech|date|shift : on ne touche QUE ce qui a changé (≫ rapide vs wipe+recreate complet).
const keyOf = (a) => a.tech + '|' + a.date + '|' + a.shift
const existByKey = {}; for (const a of existing) existByKey[keyOf(a)] = a
const desiredKeys = new Set(desired.map(keyOf))
// #2/#3 — statut par mode : draft(Proposé, auto-save, 0 SMS · ne touche pas l'existant) · submit(Soumis) · approve(Approuvé) · publish(Publié + SMS).
const STATUS_BY_MODE = { draft: 'Proposé', submit: 'Soumis', approve: 'Approuvé', publish: 'Publié' }
const mode = STATUS_BY_MODE[b.mode] ? b.mode : 'publish'
const targetStatus = STATUS_BY_MODE[mode]
let deleted = 0; let created = 0; let errors = 0; let unchanged = 0; let promoted = 0
for (const a of existing) { // supprimer ceux qui ne sont plus voulus
if (desiredKeys.has(keyOf(a))) continue
const r = await retryWrite(() => erp.remove('Shift Assignment', a.name)); if (r.ok) deleted++
}
for (const a of desired) {
const ex = existByKey[keyOf(a)]
if (ex) { // draft = laisser tel quel ; submit/approve/publish = faire MONTER le statut (jamais rétrograder un Publié)
if (mode !== 'draft' && ex.status !== 'Publié' && ex.status !== targetStatus) { const r = await retryWrite(() => erp.update('Shift Assignment', ex.name, { status: targetStatus })); if (r.ok) promoted++; else errors++ } else unchanged++
continue
}
const r = await retryWrite(() => erp.create('Shift Assignment', {
technician: a.tech, technician_name: a.tech_name || '', assignment_date: a.date,
shift_template: a.shift, zone: a.zone || '', hours: Number(a.hours) || 0,
status: (mode === 'draft' ? 'Proposé' : targetStatus), source: a.source || 'manuel',
}))
if (r.ok) created++; else errors++
}
let notified = 0
if (mode === 'publish' && b.notify && (created || promoted)) { // SMS opt-in aux techs (Twilio) — SEULEMENT à la publication, jamais en auto-save brouillon
try {
const techs = await fetchTechnicians()
const phoneById = Object.fromEntries(techs.map(t => [t.id, t.phone]))
const tplName = Object.fromEntries((await fetchTemplates()).map(t => [t.name, t.template_name || t.name]))
const byTech = {}
for (const a of (b.assignments || [])) (byTech[a.tech] || (byTech[a.tech] = [])).push(a)
const sendSms = require('./twilio').sendSmsInternal
for (const tid in byTech) {
const phone = phoneById[tid]; if (!phone) continue
const lines = byTech[tid].slice().sort((x, y) => x.date.localeCompare(y.date)).map(a => a.date.slice(5) + ' ' + (tplName[a.shift] || a.shift)).join(' · ')
try { await sendSms(phone, 'Targo — votre horaire publié : ' + lines); notified++ } catch (e) { /* skip ce tech */ }
}
} catch (e) { /* notif non bloquante */ }
}
return json(res, 200, { ok: errors === 0, mode, created, deleted, promoted, errors, notified, unchanged })
}
// Garde : matérialiser la rotation sur un HORIZON (plusieurs semaines) — comme un évènement récurrent.
// Wipe ROBUSTE : on supprime TOUTE garde (tout template on_call) dans la plage, pas seulement les shifts
// courants — sinon une ancienne garde sous un autre nom de template survit et fausse la rotation
// (« la suite est bousillée »). Puis recréation depuis la rotation déterministe (= le calque live).
if (path === '/roster/garde/apply' && method === 'POST') {
const b = await parseBody(req)
const dates = rangeDates(b.start, (b.weeks || 1) * 7)
const tpls = await fetchTemplates()
const gardeTpls = tpls.filter(t => t.on_call).map(t => t.name)
let deleted = 0
if (gardeTpls.length && dates.length) {
const existing = await erp.list('Shift Assignment', { filters: [['shift_template', 'in', gardeTpls], ['assignment_date', 'in', dates]], fields: ['name'], limit: 5000 })
for (const a of existing) { const r = await retryWrite(() => erp.remove('Shift Assignment', a.name)); if (r.ok) deleted++ }
}
let created = 0; let errors = 0
for (const a of (b.assignments || [])) {
const r = await retryWrite(() => erp.create('Shift Assignment', {
technician: a.tech, technician_name: a.tech_name || '', assignment_date: a.date,
shift_template: a.shift, zone: a.zone || '', hours: Number(a.hours) || 0,
status: 'Publié', source: 'manuel',
}))
if (r.ok) created++; else errors++
}
return json(res, 200, { ok: errors === 0, created, deleted, errors })
}
// Modifier / supprimer un type de shift (Shift Template)
const mTpl = path.match(/^\/roster\/template\/(.+)$/)
if (mTpl && method === 'PUT') {
const name = decodeURIComponent(mTpl[1]); const b = await parseBody(req)
const patch = {}
for (const f of ['start_time', 'end_time', 'hours', 'color', 'zone', 'default_required', 'required_skills', 'active', 'on_call']) if (b[f] !== undefined) patch[f] = b[f]
const r = await retryWrite(() => erp.update('Shift Template', name, patch))
return json(res, r.ok ? 200 : 500, r)
}
if (mTpl && method === 'DELETE') {
const name = decodeURIComponent(mTpl[1]); const r = await retryWrite(() => erp.remove('Shift Template', name))
return json(res, r.ok ? 200 : 500, r)
}
if (path === '/roster/availability' && method === 'GET') {
const status = qs.get('status') || ''
const rows = await erp.list('Tech Availability', {
filters: status ? [['status', '=', status]] : [],
fields: ['name', 'technician', 'technician_name', 'availability_type', 'from_date', 'to_date', 'reason', 'status', 'approver'],
orderBy: 'modified desc', limit: 200,
})
return json(res, 200, { availability: rows })
}
if (path === '/roster/availability' && method === 'POST') {
const b = await parseBody(req)
const r = await erp.create('Tech Availability', {
technician: b.technician, technician_name: b.technician_name || '',
availability_type: b.availability_type || 'Congé', status: 'Demandé',
from_date: b.from_date, to_date: b.to_date, reason: b.reason || '',
long_term: b.long_term ? 1 : 0,
})
return json(res, r.ok ? 200 : 500, r)
}
const mApprove = path.match(/^\/roster\/availability\/(.+)\/approve$/)
if (mApprove && method === 'POST') {
const name = decodeURIComponent(mApprove[1])
const b = await parseBody(req)
const r = await retryWrite(() => erp.update('Tech Availability', name, { status: b.reject ? 'Refusé' : 'Approuvé', approver: b.approver || '' }))
return json(res, r.ok ? 200 : 500, r)
}
const mSkills = path.match(/^\/roster\/technician\/(.+)\/skills$/)
if (mSkills && method === 'POST') {
const techId = decodeURIComponent(mSkills[1]); const b = await parseBody(req)
const techName = await resolveTechName(techId)
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const patch = { skills: (b.skills || '').trim() }
if (b.skill_levels !== undefined) patch.skill_levels = typeof b.skill_levels === 'string' ? b.skill_levels : JSON.stringify(b.skill_levels || {}) // niveaux 15 par compétence (JSON)
if (b.skill_eff !== undefined) patch.skill_eff = typeof b.skill_eff === 'string' ? b.skill_eff : JSON.stringify(b.skill_eff || {}) // efficacité (facteur vitesse) PAR compétence (JSON)
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch)); if (r && r.ok) invalidateTechCache()
return json(res, r.ok ? 200 : 500, { ...r, technician: techId })
}
const mCost = path.match(/^\/roster\/technician\/(.+)\/cost$/)
if (mCost && method === 'POST') {
const techId = decodeURIComponent(mCost[1]); const b = await parseBody(req)
const techName = await resolveTechName(techId)
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { cost_salary_h: Number(b.salary) || 0, cost_charges_pct: Number(b.charges) || 0, cost_other_h: Number(b.other) || 0 })); if (r && r.ok) invalidateTechCache()
return json(res, r.ok ? 200 : 500, { ...r, technician: techId })
}
const mEff = path.match(/^\/roster\/technician\/(.+)\/efficiency$/)
if (mEff && method === 'POST') {
const techId = decodeURIComponent(mEff[1]); const b = await parseBody(req)
const techName = await resolveTechName(techId)
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { efficiency: Number(b.efficiency) || 1 })); if (r && r.ok) invalidateTechCache()
return json(res, r.ok ? 200 : 500, { ...r, technician: techId, efficiency: Number(b.efficiency) || 1 })
}
const mPause = path.match(/^\/roster\/technician\/(.+)\/pause$/)
if (mPause && method === 'POST') {
const techId = decodeURIComponent(mPause[1])
const b = await parseBody(req)
// technician_id n'est pas le docname → retrouver le doc
const techName = await resolveTechName(techId)
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const patch = { status: b.paused ? PAUSE_STATUS : AVAIL_STATUS }
if (b.paused && b.reason) patch.absence_reason = b.reason
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch)); if (r && r.ok) invalidateTechCache()
return json(res, r.ok ? 200 : 500, { ...r, technician: techId, status: patch.status })
}
// Archiver / restaurer un technicien (réversible) : status Archivé ↔ Disponible. Masqué partout mais historique conservé.
const mArchive = path.match(/^\/roster\/technician\/(.+)\/archive$/)
if (mArchive && method === 'POST') {
const techId = decodeURIComponent(mArchive[1])
const b = await parseBody(req)
// Store hub (pas d'écriture ERP : v16 rejette une valeur de statut hors liste sur ce doctype → 500).
const arch = loadArchivedSet()
if (b.archived) arch.add(String(techId)); else arch.delete(String(techId))
saveArchivedSet(arch); invalidateTechCache()
return json(res, 200, { ok: true, technician: techId, archived: !!b.archived })
}
// Positions GPS LIVE de TOUS les techs ayant un appareil Traccar → marqueurs « live » sur la carte des tournées
// (rafraîchi périodiquement côté client). Léger : une requête position par appareil (getPositions parallèle).
if (path === '/roster/tech-positions' && method === 'GET') {
const techs = (await fetchTechnicians()).filter(t => t.traccar_device_id)
if (!techs.length) return json(res, 200, { positions: [] })
const { getPositions } = require('./traccar')
const ids = [...new Set(techs.map(t => parseInt(t.traccar_device_id)).filter(Boolean))]
let positions = []
try { positions = await getPositions(ids) } catch (e) { return json(res, 200, { positions: [], error: e.message }) }
const byDev = {}; for (const p of positions) if (p && p.deviceId != null) byDev[p.deviceId] = p
const out = []
for (const t of techs) {
const p = byDev[parseInt(t.traccar_device_id)]
if (p && p.latitude != null) out.push({ techId: t.id, techName: t.name, lat: p.latitude, lon: p.longitude, time: p.fixTime || p.deviceTime || p.serverTime || null, speed: p.speed || 0 })
}
return json(res, 200, { positions: out })
}
// Géofencing : timeline d'un job (En route → Arrivé → Reparti, façon suivi de colis).
const mGf = path.match(/^\/roster\/job\/(.+)\/geofence$/)
if (mGf && method === 'GET') { return json(res, 200, require('./geofence').timeline(decodeURIComponent(mGf[1]))) }
// Géofencing : états live pour une LISTE de jobs (badges board/carte). ?jobs=a,b,c
if (path === '/roster/geofence-states' && method === 'GET') {
const u = new URL(req.url, 'http://localhost'); const names = (u.searchParams.get('jobs') || '').split(',').map(x => x.trim()).filter(Boolean)
return json(res, 200, { states: require('./geofence').statesFor(names) })
}
// Géofencing : forcer un passage (diagnostic/test). ?dry=1 = aperçu sans écrire.
if (path === '/roster/geofence-scan' && method === 'GET') {
const u = new URL(req.url, 'http://localhost')
return json(res, 200, await require('./geofence').runScan({ dryRun: u.searchParams.get('dry') === '1' }))
}
// Associer / changer (ou retirer) l'appareil Traccar d'un tech (GPS live + tracé) — éditable INLINE depuis Planif.
// device_id = id numérique Traccar (ou '' pour dissocier).
const mTrk = path.match(/^\/roster\/technician\/(.+)\/traccar-device$/)
if (mTrk && method === 'POST') {
const techId = decodeURIComponent(mTrk[1]); const b = await parseBody(req)
const techName = await resolveTechName(techId)
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const deviceId = (b.device_id == null || b.device_id === '') ? '' : String(b.device_id)
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { traccar_device_id: deviceId })); if (r && r.ok) invalidateTechCache()
return json(res, r.ok ? 200 : 500, { ...r, technician: techId, traccar_device_id: deviceId })
}
// Patron d'horaire RÉCURRENT (weekly_schedule) — source des quarts matérialisés (hybride). body.schedule = {mon:{start,end}|null,…}
const mSched = path.match(/^\/roster\/technician\/(.+)\/weekly-schedule$/)
if (mSched && method === 'POST') {
const techId = decodeURIComponent(mSched[1]); const b = await parseBody(req)
const techName = await resolveTechName(techId); if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const sched = (b.schedule && typeof b.schedule === 'object') ? b.schedule : null
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { weekly_schedule: sched ? JSON.stringify(sched) : '' })); if (r && r.ok) invalidateTechCache()
return json(res, r.ok ? 200 : 500, { ...r, technician: techId })
}
// HYBRIDE : matérialise les quarts depuis les patrons (fériés/vacances sautés, manuels préservés). GET=aperçu / POST=applique. ?weeks= ?tech=
if (path === '/roster/materialize-shifts' && (method === 'GET' || method === 'POST')) {
const u = new URL(req.url, 'http://localhost'); const b = method === 'POST' ? await parseBody(req) : {}
return json(res, 200, await materializeShifts({ dryRun: method === 'GET', weeks: Number(b.weeks || u.searchParams.get('weeks')) || 4, techId: b.tech || u.searchParams.get('tech') || null }))
}
// Supprimer une assignation publiée
const mDelA = path.match(/^\/roster\/assignment\/(.+)$/)
if (mDelA && method === 'DELETE') {
const name = decodeURIComponent(mDelA[1])
const r = await retryWrite(() => erp.remove('Shift Assignment', name))
return json(res, r.ok ? 200 : 500, r)
}
return json(res, 404, { error: 'roster: route inconnue ' + path })
}
module.exports = { handle, handlePublicBooking, handleFieldTech, fieldLink, techFieldLink, generate, publish, coverage, fetchTechnicians, fetchTemplates, bookingSlots, startShiftMaterializer }