Après confirmation d'un créneau (Option A), le client obtient un bouton .ics (Apple/Google/Outlook) pré-rempli : date/heure + durée, « Rendez-vous Gigafibre — <service> », technicien en description, adresse en LOCATION. Réduit les oublis/no-shows côté client. Heure flottante (fuseau de l'appareil = Québec). Vérifié : flux confirmer→lien, .ics bien formé (DTSTART/DTEND/SUMMARY), 0 erreur console ; live à msg.gigafibre.ca/book. Hub redémarré, sain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2475 lines
190 KiB
JavaScript
2475 lines
190 KiB
JavaScript
'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 en MINUTES. HARDENING : un nœud SANS coords n'est plus à 0 min de tout (= « adjacent gratuit », ce qui faisait
|
||
// insérer les jobs sans coords n'importe où dans une tournée) mais à NO_COORD_MIN (« loin/inconnu », > toute vraie étape ~90 min)
|
||
// → le solveur ne les chaîne plus gratuitement au milieu des vrais arrêts. Diagonale = 0.
|
||
const NO_COORD_MIN = 180
|
||
const M = Array.from({ length: N }, () => new Array(N).fill(NO_COORD_MIN))
|
||
for (let i = 0; i < N; i++) M[i][i] = 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 ? NO_COORD_MIN : 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 1–5}
|
||
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 d’aé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.
|
||
// hold_minutes : réservation EXCLUSIVE d'un créneau offert. 5 min (décision Louis 2026-07-19) → passé ce délai
|
||
// il retourne au pool et redevient indicatif (revalidé à la confirmation via la garde F). Éditable via /roster/policy.
|
||
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: 5, 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
|
||
}
|
||
// Garde anti-clobber « déjà assigné dans F » — PARTAGÉE par /roster/assign-job, confirmWindow et /roster/book/confirm.
|
||
// Retourne un objet conflit (→ 409 côté staff) si le ticket legacy est assigné à un AUTRE staff réel dans F, sinon null.
|
||
// Best-effort : F injoignable ⇒ null (on ne bloque pas). Empêche qu'une confirmation client écrase une assignation F.
|
||
async function fConflict (legacyId, techId) {
|
||
if (!legacyId) return null
|
||
try {
|
||
const st = await require('./legacy-dispatch-sync').ticketAssignState(legacyId)
|
||
const sidTarget = (String(techId || '').match(/(\d{2,})$/) || [])[1] || ''
|
||
if (st && st.assigned && String(st.staff_id) !== sidTarget) {
|
||
return { 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) { /* F injoignable → best-effort */ }
|
||
return 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
|
||
// Garde F : si le ticket legacy est déjà assigné à un AUTRE tech dans F, on N'ÉCRASE PAS (côté client, pas de force).
|
||
// On enregistre le créneau choisi (booking_status Proposé) et on laisse le répartiteur confirmer le bon tech.
|
||
let legacyId = ''
|
||
try { const jb = await erp.get('Dispatch Job', jobName, { fields: ['legacy_ticket_id'] }); legacyId = (jb && jb.legacy_ticket_id) || '' } catch (e) {}
|
||
const cf = await fConflict(legacyId, slot.tech)
|
||
if (cf) {
|
||
const rp = await retryWrite(() => erp.update('Dispatch Job', jobName, { scheduled_date: date, start_time: st, booking_status: 'Proposé', booking_prefs: JSON.stringify([{ date, start }]) }))
|
||
if (rp.ok) releaseHold(date + '|' + start)
|
||
return { ok: true, confirmed: false, message: 'Créneau enregistré — nous confirmerons le technicien sous peu.' }
|
||
}
|
||
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>
|
||
:root{--g:#00C853;--g-d:#00A344;--ink:#1B2E24;--muted:#64748B;--line:#e6ebe8;--bg:#f4f7f5;--card:#fff}
|
||
*{box-sizing:border-box}
|
||
body{font-family:'Plus Jakarta Sans',-apple-system,Segoe UI,Roboto,sans-serif;margin:0;background:var(--bg);color:var(--ink);-webkit-font-smoothing:antialiased}
|
||
.wrap{max-width:560px;margin:0 auto;padding:16px}
|
||
.card{background:var(--card);border-radius:16px;box-shadow:0 1px 3px rgba(16,40,28,.06),0 12px 40px -24px rgba(16,40,28,.3);padding:22px;margin-bottom:12px}
|
||
.brandrow{display:flex;align-items:center;gap:9px;margin-bottom:14px}
|
||
.logo{width:30px;height:30px;border-radius:8px;background:var(--g);display:grid;place-items:center;color:#fff;font-weight:800}
|
||
.logo svg{width:18px;height:18px}
|
||
.brandrow b{font-size:16px;letter-spacing:-.01em}
|
||
h1{font-size:21px;margin:0 0 4px;letter-spacing:-.02em}
|
||
.sub{color:var(--muted);font-size:13px;margin-bottom:4px}
|
||
.jobline{font-size:12.5px;color:var(--muted);font-weight:600;margin-bottom:6px}
|
||
.seg{display:flex;background:var(--bg);border:1px solid var(--line);border-radius:11px;padding:3px;margin:16px 0 6px}
|
||
.seg button{flex:1;appearance:none;border:0;background:transparent;font-family:inherit;font-size:13px;font-weight:700;color:var(--muted);padding:9px 8px;border-radius:8px;cursor:pointer;transition:.15s}
|
||
.seg button.on{background:var(--card);color:var(--g-d);box-shadow:0 1px 3px rgba(16,40,28,.12)}
|
||
.modehint{font-size:12.5px;color:var(--muted);margin:8px 0 2px;line-height:1.45}
|
||
.week{background:var(--g);color:#fff;font-weight:700;font-size:12.5px;padding:8px 12px;border-radius:9px;margin:18px 0 6px}
|
||
.day{font-weight:700;margin:14px 0 4px;font-size:13px;color:var(--ink)}
|
||
.ampm{font-size:10.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.7px;margin:10px 0 5px;font-weight:800}
|
||
.slots{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:8px}
|
||
.slot{border:1.5px solid var(--line);border-radius:10px;padding:11px 6px;text-align:center;cursor:pointer;font-size:14px;font-weight:600;position:relative;background:var(--card);transition:.12s}
|
||
.slot:hover{border-color:var(--g)}
|
||
.slot.sel{background:#e7f9ef;border-color:var(--g);color:var(--g-d)}
|
||
.rank{position:absolute;top:-8px;right:-8px;background:var(--g);color:#fff;width:20px;height:20px;border-radius:50%;font-size:12px;font-weight:700;line-height:20px}
|
||
.btn{background:var(--g);color:#fff;border:0;border-radius:11px;padding:14px 18px;font-size:15px;font-weight:700;font-family:inherit;cursor:pointer;width:100%;margin-top:14px}
|
||
.btn2{display:block;text-align:center;text-decoration:none;background:#fff;color:var(--g);border:1.5px solid var(--g);border-radius:11px;padding:12px 18px;font-size:14px;font-weight:700;margin-top:10px}
|
||
.btn:disabled{background:#b9c7c0;cursor:default}
|
||
.hint{font-size:12px;color:var(--muted);margin:9px 0 0;text-align:center}
|
||
.hold{display:none;align-items:center;gap:9px;background:#e7f9ef;border:1px solid #b6ebcd;border-radius:10px;padding:10px 13px;margin-top:12px;font-size:13px;font-weight:600;color:var(--g-d)}
|
||
.hold.on{display:flex}
|
||
.hold .cd{margin-left:auto;font-variant-numeric:tabular-nums;font-weight:800}
|
||
.ok{background:#e7f9ef;color:#0d6b34;padding:18px;border-radius:12px;text-align:center;font-size:15px}
|
||
.err{background:#fdecea;color:#b3261e;padding:13px;border-radius:10px;font-size:13.5px}
|
||
.empty{color:#9aa5a0;font-size:12.5px;font-style:italic;padding:8px 0}
|
||
.ff-row{display:flex;align-items:center;gap:8px;margin:8px 0}
|
||
.ff-n{width:22px;height:22px;border-radius:50%;background:var(--g);color:#fff;font-weight:700;font-size:12px;display:grid;place-items:center;flex:none}
|
||
.ff-date,.ff-per{font:inherit;font-size:14px;padding:9px 10px;border:1px solid var(--line);border-radius:9px;background:var(--card);color:var(--ink)}
|
||
.ff-date{flex:1;min-width:0}
|
||
.foot{text-align:center;color:var(--muted);font-size:12px;margin-top:4px}
|
||
.foot b{color:var(--g-d)}
|
||
</style></head>
|
||
<body><div class=wrap>
|
||
<div class=card>
|
||
<div class=brandrow><span class=logo><svg viewBox="0 0 24 24" fill=none stroke=currentColor stroke-width=2.4 stroke-linecap=round stroke-linejoin=round><path d="M13 2 3 14h7l-1 8 10-12h-7l1-8Z"/></svg></span><b>Gigafibre</b></div>
|
||
<h1>Votre rendez-vous</h1>
|
||
<div class=jobline id=jobinfo>Chargement…</div>
|
||
<div id=content></div>
|
||
</div>
|
||
<div class=foot>Gigafibre · propulsé par <b>Targo</b></div>
|
||
</div>
|
||
<script>
|
||
var token=new URLSearchParams(location.search).get('token')||'';
|
||
var FR=['dimanche','lundi','mardi','mercredi','jeudi','vendredi','samedi'];
|
||
var MO=['janv.','févr.','mars','avr.','mai','juin','juil.','août','sept.','oct.','nov.','déc.'];
|
||
var mode='pick',picks=[],sel=null,windows=[],dur=1,timer=null,deadline=0,loc='',svc='';
|
||
function pad(n){return (n<10?'0':'')+n}
|
||
// Lien .ics universel (Apple/Google/Outlook) — heure « flottante » (interprétée dans le fuseau de l'appareil, = Québec).
|
||
function icsLink(date,start,durH,tech){var t=start.split(':'),d=date.replace(/-/g,'');var sh=Number(t[0])||9,sm=Number(t[1])||0;var eh=Math.min(23,sh+Math.max(1,Math.round(durH||1)));var dt=function(h,m){return d+'T'+pad(h)+pad(m)+'00'};
|
||
var ics=['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//Gigafibre//RDV//FR','BEGIN:VEVENT','DTSTART:'+dt(sh,sm),'DTEND:'+dt(eh,sm),'SUMMARY:Rendez-vous Gigafibre'+(svc?' - '+svc:''),'DESCRIPTION:'+((tech?'Technicien: '+tech+'. ':'')+'Rendez-vous technique Gigafibre.'),'LOCATION:'+(loc||''),'END:VEVENT','END:VCALENDAR'].join('\r\n');
|
||
return 'data:text/calendar;charset=utf-8,'+encodeURIComponent(ics)}
|
||
function d2(iso){var a=iso.split('-').map(Number);return new Date(Date.UTC(a[0],a[1]-1,a[2]))}
|
||
function dayLabel(iso){var dt=d2(iso);return FR[dt.getUTCDay()]+' '+dt.getUTCDate()+' '+MO[dt.getUTCMonth()]}
|
||
function wkMon(iso){var dt=d2(iso);var off=(dt.getUTCDay()+6)%7;dt.setUTCDate(dt.getUTCDate()-off);return dt.toISOString().slice(0,10)}
|
||
function wkLabel(m){var a=d2(m);var b=new Date(a);b.setUTCDate(b.getUTCDate()+6);return 'Semaine du '+a.getUTCDate()+' '+MO[a.getUTCMonth()]+' – '+b.getUTCDate()+' '+MO[b.getUTCMonth()]}
|
||
function api(path,opts){return fetch(path,opts).then(function(x){return x.json()}).catch(function(){return{ok:false}})}
|
||
function el(id){return document.getElementById(id)}
|
||
function load(){api('/book/api/options?token='+encodeURIComponent(token)).then(function(r){
|
||
var info=el('jobinfo'),c=el('content');
|
||
if(!r.ok){info.textContent='';c.innerHTML='<div class=err>Lien invalide ou expiré. Contactez-nous.</div>';return}
|
||
if(r.job&&r.job.scheduled){info.textContent='';c.innerHTML='<div class=ok>Votre rendez-vous est déjà confirmé :<br><b>'+dayLabel(r.job.scheduled)+'</b>.</div>';return}
|
||
dur=Number(r.job&&r.job.duration)||1;windows=r.windows||[];loc=(r.job&&r.job.location)||'';svc=(r.job&&r.job.service_type)||'';
|
||
info.textContent=(r.job&&r.job.service_type?r.job.service_type+' · ':'')+((r.job&&r.job.location)||'');
|
||
if(!windows.length){renderFreeform();return}
|
||
c.innerHTML='<div class=seg id=seg><button data-m=pick class=on>Choisir un créneau</button><button data-m=propose>Proposer mes dispos</button></div>'
|
||
+'<div class=modehint id=modehint></div><div id=grid></div>'
|
||
+'<div class=hold id=hold><span>Réservé pour vous</span><span class=cd id=cd>5:00</span></div>'
|
||
+'<button class=btn id=go disabled>Confirmer ce rendez-vous</button><div class=hint id=hint></div>';
|
||
el('seg').onclick=function(e){var b=e.target.closest('button');if(b)setMode(b.dataset.m)};
|
||
el('go').onclick=function(){mode==='pick'?confirmPick():submitPropose()};
|
||
setMode('pick')
|
||
})}
|
||
function renderFreeform(){var c=el('content');
|
||
var opt='<option value=09:00>Matin</option><option value=13:00>Après-midi</option><option value=17:00>Soir</option>';
|
||
var row=function(n){return '<div class=ff-row><span class=ff-n>'+n+'</span><input type=date class=ff-date data-n="'+n+'"><select class=ff-per data-n="'+n+'">'+opt+'</select></div>'};
|
||
c.innerHTML='<div class=modehint>Nous n\'avons pas de créneau en ligne pour l\'instant. Indiquez jusqu\'à 3 disponibilités qui vous conviennent — un répartiteur vous confirmera rapidement.</div>'+row(1)+row(2)+row(3)+'<button class=btn id=go>Envoyer mes disponibilités</button><div class=hint id=hint></div>';
|
||
el('go').onclick=submitFreeform}
|
||
function submitFreeform(){var go=el('go');var prefs=[];[].forEach.call(document.querySelectorAll('.ff-date'),function(d){if(d.value){var per=document.querySelector('.ff-per[data-n="'+d.dataset.n+'"]');prefs.push({date:d.value,start:per?per.value:'09:00'})}});
|
||
if(!prefs.length){el('hint').textContent='Choisissez au moins une date.';return}
|
||
go.disabled=true;go.textContent='Envoi…';
|
||
api('/book/api/submit?token='+encodeURIComponent(token),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode:'rank',prefs:prefs})}).then(done)}
|
||
function setMode(m){mode=m;picks=[];sel=null;stopCd();el('hold').classList.remove('on');
|
||
Array.prototype.forEach.call(document.querySelectorAll('#seg button'),function(b){b.classList.toggle('on',b.dataset.m===m)});
|
||
el('modehint').textContent=m==='pick'?'Touchez un créneau — il est réservé pour vous pendant 5 minutes, le temps de confirmer.':'Touchez 1 à 3 créneaux en ordre de préférence. On confirme le 1er possible.';
|
||
el('go').textContent=m==='pick'?'Confirmer ce rendez-vous':'Envoyer mes disponibilités';el('go').disabled=true;
|
||
renderTiles();render()}
|
||
function renderTiles(){var weeks={};windows.forEach(function(w){var mk=wkMon(w.date);(weeks[mk]=weeks[mk]||{});(weeks[mk][w.date]=weeks[mk][w.date]||[]).push(w)});
|
||
var h='';Object.keys(weeks).sort().forEach(function(mk){h+='<div class=week>'+wkLabel(mk)+'</div>';
|
||
Object.keys(weeks[mk]).sort().forEach(function(d){var ws=weeks[mk][d].sort(function(a,b){return a.start_h-b.start_h});var am=ws.filter(function(w){return w.start_h<12}),pm=ws.filter(function(w){return w.start_h>=12});h+='<div class=day>'+dayLabel(d)+'</div>';
|
||
var sect=function(lab,arr){return arr.length?('<div class=ampm>'+lab+'</div><div class=slots>'+arr.map(function(w){return '<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)})});
|
||
el('grid').innerHTML=h;
|
||
Array.prototype.forEach.call(document.querySelectorAll('#grid .slot'),function(s){s.onclick=function(){mode==='pick'?pickOne(s):togglePref(s)}})}
|
||
function pickOne(s){var k=s.dataset.d+'|'+s.dataset.s;if(sel===k){sel=null;el('hold').classList.remove('on');stopCd();el('go').disabled=true;render();return}
|
||
sel=k;render();el('go').disabled=true;
|
||
api('/book/api/hold?token='+encodeURIComponent(token),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({date:s.dataset.d,start:s.dataset.s})}).then(function(r){
|
||
if(sel!==k)return;el('hold').classList.add('on');startCd((r&&r.expires_in_s)||300);el('go').disabled=false})}
|
||
function togglePref(s){var k=s.dataset.d+'|'+s.dataset.s,i=picks.indexOf(k);if(i>=0)picks.splice(i,1);else if(picks.length<3)picks.push(k);render();el('go').disabled=!picks.length}
|
||
function render(){Array.prototype.forEach.call(document.querySelectorAll('#grid .slot'),function(s){var k=s.dataset.d+'|'+s.dataset.s;
|
||
if(mode==='pick'){s.classList.toggle('sel',sel===k);var b=s.querySelector('.rank');if(b)b.remove()}
|
||
else{var i=picks.indexOf(k);s.classList.toggle('sel',i>=0);var r=s.querySelector('.rank');if(i>=0){if(!r){r=document.createElement('div');r.className='rank';s.appendChild(r)}r.textContent=i+1}else if(r)r.remove()}});
|
||
if(el('hint'))el('hint').textContent=mode==='propose'?(picks.length?picks.length+' choix — le 1er sera priorisé.':''):''}
|
||
function startCd(sec){deadline=Date.now()+sec*1000;stopCd();tick();timer=setInterval(tick,1000)}
|
||
function stopCd(){if(timer){clearInterval(timer);timer=null}}
|
||
function tick(){var left=Math.max(0,Math.round((deadline-Date.now())/1000));var m=Math.floor(left/60),s=left%60;if(el('cd'))el('cd').textContent=m+':'+(s<10?'0':'')+s;if(left<=0)expire()}
|
||
function expire(){stopCd();var k=sel;sel=null;el('hold').classList.remove('on');el('go').disabled=true;render();
|
||
if(k)api('/book/api/hold?token='+encodeURIComponent(token),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({date:k.split('|')[0],start:k.split('|')[1],release:true})});
|
||
el('hint').textContent='Le créneau a expiré et est de nouveau disponible — touchez-en un autre.'}
|
||
function confirmPick(){if(!sel)return;var go=el('go');go.disabled=true;go.textContent='Confirmation…';stopCd();
|
||
api('/book/api/submit?token='+encodeURIComponent(token),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode:'pick',date:sel.split('|')[0],start:sel.split('|')[1]})}).then(done)}
|
||
function submitPropose(){var go=el('go');go.disabled=true;go.textContent='Envoi…';var prefs=picks.map(function(k){return{date:k.split('|')[0],start:k.split('|')[1]}});
|
||
api('/book/api/submit?token='+encodeURIComponent(token),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode:'rank',prefs:prefs})}).then(done)}
|
||
function done(r){var c=el('content');
|
||
if(r&&r.ok&&r.confirmed)c.innerHTML='<div class=ok>✅ Rendez-vous confirmé :<br><b>'+dayLabel(r.date)+' à '+r.start+'</b>'+(r.tech?'<br><span style=color:#0d6b34;font-weight:600>Technicien : '+r.tech+'</span>':'')+'<br>Merci !</div><a class=btn2 href="'+icsLink(r.date,r.start,dur,r.tech)+'" download="rendez-vous-gigafibre.ics">📅 Ajouter à mon calendrier</a>';
|
||
else if(r&&r.ok)c.innerHTML='<div class=ok>Merci !<br>'+((r&&r.message)||'Nous vous confirmerons sous peu.')+'</div>';
|
||
else c.innerHTML='<div class=err>'+((r&&r.message)||(r&&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 })
|
||
}
|
||
// Hold PUBLIC (Option A « on propose, le client choisit ») : à la sélection d'un créneau, on le réserve
|
||
// EXCLUSIVEMENT hold_minutes (5). Passé ce délai il retourne au pool (bookingSlots re-soustrait les holds vivants).
|
||
if (path === '/book/api/hold' && method === 'POST') {
|
||
const job = await jobByToken(token); if (!job) return json(res, 404, { ok: false, error: 'lien invalide' })
|
||
const b = await parseBody(req); if (!b.date || !b.start) return json(res, 400, { ok: false, 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 }) }
|
||
const minutes = getBookingPolicy().hold_minutes
|
||
addHold(key, minutes)
|
||
return json(res, 200, { ok: true, minutes, expires_in_s: minutes * 60 })
|
||
}
|
||
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.' })
|
||
}
|
||
// Option A : le client CHOISIT un créneau proposé → confirmation immédiate (garde F : si conflit, enregistré en Proposé).
|
||
if (b.mode === 'pick' && b.date && b.start) {
|
||
const r = await confirmWindow(job.name, b.date, b.start, dur, skill)
|
||
return json(res, 200, r)
|
||
}
|
||
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. Carte = MapLibre + tuiles OSM (aucun jeton Mapbox).
|
||
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('__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 })
|
||
}
|
||
// MEILLEUR TRAJET du jour : ordre optimal des arrêts via NOTRE OSRM (OSM, sans trafic). La navigation réelle
|
||
// (trafic en direct) est laissée au GPS du téléphone (Google Maps multi-arrêts) → zéro Mapbox. `from`=lat,lon = position courante.
|
||
if (path === '/field/route' && method === 'GET') {
|
||
const tech = techFieldVerify(token); if (!tech) return json(res, 403, { ok: false, error: 'lien invalide' })
|
||
const today = todayET()
|
||
const rows = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', tech], ['scheduled_date', '=', today]], fields: ['name', 'subject', 'address', 'latitude', 'longitude', 'start_time', 'actual_end', 'status'], orderBy: 'start_time asc', limit: 60 })
|
||
let stops = (rows || [])
|
||
.filter(j => j.latitude != null && Math.abs(+j.latitude) > 0.01 && !j.actual_end && j.status !== 'Completed' && j.status !== 'Cancelled')
|
||
.map(j => ({ name: j.name, subject: j.subject || '', address: j.address || '', lat: +j.latitude, lon: +j.longitude }))
|
||
let ordered = false
|
||
const from = url.searchParams.get('from') // "lat,lon" position courante (optionnel)
|
||
if (stops.length >= 2) {
|
||
try {
|
||
// Ordre optimal via la MATRICE OSRM /table (le plugin /trip n'est pas compilé sur notre OSRM) + plus-proche-voisin
|
||
// depuis l'origine. Heuristique TSP suffisante pour une tournée de tech ; le trafic réel = géré par la nav du téléphone.
|
||
const coords = []
|
||
const hasFrom = from && /^-?\d+(\.\d+)?,-?\d+(\.\d+)?$/.test(from)
|
||
if (hasFrom) { const [la, lo] = from.split(','); coords.push([+lo, +la]) } // origine (position courante) en index 0
|
||
stops.forEach(s => coords.push([s.lon, s.lat]))
|
||
const coordStr = coords.map(c => c[0].toFixed(6) + ',' + c[1].toFixed(6)).join(';')
|
||
const j = await osrmGet('/table/v1/driving/' + coordStr + '?annotations=duration')
|
||
if (j && j.code === 'Ok' && Array.isArray(j.durations) && j.durations.length === coords.length) {
|
||
const D = j.durations; const n = coords.length; const visited = new Array(n).fill(false)
|
||
visited[0] = true; let cur = 0; const tour = [0] // départ = origine si fournie, sinon 1er arrêt (ordre horaire)
|
||
for (let step = 1; step < n; step++) {
|
||
let best = -1; let bestD = Infinity
|
||
for (let k = 0; k < n; k++) { if (visited[k]) continue; const dd = (D[cur] && D[cur][k] != null) ? D[cur][k] : Infinity; if (dd < bestD) { bestD = dd; best = k } }
|
||
if (best < 0) break; visited[best] = true; tour.push(best); cur = best
|
||
}
|
||
const seq = tour.map(idx => hasFrom ? (idx >= 1 ? stops[idx - 1] : null) : stops[idx]).filter(Boolean)
|
||
if (seq.length === stops.length) { stops = seq; ordered = true }
|
||
}
|
||
} catch (e) { /* OSRM indispo → ordre horaire conservé */ }
|
||
}
|
||
return json(res, 200, { ok: true, ordered, count: stops.length, stops })
|
||
}
|
||
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 })
|
||
}
|
||
// PROPOSER un RDV au client (nouveau, pas un report) : envoie le lien /book → le client CHOISIT un créneau OU
|
||
// PROPOSE 3 dispos. C'est le pont agent→client (le staff diagnostique, le client se planifie tout seul).
|
||
// Résout le job depuis {job} | {source_issue|ticket} | {customer} (1er job ouvert) → 404 sinon (« créez d'abord l'intervention »).
|
||
if (path === '/roster/book/propose' && method === 'POST') {
|
||
const b = await parseBody(req)
|
||
let jobName = b.job
|
||
if (!jobName && (b.source_issue || b.ticket)) { const rows = await erp.list('Dispatch Job', { filters: [['source_issue', '=', b.source_issue || b.ticket], ['status', 'in', ['open', 'assigned']]], fields: ['name'], limit: 1 }); jobName = rows[0] && rows[0].name }
|
||
if (!jobName && b.customer) { const rows = await erp.list('Dispatch Job', { filters: [['customer', '=', b.customer], ['status', 'in', ['open', 'assigned']]], fields: ['name'], order_by: 'creation desc', limit: 1 }); jobName = rows[0] && rows[0].name }
|
||
if (!jobName && (b.customer || b.subject)) { // aucun job ouvert → on en CRÉE un (ouvert, sans tech ni heure) pour porter le lien client
|
||
const created = await require('./dispatch').createDispatchJob({ subject: b.subject || 'Rendez-vous à planifier', customer: b.customer || '', service_location: b.service_location || '', duration_h: Number(b.duration) || 1, source_issue: b.source_issue || '', priority: b.priority || 'medium' })
|
||
jobName = created && created.job_id
|
||
if (jobName && b.skill) { try { await retryWrite(() => erp.update('Dispatch Job', jobName, { required_skill: b.skill })) } catch (e) {} }
|
||
}
|
||
if (!jobName) return json(res, 404, { ok: false, error: 'impossible de retrouver ou créer l\'intervention (client ou sujet requis)' })
|
||
const job = await erp.get('Dispatch Job', jobName, { fields: ['name', 'booking_token', 'customer', 'customer_name', 'scheduled_date'] })
|
||
if (!job) return json(res, 404, { ok: false, error: 'job introuvable' })
|
||
let token = job.booking_token
|
||
if (!token) { token = crypto.randomBytes(12).toString('hex'); await retryWrite(() => erp.update('Dispatch Job', jobName, { booking_token: token })) }
|
||
const url = (cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca') + '/book?token=' + token
|
||
if (!job.scheduled_date) { await retryWrite(() => erp.update('Dispatch Job', jobName, { booking_status: 'À reporter' })) } // = file « à planifier avec le client »
|
||
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, job: jobName, url, sms: false, note: 'Lien prêt (aucun téléphone trouvé — copiez-le ou fournissez "phone").' })
|
||
const msg = b.message || `Bonjour, Gigafibre souhaite planifier votre rendez-vous technique. Choisissez le créneau qui vous convient — ou proposez vos disponibilités : ${url} Merci !`
|
||
let sid = null
|
||
try { sid = await require('./twilio').sendSmsInternal(phone, msg, job.customer) } catch (e) { return json(res, 200, { ok: true, job: jobName, url, sms: false, error: String(e.message || e) }) }
|
||
return json(res, 200, { ok: true, job: jobName, 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) {
|
||
const cf = await fConflict(legacyId, b.tech)
|
||
if (cf) return json(res, 409, { ok: false, ...cf })
|
||
}
|
||
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' })
|
||
// Garde anti-clobber F (comme /roster/assign-job) : si on (ré)assigne un tech et que le ticket legacy est
|
||
// déjà assigné à un AUTRE tech dans F, refuser en 409 sauf force=true → l'OPS propose « réassigner ? ».
|
||
if (b.tech && !b.force) {
|
||
let legacyId = ''
|
||
try { const jb = await erp.get('Dispatch Job', b.job, { fields: ['legacy_ticket_id'] }); legacyId = (jb && jb.legacy_ticket_id) || '' } catch (e) {}
|
||
const cf = await fConflict(legacyId, b.tech)
|
||
if (cf) return json(res, 409, { ok: false, ...cf })
|
||
}
|
||
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 1–5 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' }))
|
||
}
|
||
// Géofencing : passe TRACE rétrospective (dérive Arrivé/Reparti de l'historique GPS complet) — corrige les arrivées
|
||
// manquées par le scan live + BACKFILL d'une journée. ?date=YYYY-MM-DD (défaut aujourd'hui) · ?dry=1 = aperçu.
|
||
if (path === '/roster/geofence-reconcile' && method === 'GET') {
|
||
const u = new URL(req.url, 'http://localhost')
|
||
return json(res, 200, await require('./geofence').reconcileFromTrack({ date: u.searchParams.get('date') || undefined, dryRun: u.searchParams.get('dry') === '1' }))
|
||
}
|
||
// Roster des appareils GPS Traccar ENRICHI (appareil + tech(s) assigné(s) + non-associé + dernier signal) → vue « appareil → tech ».
|
||
if (path === '/roster/traccar-devices' && method === 'GET') {
|
||
const [devs, techs] = await Promise.all([require('./traccar').getDevices().catch(() => []), fetchTechnicians().catch(() => [])])
|
||
const byDev = new Map() // deviceId(String) → [{id,name}] (peut être >1 = doublon à signaler)
|
||
for (const t of (techs || [])) { const d = t.traccar_device_id; if (!d) continue; const k = String(d); if (!byDev.has(k)) byDev.set(k, []); byDev.get(k).push({ id: t.id, name: t.name }) }
|
||
const out = (devs || []).map(d => { const holders = byDev.get(String(d.id)) || []; return { id: d.id, name: d.name || ('#' + d.id), unique_id: d.uniqueId || '', status: d.status || 'unknown', last_update: d.lastUpdate || null, assigned: holders, unassigned: holders.length === 0, duplicate: holders.length > 1 } })
|
||
return json(res, 200, { devices: out, count: out.length, unassigned: out.filter(x => x.unassigned).length, duplicates: out.filter(x => x.duplicate).length })
|
||
}
|
||
// Assigner un appareil → un tech (SENS INVERSE). Libère d'abord tout AUTRE tech détenant l'appareil (le champ vit sur le tech →
|
||
// sans ça 2 techs pointeraient le même device). tech_id vide = simplement dissocier l'appareil de tous.
|
||
if (path === '/roster/traccar-device-assign' && method === 'POST') {
|
||
const b = await parseBody(req); const deviceId = String(b.device_id == null ? '' : b.device_id).trim(); const techId = String(b.tech_id || '').trim()
|
||
if (!deviceId) return json(res, 400, { ok: false, error: 'device_id requis' })
|
||
const techs = await fetchTechnicians().catch(() => [])
|
||
const holders = (techs || []).filter(t => String(t.traccar_device_id) === deviceId && t.id !== techId)
|
||
const released = []
|
||
for (const h of holders) { try { const nm = await resolveTechName(h.id); if (nm) { await retryWrite(() => erp.update('Dispatch Technician', nm, { traccar_device_id: '' })); released.push(h.id) } } catch (e) {} }
|
||
let assigned = null
|
||
if (techId) { const nm = await resolveTechName(techId); if (!nm) return json(res, 404, { ok: false, error: 'tech introuvable: ' + techId }); const r = await retryWrite(() => erp.update('Dispatch Technician', nm, { traccar_device_id: deviceId })); if (!(r && r.ok)) return json(res, 500, { ok: false, error: (r && r.error) || 'écriture échouée' }); assigned = techId }
|
||
invalidateTechCache()
|
||
return json(res, 200, { ok: true, device_id: deviceId, tech_id: techId || null, assigned, released })
|
||
}
|
||
// 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 }
|