gigafibre-fsm/services/targo-hub/lib/roster.js
louispaulb cd12dbe601 feat(dispatch): P4 hub-side optimize orchestration + same-address rules
P4 — the FULL optimize orchestration now lives in the hub
(POST /roster/optimize-plan): pool (buildUnassigned), day bucketing
(dated-in-window → its day; overdue/undated → day 1 with spillover to
next day if unplaced), vehicles from real shift windows (assignments ×
templates) or assumed 8-16 weekdays (assumed_shift map returned; never
weekends; absences excluded), existing load (occupancyByTechDay) reduces
capacity, origins = home else TARGO depot, priorities from j.priority,
per-job AM/PM windows + duration overrides accepted, OSRM matrix +
OR-Tools solve per day (shared solveVrp, also used by /optimize-routes).
Reusable by the morning auto-plan cron and the field app.

Address rules (user cases baked in, hub-side):
- SAME ADDRESS = ONE STOP: pool jobs sharing an address key (~10m coords
  or normalized address) are merged into one solver node (service times
  summed) → same tech, single stop. Verified: install + TV boîtier of
  the same customer (Cazaville) grouped on Stéphane, 🔗 badges.
- COORDINATION: a pool job at the address of an ALREADY-ASSIGNED job
  that day is pinned onto THAT tech (they're going anyway) — verified:
  "Réinstallation #253028" pinned to Frédérique who already has the
  install there; capable flag kept honest (⚠ if skill missing).

SPA: optimizeSuggestion → hub-first (dates/techs/opts/dur_overrides/
job_windows), entries enriched client-side (lvl/eff/noShift from
assumed_shift); local orchestration kept as emergency fallback. Review
shows 🔗 teal (coordonné avec déjà-assigné) / 🔗 indigo (N jobs même
adresse = 1 arrêt); map merges same-coord stops into one numbered pin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 19:14:38 -04:00

1743 lines
128 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

'use strict'
/**
* roster.js — Planification (« Roster AI »).
*
* Orchestre le solveur d'horaires OR-Tools (service roster-solver) avec les
* données réelles de l'ERPNext de facturation :
* - techniciens HUMAINS : Dispatch Technician (resource_type='human')
* - compétences : tags (_user_tags) du tech
* - disponibilité : status ('En pause'), absence_from/until, Tech Availability (Approuvé)
* - modèles de shifts : Shift Template
* - besoins de couverture : Shift Requirement (→ « dispo vs requis »)
* - assignations : Shift Assignment (statut Proposé/Publié)
*
* Le solveur ne fait QUE proposer ; /publish écrit les Shift Assignment.
* Aucune paie : on planifie + approuve, c'est tout.
*
* Routes (préfixe /roster) :
* GET /roster/technicians → techs humains + skills + indispos
* GET /roster/templates → modèles de shifts
* POST /roster/templates → créer un modèle
* GET /roster/requirements?start=&days= → besoins de couverture
* POST /roster/requirements → créer un besoin
* GET /roster/assignments?start=&days= → assignations existantes
* GET /roster/coverage?start=&days= → dispo vs requis (par besoin)
* POST /roster/generate {start,days,weights} → propose un horaire (n'écrit rien)
* POST /roster/publish {assignments} → écrit les Shift Assignment (Publié)
* POST /roster/availability {…} → demande congé/pause (Tech Availability)
* POST /roster/availability/:name/approve → approuve une demande
* POST /roster/technician/:id/pause {paused,reason} → met/retire un tech en pause
*/
const http = require('http')
const crypto = require('crypto')
const { json, parseBody, log, readJsonFile, writeJsonFile } = require('./helpers')
const erp = require('./erp')
const cfg = require('./config')
const fs = require('fs')
const path = require('path')
const 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
}
// Priorité de job = champ ERPNext standard (low/medium/high), édité via /roster/job/update (comme le pool). Plus de store parallèle.
const SOLVER_URL = cfg.ROSTER_SOLVER_URL || 'http://roster-solver:8090'
const PAUSE_STATUS = 'En pause'
const AVAIL_STATUS = 'Disponible'
// ── Date helpers (local, sans dépendance) ──────────────────────────────────
function iso (d) { return d.toISOString().slice(0, 10) }
function parseISO (s) { const [y, m, dd] = s.split('-').map(Number); return new Date(Date.UTC(y, m - 1, dd)) }
function addDays (d, n) { const r = new Date(d); r.setUTCDate(r.getUTCDate() + n); return r }
function rangeDates (start, days) {
const s = parseISO(start); const out = []
for (let i = 0; i < days; i++) out.push(iso(addDays(s, i)))
return out
}
function splitCsv (s) {
return String(s || '').split(',').map(x => x.trim()).filter(Boolean)
}
// POST au solveur via le module http natif (comme erpFetch — fiable dans le
// process long du hub, contrairement au fetch global undici).
function postSolver (path, body) {
const data = JSON.stringify(body)
const u = new URL(SOLVER_URL + path)
return new Promise((resolve, reject) => {
const req = http.request({
hostname: u.hostname, port: u.port || 80, path: u.pathname + u.search, method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
timeout: 30000,
}, (res) => {
let d = ''
res.on('data', c => { d += c })
res.on('end', () => { try { resolve(JSON.parse(d)) } catch { resolve({ status: 'ERROR', message: 'réponse solveur invalide' }) } })
})
req.on('error', reject)
req.on('timeout', () => { req.destroy(); reject(new Error('solveur: timeout')) })
req.write(data); req.end()
})
}
const OSRM_URL = cfg.OSRM_URL || 'http://osrm:5000'
// Matrice de TEMPS DE ROUTE (minutes) via OSRM /table, dans l'ordre attendu par route_solver : jobs d'abord, PUIS domiciles des véhicules.
// null si un nœud n'a pas de coordonnées → le solveur retombe proprement sur le haversine (pas de dépendance dure à OSRM).
async function osrmMatrix (jobs, vehicles) {
const ok = (a, b) => a != null && b != null && isFinite(+a) && isFinite(+b) && (Math.abs(+a) > 0.01 || Math.abs(+b) > 0.01)
// Ordre des nœuds = celui de route_solver : jobs d'abord, PUIS domiciles des véhicules. null = sans coordonnées.
const nodes = []
for (const j of jobs) nodes.push(ok(j.lat, j.lon) ? [+j.lon, +j.lat] : null)
for (const v of vehicles) nodes.push(ok(v.home_lat, v.home_lon) ? [+v.home_lon, +v.home_lat] : null)
const N = nodes.length
const withIdx = []; for (let i = 0; i < N; i++) if (nodes[i]) withIdx.push(i)
if (withIdx.length < 2) return null // pas assez de points géolocalisés → le solveur retombe sur le haversine
const coords = withIdx.map(i => nodes[i])
const path = '/table/v1/driving/' + coords.map(c => c[0].toFixed(6) + ',' + c[1].toFixed(6)).join(';') + '?annotations=duration'
const u = new URL(OSRM_URL + path)
const raw = await new Promise((resolve, reject) => {
const rq = http.request({ hostname: u.hostname, port: u.port || 80, path: u.pathname + u.search, method: 'GET', timeout: 20000 }, (r) => {
let d = ''; r.on('data', c => { d += c }); r.on('end', () => resolve(d))
})
rq.on('error', reject); rq.on('timeout', () => { rq.destroy(); reject(new Error('osrm timeout')) }); rq.end()
})
const j = JSON.parse(raw)
if (j.code !== 'Ok' || !Array.isArray(j.durations)) throw new Error('osrm code ' + (j.code || '?'))
// Matrice NxN complète en MINUTES : temps de route OSRM pour les paires géolocalisées, 0 (neutre) pour tout nœud sans coords.
const M = Array.from({ length: N }, () => new Array(N).fill(0))
for (let a = 0; a < withIdx.length; a++) for (let b = 0; b < withIdx.length; b++) { const s = j.durations[a][b]; M[withIdx[a]][withIdx[b]] = (s == null ? 0 : Math.round(s / 60 * 10) / 10) }
return M
}
// GET brut vers OSRM (helper des proxys /roster/osrm-*) — remplace les appels Mapbox payants du SPA.
function osrmGet (path) {
const u = new URL(OSRM_URL + path)
return new Promise((resolve, reject) => {
const rq = http.request({ hostname: u.hostname, port: u.port || 80, path: u.pathname + u.search, method: 'GET', timeout: 20000 }, (r) => {
let d = ''; r.on('data', c => { d += c }); r.on('end', () => { try { resolve(JSON.parse(d)) } catch (e) { reject(new Error('osrm: réponse invalide')) } })
})
rq.on('error', reject); rq.on('timeout', () => { rq.destroy(); reject(new Error('osrm timeout')) }); rq.end()
})
}
const osrmPts = (b, max) => { const pts = Array.isArray(b.points) ? b.points.slice(0, max) : []; return (pts.length >= 2 && pts.every(p => Array.isArray(p) && isFinite(+p[0]) && isFinite(+p[1]))) ? pts.map(p => (+p[0]).toFixed(6) + ',' + (+p[1]).toFixed(6)).join(';') : null }
// Matrice OSRM (si dispo) + solveur VRP — brique partagée (endpoint /optimize-routes ET optimizePlan).
async function solveVrp (body) {
if (!body.matrix) {
try { const m = await osrmMatrix(body.jobs, body.vehicles); if (m) { body.matrix = m; body.matrix_source = 'osrm' } }
catch (e) { log('osrm matrix skip: ' + (e && e.message)) }
}
return postSolver('/route', body)
}
// ── P4 — Orchestration d'optimisation COMPLÈTE côté hub (réutilisable : SPA, cron auto-plan matinal, app terrain). ──
// Règles métier incluses :
// • MÊME ADRESSE = 1 ARRÊT : les jobs du pool à la même adresse sont fusionnés en 1 nœud solveur (temps sur place additionnés)
// → même tech, arrêt unique (ex. installation + config boîtier tel au même domicile).
// • COORDINATION : un job du pool à l'adresse d'un job DÉJÀ ASSIGNÉ ce jour-là est ÉPINGLÉ sur CE tech (il y va déjà) —
// ex. « Configuration de boîtier tel » placé sur l'arrêt de l'installateur.
const hmToMin = (t) => { const p = String(t || '').split(':'); const h = Number(p[0]); const m = Number(p[1]) || 0; return isFinite(h) ? h * 60 + m : null }
function addrKeyOf (j) { // clé d'adresse : coords arrondies (~10 m) si dispo, sinon adresse normalisée
const la = Number(j.latitude != null ? j.latitude : j.lat); const lo = Number(j.longitude != null ? j.longitude : j.lon)
if (isFinite(la) && isFinite(lo) && Math.abs(la) > 0.01) return la.toFixed(4) + ',' + lo.toFixed(4)
const a = String(j.address || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').replace(/[^a-z0-9]/g, '')
return a.length >= 6 ? 'a:' + a : null
}
async function optimizePlan (body) {
const start = body.start || new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
const dates = (Array.isArray(body.dates) && body.dates.length ? body.dates.slice(0, 7) : rangeDates(start, Math.max(1, Math.min(7, Number(body.days) || 2)))).sort()
const lo = dates[0]; const hi = dates[dates.length - 1]
const span = Math.round((Date.parse(hi) - Date.parse(lo)) / 86400000) + 1
const opts = body.opts || {}
const durOv = body.dur_overrides || {}
const winOv = body.job_windows || {} // {jobName:{tw_start_min,tw_end_min}} (AM/PM)
const wantTech = Array.isArray(body.tech_ids) && body.tech_ids.length ? new Set(body.tech_ids) : null
const [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))
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 || '', 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(),
})
// 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é).
const dset = new Set(dates); const byDay = {}; const skipped = []
for (const j of pool) {
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]
;(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: !j.required_skill || !t || (t.skills || []).includes(j.required_skill) })
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 || [], 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 || '',
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) : 2, 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: !j.required_skill || (t.skills || []).includes(j.required_skill), 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, 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 ────────
async function fetchTechnicians () {
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'],
limit: 500,
})
return rows.map(t => ({
id: t.technician_id || t.name,
name: t.full_name || t.technician_id,
status: t.status,
group: t.tech_group || '',
efficiency: Number(t.efficiency) || 1,
cost_salary_h: Number(t.cost_salary_h) || 0,
cost_charges_pct: Number(t.cost_charges_pct) || 0,
cost_other_h: Number(t.cost_other_h) || 0,
cost_h: Math.round(((Number(t.cost_salary_h) || 0) * (1 + (Number(t.cost_charges_pct) || 0) / 100) + (Number(t.cost_other_h) || 0)) * 100) / 100,
color: t.color_hex || '#1976d2',
phone: t.phone,
traccar_device_id: t.traccar_device_id || '', // appareil Traccar associé (pour GPS live + tracé) — éditable inline
employee: t.employee,
skills: splitCsv(t.skills || t._user_tags), // champ skills (ou tags Frappe)
skill_levels: (() => { try { return JSON.parse(t.skill_levels || '{}') } catch { return {} } })(), // {compétence: niveau 15}
skill_eff: (() => { try { return JSON.parse(t.skill_eff || '{}') } catch { return {} } })(), // {compétence: facteur d'efficacité (vitesse)}
absence_from: t.absence_from,
absence_until: t.absence_until,
}))
}
// 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,
}))
}
// ── Construit le payload du solveur + l'appelle ─────────────────────────────
async function generate (start, days, weights) {
const dateList = rangeDates(start, days)
// Séquentiel volontaire : le backend frappe (peu de workers) reset des
// connexions sous rafale concurrente → erp.list renvoie [] par intermittence.
const techs = await fetchTechnicians()
const templates = await fetchTemplates()
const requirements = await fetchRequirements(start, days)
const unavail = await buildUnavailability(techs, dateList)
const shift_templates = templates.map(t => ({
id: t.name, name: t.template_name || t.name,
hours: Number(t.hours) || 8,
}))
const technicians = techs.map(t => ({
id: t.id, name: t.name, skills: t.skills,
max_hours_week: 40, max_days: 5, cost_per_h: t.cost_h || 0,
zone_home: null, preferred_off: [], time_factor: t.efficiency || 1,
unavailable: [...unavail[t.id]],
}))
const coverage = requirements.map(r => ({
date: r.requirement_date, shift: r.shift_template, zone: r.zone || '—',
required: Number(r.required_count) || 1,
required_skills: splitCsv(r.required_skills),
}))
const payload = { horizon: { start, days }, shift_templates, technicians, coverage, weights: weights || undefined, max_seconds: 12 }
const result = await postSolver('/solve', payload)
// enrichir avec le nom + couleur pour l'UI
const nameById = Object.fromEntries(techs.map(t => [t.id, t.name]))
const colorByTpl = Object.fromEntries(templates.map(t => [t.name, t.color || '#1976d2']))
for (const a of (result.assignments || [])) {
a.tech_name = nameById[a.tech] || a.tech
a.color = colorByTpl[a.shift] || '#1976d2'
}
return { ...result, counts: { technicians: technicians.length, templates: shift_templates.length, requirements: coverage.length } }
}
// Écrit les assignations retenues comme Shift Assignment (Publié).
async function publish (assignments) {
const created = []; const errors = []
for (const a of assignments || []) {
const r = await retryWrite(() => erp.create('Shift Assignment', {
technician: a.tech, technician_name: a.tech_name || '',
assignment_date: a.date, shift_template: a.shift, zone: a.zone || '',
hours: Number(a.hours) || 0, status: 'Publié', source: a.source || 'solveur',
}))
if (r.ok) created.push(r.name); else errors.push({ a, error: r.error })
}
return { ok: errors.length === 0, created: created.length, errors }
}
// dispo vs requis : pour chaque besoin, compte les assignations publiées correspondantes
async function coverage (start, days) {
const reqs = await fetchRequirements(start, days)
const asgs = await fetchAssignments(start, days)
const key = (d, s, z) => `${d}|${s}|${z || '—'}`
const counts = {}
for (const a of asgs) {
if (a.status === 'Annulé') continue
counts[key(a.date, a.shift, a.zone)] = (counts[key(a.date, a.shift, a.zone)] || 0) + 1
}
return reqs.map(r => {
const assigned = counts[key(r.requirement_date, r.shift_template, r.zone)] || 0
const required = Number(r.required_count) || 0
return { date: r.requirement_date, shift: r.shift_template, zone: r.zone || '—', required, assigned, shortfall: Math.max(0, required - assigned) }
})
}
// ── Prise de RDV : disponibilité consciente du roster ──────────────────────
// Renvoie les fenêtres libres où un tech EN SHIFT publié ce jour-là, avec la
// compétence requise, est disponible (trous dans son shift moins les jobs déjà
// pointés). Sert aux 2 canaux : on propose au client, ou on valide son choix.
function timeToH (t) { if (!t) return 0; const [h, m] = String(t).split(':').map(Number); return (h || 0) + (m || 0) / 60 }
function nowFrappe () { return new Date().toLocaleString('sv-SE', { timeZone: 'America/Toronto' }).replace('T', ' ') } // "YYYY-MM-DD HH:MM:SS"
function minutesBetween (a, b) { if (!a || !b) return null; const ms = Date.parse(String(b).replace(' ', 'T')) - Date.parse(String(a).replace(' ', 'T')); return isFinite(ms) ? Math.max(0, Math.round(ms / 60000)) : null }
function hToTime (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') }
// ── DURÉE D'UNE JOB = modèle ADDITIF par caractéristiques (hors transport) ───────────────────────────────
// Base (1) + Σ caractéristiques cochées (×qté), × vitesse tech. Table ÉDITABLE (tableur inline Ops), persistée en JSON.
// `minutes` = seed (réglé à la main, inspiré du tableur dispatch) ; `learned_min`+`samples` = calibration future par
// régression sur les durées RÉELLES (geofence/checkpoints) — « triangulation » des temps par caractéristique.
const JOBCHAR_PATH = '/app/data/job-characteristics.json'
const JOBCHAR_SEED = { items: [
{ id: 'install_resid', cat: 'base', label: 'Installation fibre résidentielle', minutes: 120, per_qty: false, keywords: 'installation,raccordement' },
{ id: 'install_comm', cat: 'base', label: 'Installation commerciale', minutes: 240, per_qty: false, keywords: 'commercial' },
{ id: 'predrop', cat: 'base', label: 'Pré-drop (pose)', minutes: 120, per_qty: false, keywords: 'pre-drop,predrop,pré-drop' },
{ id: 'repar_fibre', cat: 'base', label: 'Réparation / bris de fibre', minutes: 105, per_qty: false, keywords: 'réparation,reparation,bris,fibre' },
{ id: 'depan_wifi', cat: 'base', label: 'Dépannage Wifi / réseau', minutes: 90, per_qty: false, keywords: 'wifi,sans-fil,internet' },
{ id: 'changer_secteur', cat: 'base', label: 'Changer de secteur', minutes: 75, per_qty: false, keywords: 'secteur' },
{ id: 'retrait', cat: 'base', label: 'Retrait / désinstallation', minutes: 60, per_qty: false, keywords: 'retrait,désinstall,desinstall' },
{ id: 'chgmt_equip', cat: 'base', label: "Changement d'équipement (ONU/modem/Hx)", minutes: 45, per_qty: false, keywords: 'changement,onu,modem,équipement,equipement,hx' },
{ id: 'migration_tv', cat: 'base', label: 'Migration TV / boîtier', minutes: 45, per_qty: false, keywords: 'migration,tv,boîtier,boitier' },
{ id: 'ata', cat: 'base', label: 'Activation téléphonie (ATA)', minutes: 45, per_qty: false, keywords: 'ata,téléphonie,telephonie' },
{ id: 'enfoui', cat: 'addon', label: 'Drop enfoui (au lieu daérien)', minutes: 75, per_qty: false, keywords: 'enfoui,enterré,enterre,souterrain' },
{ id: 'echelle', cat: 'addon', label: 'Aérien avec échelle / poteau', minutes: 30, per_qty: false, keywords: 'aérien,aerien,poteau,échelle,echelle' },
{ id: 'dist_poteau', cat: 'addon', label: 'Distance poteau → maison > 60 m', minutes: 30, per_qty: false, keywords: '' },
{ id: 'boitier_tv', cat: 'addon', label: 'Boîtier TV / STB supplémentaire', minutes: 30, per_qty: true, keywords: 'stb,boîtier tv,boitier tv' },
{ id: 'mesh', cat: 'addon', label: "Point d'accès mesh supplémentaire", minutes: 20, per_qty: true, keywords: 'mesh,borne' },
{ id: 'tel_ata', cat: 'addon', label: 'Ligne téléphonie / ATA ajoutée', minutes: 30, per_qty: true, keywords: '' },
{ id: 'epissure', cat: 'addon', label: 'Épissure / fusion (par connecteur)', minutes: 20, per_qty: true, keywords: 'épissure,epissure,fusion' },
{ id: 'wifi_complexe', cat: 'addon', label: 'Config Wifi complexe (multi-SSID/VLAN)', minutes: 30, per_qty: false, keywords: '' },
{ id: 'tel_complexe', cat: 'addon', label: 'Téléphonie complexe (PBX/multi-lignes)', minutes: 30, per_qty: false, keywords: 'pbx' },
{ id: 'test_otdr', cat: 'addon', label: 'Test / certification (OTDR, photos)', minutes: 20, per_qty: false, keywords: 'otdr,certification' },
{ id: 'acces_difficile', cat: 'addon', label: 'Accès difficile (hauteur, vide sanitaire)', minutes: 30, per_qty: false, keywords: '' },
{ id: 'tampon_client', cat: 'modifier', label: 'Tampon coordination client', minutes: 15, per_qty: false, keywords: '' },
{ id: 'junior_install', cat: 'modifier', label: 'Tech junior sur install (pénalité)', minutes: 90, per_qty: false, keywords: '' },
] }
function readJobChar () { try { return JSON.parse(require('fs').readFileSync(JOBCHAR_PATH, 'utf8')) } catch (e) { return JSON.parse(JSON.stringify(JOBCHAR_SEED)) } }
function writeJobChar (items) { try { require('fs').writeFileSync(JOBCHAR_PATH, JSON.stringify({ items, updated: nowFrappe() }, null, 2)); return true } catch (e) { return false } }
// Durée estimée (minutes, hors transport) à partir des caractéristiques cochées : picks = [{id, qty}].
function estimateMinutes (picks, items) {
const by = {}; for (const i of (items || readJobChar().items)) by[i.id] = i
let m = 0; for (const p of (picks || [])) { const it = by[p.id]; if (!it) continue; const mn = (it.learned_min != null ? it.learned_min : it.minutes) || 0; m += mn * (it.per_qty ? (Number(p.qty) || 1) : 1) }
return Math.round(m)
}
// Auto-détection DÉTERMINISTE (mots-clés / regex, AUCUNE IA) des caractéristiques d'un job depuis son texte.
function detectCharacteristics (job, items) {
const strip = s => String(s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '')
const text = strip([job.subject, job.legacy_detail, job.legacy_dept, job.job_type, job.service_type].join(' '))
const hit = kw => String(kw || '').split(',').map(x => strip(x).trim()).filter(Boolean).some(k => new RegExp('\\b' + k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b').test(text)) // limites de mots → pas de faux positif « ap » dans « après »
const bases = items.filter(i => i.cat === 'base')
let base = bases.find(b => hit(b.keywords))
if (!base) { const jt = strip(job.job_type); base = bases.find(b => (jt.includes('install') && b.id === 'install_resid') || (jt.includes('repar') && b.id === 'repar_fibre') || (jt.includes('retrait') && b.id === 'retrait')) }
const picks = base ? [{ id: base.id, qty: 1 }] : []
for (const a of items.filter(i => i.cat === 'addon')) if (hit(a.keywords)) picks.push({ id: a.id, qty: 1 })
return picks
}
// Sites PRÉ-CÂBLÉS : une INSTALLATION y prend ~¼ du temps normal (infra déjà en place → branchement rapide).
const FAST_INSTALL_SITES = ['lac des pins', 'camping', 'domaine dauphinais']
function estimateForJob (job, items) {
items = items || readJobChar().items
const picks = detectCharacteristics(job, items); const by = {}; for (const i of items) by[i.id] = i
let minutes = estimateMinutes(picks, items)
const labels = picks.map(p => (by[p.id] || {}).label).filter(Boolean)
// Installation à un site pré-câblé → temps /4 (Lac des pins / Camping / Domaine Dauphinais).
const isInstall = picks.some(p => String(p.id).startsWith('install')) || String(job.required_skill || '').toLowerCase().includes('install')
if (isInstall && minutes > 0) {
const loc = [job.address, job.location_label, job.service_location, job.subject, job.municipalite].join(' ').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '')
if (FAST_INSTALL_SITES.some(s => loc.includes(s))) { minutes = Math.max(15, Math.round(minutes / 4)); labels.push('site pré-câblé ×¼') }
}
return { minutes, picks, labels }
}
// ── #56 Politique de créneaux offerts + holds temporaires ────────────────────
// Persistée dans le même fichier que la politique de reprise (sous-objet `booking`),
// éditée via /roster/policy (lib/roster-assistant.js). Appliquée ici à TOUTE source
// de créneaux (page /book, vue agent, fit) → comportement cohérent partout.
const BOOKING_DEFAULTS = { lead_hours: 24, day_start: 8, day_end: 18, days_offered: [1, 2, 3, 4, 5], horizon_days: 21, max_per_day: 0, hold_minutes: 10, skill_by_type: {} }
function getBookingPolicy () {
try { const p = JSON.parse(fs.readFileSync(POLICY_FILE, 'utf8')); return { ...BOOKING_DEFAULTS, ...(p.booking || {}) } } catch { return { ...BOOKING_DEFAULTS } }
}
// Compétence REQUISE pour un job → seuls les techs qui l'ont produisent des créneaux (techGaps filtre).
// Un ajout TV/borne WiFi peut mapper à '' (n'importe qui) ; une installation/réparation à 'installateur'.
// Source : champ explicite Dispatch Job.required_skill, sinon table service_type → tag (politique booking).
function skillForJob (job) {
if (!job) return ''
if (job.required_skill) return String(job.required_skill).trim()
const map = getBookingPolicy().skill_by_type || {}
return String(map[job.service_type] || '').trim()
}
// Repli : déduit une COMPÉTENCE (parmi les skills réels des techs) depuis le département/type legacy.
// Sert à colorer les tickets par la couleur de leur compétence (éditable via le gestionnaire de tags).
function deptToSkill (txt) {
const d = String(txt || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
if (!d) return ''
if (/teleph/.test(d)) return 'telephone'
if (/tele|televis/.test(d)) return 'tv'
if (/fusion|episs/.test(d)) return 'épissure'
if (/monteur|aerien/.test(d)) return 'monteur'
if (/netadmin|net admin/.test(d)) return 'netadmin'
if (/repar|desinstall/.test(d)) return 'réparation'
if (/install|fibre/.test(d)) return 'installation'
return ''
}
// 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()
for (const j of jobs) { j.required_skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject); j.required_level = jlv[j.name] || 0; const est = estimateForJob(j, chars); j.est_min = est.minutes; j.est_labels = est.labels }
await attachLocations(jobs)
log(`pool: construit ${jobs.length} jobs en ${nowMs() - t0}ms`) // mesure réelle (requête + enrichissement)
return jobs
}
function refreshPoolBg () { if (_poolCache.building) return; _poolCache.building = buildUnassigned().then(j => { _poolCache = { ts: nowMs(), jobs: j, building: null } }).catch(e => { _poolCache.building = null; log('pool refresh err: ' + (e && e.message)) }) }
function invalidatePool () { _poolCache.ts = 0; refreshPoolBg() } // après une écriture pool → forcer le rafraîchissement
// Holds en mémoire : quand un client/agent sélectionne une fenêtre, on la réserve
// quelques minutes pour éviter qu'un autre la prenne pendant la confirmation.
// Transitoire (perdu au redémarrage du hub = acceptable pour un blocage de ~10 min).
const bookingHolds = new Map() // clé 'YYYY-MM-DD|HH:MM' → [expiryMs, …]
function holdCount (key) {
const now = nowMs(); const arr = (bookingHolds.get(key) || []).filter(t => t > now)
if (arr.length) bookingHolds.set(key, arr); else bookingHolds.delete(key)
return arr.length
}
function addHold (key, minutes) {
const arr = (bookingHolds.get(key) || []).filter(t => t > nowMs())
arr.push(nowMs() + Math.max(1, minutes || 10) * 60000); bookingHolds.set(key, arr)
return arr.length
}
function releaseHold (key) { bookingHolds.delete(key) }
async function loadBookingData (start, days) {
const dates = rangeDates(start, days)
const asgs = await fetchAssignments(start, days)
const techs = await fetchTechnicians()
const templates = await fetchTemplates()
const unavail = await buildUnavailability(techs, dates) // En pause + absence_from/until + Tech Availability approuvées (par jour)
const techById = Object.fromEntries(techs.map(t => [t.id, t]))
const tplByName = Object.fromEntries(templates.map(t => [t.name, t]))
const jobs = await erp.list('Dispatch Job', {
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned']]],
fields: ['assigned_tech', 'scheduled_date', 'start_time', 'duration_h'], limit: 2000,
})
const booked = {}
for (const j of jobs) {
if (!j.start_time) continue
const k = j.assigned_tech + '|' + j.scheduled_date
;(booked[k] || (booked[k] = [])).push({ s: timeToH(j.start_time), e: timeToH(j.start_time) + (Number(j.duration_h) || 1) })
}
return { asgs, techById, tplByName, booked, unavail }
}
// Trous libres d'un tech (dans son shift, moins jobs pointés), filtrés compétence/zone.
function techGaps (a, d, skill, zone) {
const t = d.techById[a.tech]; if (!t || t.status === PAUSE_STATUS) return null
if (d.unavail && d.unavail[a.tech] && d.unavail[a.tech].has(a.date)) return null // absence/congé approuvé ce jour-là → pas de créneaux
if (skill && !(t.skills || []).includes(skill)) return null
if (zone && a.zone && a.zone !== zone) return null
const tpl = d.tplByName[a.shift]; if (!tpl) return null
if (tpl.on_call) return null // garde (sur appel) = capacité d'urgence, JAMAIS offerte au booking client
const sh = timeToH(tpl.start_time) || 8; const eh = timeToH(tpl.end_time) || (sh + (Number(tpl.hours) || 8))
const day = (d.booked[a.tech + '|' + a.date] || []).slice().sort((x, y) => x.s - y.s)
let cursor = sh; const gaps = []
for (const b of day) { if (b.s > cursor) gaps.push([cursor, b.s]); cursor = Math.max(cursor, b.e) }
if (cursor < eh) gaps.push([cursor, eh])
return { tech: t, gaps }
}
// Premier créneau libre (en heures, ex 8.5) d'un tech un jour donné pour une durée `dur`.
// Aucun trou assez large → empile après le dernier bloc (surbook visible) ; pas de shift régulier → null.
// Partagé par /roster/assign-job (drag-drop) et /roster/backfill-start-times.
function firstFitStart (d, techId, date, dur) {
const mine = d.asgs.filter(a => a.tech === techId && a.date === date && !(d.tplByName[a.shift] && d.tplByName[a.shift].on_call))
if (!mine.length) return null
let best = null
for (const a of mine) { const g = techGaps(a, d, null, null); if (!g) continue; for (const [gs, ge] of g.gaps) if (ge - gs >= dur - 1e-6) { if (best == null || gs < best) best = gs; break } }
if (best == null) { const tpl = d.tplByName[mine[0].shift]; const sh = timeToH(tpl && tpl.start_time) || 8; const day = (d.booked[techId + '|' + date] || []).slice().sort((x, y) => x.e - y.e); best = day.length ? day[day.length - 1].e : sh }
return best
}
async function bookingSlots ({ skill, zone, duration = 1, start, days = 7, limit = 24, aggregate = false, ignorePolicy = false } = {}) {
const dur = Number(duration) || 1
const pol = getBookingPolicy()
const d = await loadBookingData(start, days)
const leadCut = ignorePolicy ? 0 : nowMs() + (pol.lead_hours || 0) * 3600000
const dayStart = ignorePolicy ? 0 : (Number.isFinite(pol.day_start) ? pol.day_start : 0)
const dayEnd = ignorePolicy ? 24 : (Number.isFinite(pol.day_end) ? pol.day_end : 24)
const offered = !ignorePolicy && Array.isArray(pol.days_offered) && pol.days_offered.length ? pol.days_offered : null
const out = []
for (const a of d.asgs) {
if (a.status === 'Annulé') continue
if (offered && !offered.includes(parseISO(a.date).getUTCDay())) continue // jour non offert par la politique
const g = techGaps(a, d, skill, zone); if (!g) continue
for (const [gs, ge] of g.gaps) {
let s = Math.max(gs, dayStart); const end = Math.min(ge, dayEnd) // borné à la plage horaire offerte
while (s + dur <= end) {
const slotMs = parseISO(a.date).getTime() + s * 3600000 // approx UTC — suffisant pour un délai exprimé en heures
if (slotMs >= leadCut) out.push({ date: a.date, start: hToTime(s), end: hToTime(s + dur), start_h: s, tech: a.tech, tech_name: g.tech.name, zone: a.zone || '', shift: a.shift })
s += dur
}
}
}
if (aggregate) { // client : 1 fenêtre par (date,heure) + nb de techs dispo, moins les holds actifs
const byWin = {}
for (const s of out) { const k = s.date + '|' + s.start; (byWin[k] || (byWin[k] = { date: s.date, start: s.start, end: s.end, start_h: s.start_h, available: 0 })).available++ }
let arr = Object.values(byWin).map(w => ({ ...w, available: w.available - holdCount(w.date + '|' + w.start) })).filter(w => w.available > 0)
arr.sort((x, y) => x.date.localeCompare(y.date) || x.start_h - y.start_h)
if (!ignorePolicy && pol.max_per_day > 0) { const c = {}; arr = arr.filter(w => { c[w.date] = (c[w.date] || 0) + 1; return c[w.date] <= pol.max_per_day }) } // plafond/jour
return arr.slice(0, limit)
}
out.sort((x, y) => x.date.localeCompare(y.date) || x.start_h - y.start_h)
return out.slice(0, limit)
}
// Fit : le client fournit 3 dispos classées → on place dans le 1er choix tenable,
// sinon 2e, sinon 3e. Si aucune ne tient → on PROPOSE nos créneaux (fallback).
async function fitBooking ({ skill, zone, duration = 1, prefs = [] } = {}) {
const dur = Number(duration) || 1
const dates = [...new Set((prefs || []).map(p => p.date).filter(Boolean))].sort()
if (!dates.length) return { chosen: null, proposed: [] }
const span = Math.max(1, Math.round((parseISO(dates[dates.length - 1]) - parseISO(dates[0])) / 86400000) + 1)
const d = await loadBookingData(dates[0], span)
const byDate = {}; for (const a of d.asgs) (byDate[a.date] || (byDate[a.date] = [])).push(a)
for (let i = 0; i < prefs.length; i++) {
const p = prefs[i]; const ps = timeToH(p.start); const pe = ps + dur
for (const a of (byDate[p.date] || [])) {
if (a.status === 'Annulé') continue
const g = techGaps(a, d, skill, zone); if (!g) continue
if (g.gaps.some(([gs, ge]) => gs <= ps && ge >= pe)) {
return { chosen: { rank: i + 1, date: p.date, start: p.start, end: hToTime(pe), tech: a.tech, tech_name: g.tech.name, zone: a.zone || '', shift: a.shift }, proposed: [] }
}
}
}
const proposed = await bookingSlots({ skill, zone, duration: dur, start: dates[0], days: 14, limit: 6, aggregate: true })
return { chosen: null, proposed }
}
// ── Portail public de prise de RDV (staging — PAS sur Lovable tant que non validé) ──
function todayET () { return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) }
async function jobByToken (token) {
if (!token) return null
const rows = await erp.list('Dispatch Job', { filters: [['booking_token', '=', token]], fields: ['name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'start_time', 'booking_status'], limit: 1 })
return rows[0] || null
}
async function confirmWindow (jobName, date, start, duration, skill) {
// À la confirmation on veut juste vérifier que le tech est ENCORE physiquement libre
// (pas re-filtrer par la politique d'offre) → ignorePolicy. MAIS on garde le filtre COMPÉTENCE
// (skill) sinon on pourrait assigner un tech non qualifié au créneau choisi.
const day = await bookingSlots({ skill, duration, start: date, days: 1, limit: 300, ignorePolicy: true })
const slot = day.find(s => s.start === start)
if (!slot) return { ok: false, message: 'Ce créneau vient d\'être pris — choisissez-en un autre.' }
const st = start.length === 5 ? start + ':00' : start
const r = await retryWrite(() => erp.update('Dispatch Job', jobName, { scheduled_date: date, start_time: st, assigned_tech: slot.tech, status: 'assigned', booking_status: 'Confirmé' }))
if (r.ok) releaseHold(date + '|' + start)
return r.ok ? { ok: true, confirmed: true, date, start, tech: slot.tech_name } : { ok: false, message: r.error || 'échec' }
}
const BOOK_HTML = `<!doctype html><html lang=fr><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>Prendre rendez-vous — Gigafibre</title>
<style>body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;margin:0;background:#f4f6f8;color:#1a1a1a}.wrap{max-width:560px;margin:0 auto;padding:16px}.card{background:#fff;border-radius:12px;box-shadow:0 1px 4px rgba(0,0,0,.08);padding:20px;margin-bottom:12px}h1{font-size:20px;margin:0 0 4px}.sub{color:#666;font-size:13px;margin-bottom:8px}.brand{color:#1565c0;font-weight:800}.day{font-weight:700;margin:14px 0 6px;font-size:13px;color:#444}.slots{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:8px}.slot{border:1px solid #d0d7de;border-radius:8px;padding:10px 6px;text-align:center;cursor:pointer;font-size:14px;position:relative}.slot:hover{border-color:#1565c0}.slot.sel{background:#e3f2fd;border-color:#1565c0;font-weight:700}.rank{position:absolute;top:-8px;right:-8px;background:#1565c0;color:#fff;width:20px;height:20px;border-radius:50%;font-size:12px;line-height:20px}.btn{background:#1565c0;color:#fff;border:0;border-radius:8px;padding:13px 18px;font-size:15px;font-weight:600;cursor:pointer;width:100%;margin-top:12px}.btn:disabled{background:#b0bec5}.hint{font-size:12px;color:#666;margin:8px 0}.ok{background:#e8f5e9;color:#1b5e20;padding:16px;border-radius:8px;text-align:center}.err{background:#ffebee;color:#b71c1c;padding:12px;border-radius:8px}.week{background:#1565c0;color:#fff;font-weight:700;font-size:13px;padding:8px 12px;border-radius:8px;margin:18px 0 4px}.ampm{font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.5px;margin:10px 0 4px;font-weight:700}.empty{color:#999;font-size:12px;font-style:italic}</style></head>
<body><div class=wrap><div class=card><h1>Prendre <span class=brand>rendez-vous</span></h1><div class=sub id=jobinfo>Chargement…</div><div id=content></div></div><div class=sub style=text-align:center>Gigafibre · propulsé par Targo</div></div>
<script>
const token=new URLSearchParams(location.search).get('token')||'';
const FR=['dimanche','lundi','mardi','mercredi','jeudi','vendredi','samedi'];
const MO=['janv.','févr.','mars','avr.','mai','juin','juil.','août','sept.','oct.','nov.','déc.'];
let picks=[];
function d2(iso){const a=iso.split('-').map(Number);return new Date(Date.UTC(a[0],a[1]-1,a[2]))}
function dayLabel(iso){const dt=d2(iso);return FR[dt.getUTCDay()]+' '+dt.getUTCDate()+' '+MO[dt.getUTCMonth()]}
function wkMon(iso){const dt=d2(iso);const off=(dt.getUTCDay()+6)%7;dt.setUTCDate(dt.getUTCDate()-off);return dt.toISOString().slice(0,10)}
function wkLabel(m){const a=d2(m);const b=new Date(a);b.setUTCDate(b.getUTCDate()+6);return 'Semaine du '+a.getUTCDate()+' '+MO[a.getUTCMonth()]+' '+b.getUTCDate()+' '+MO[b.getUTCMonth()]}
async function load(){const r=await fetch('/book/api/options?token='+encodeURIComponent(token)).then(x=>x.json()).catch(()=>({ok:false}));const info=document.getElementById('jobinfo'),c=document.getElementById('content');
if(!r.ok){info.textContent='';c.innerHTML='<div class=err>Lien invalide ou expiré. Contactez-nous.</div>';return}
if(r.job.scheduled){info.innerHTML='Votre rendez-vous est déjà confirmé : <b>'+dayLabel(r.job.scheduled)+'</b>.';c.innerHTML='';return}
info.innerHTML='Choisissez vos disponibilités <b>en ordre de préférence</b> (jusquà 3). On confirme le 1er créneau possible.';
if(!r.windows.length){c.innerHTML='<div class=err>Aucune disponibilité pour le moment — nous vous contacterons.</div>';return}
const weeks={};r.windows.forEach(w=>{const mk=wkMon(w.date);(weeks[mk]=weeks[mk]||{});(weeks[mk][w.date]=weeks[mk][w.date]||[]).push(w)});let h='';
Object.keys(weeks).sort().forEach(mk=>{h+='<div class=week>'+wkLabel(mk)+'</div>';Object.keys(weeks[mk]).sort().forEach(d=>{const ws=weeks[mk][d].sort((a,b)=>a.start_h-b.start_h);const am=ws.filter(w=>w.start_h<12),pm=ws.filter(w=>w.start_h>=12);h+='<div class=day>'+dayLabel(d)+'</div>';const sect=(lab,arr)=>arr.length?('<div class=ampm>'+lab+'</div><div class=slots>'+arr.map(w=>'<div class=slot data-d="'+w.date+'" data-s="'+w.start+'">'+w.start+'</div>').join('')+'</div>'):'';h+=sect('Matin',am)+sect('Après-midi',pm)})});
h+='<div class=hint id=hint>Touchez 1 à 3 créneaux.</div><button class=btn id=go disabled>Confirmer mes disponibilités</button>';c.innerHTML=h;
c.querySelectorAll('.slot').forEach(el=>el.onclick=()=>toggle(el));document.getElementById('go').onclick=submit}
function toggle(el){const k=el.dataset.d+'|'+el.dataset.s,i=picks.indexOf(k);if(i>=0)picks.splice(i,1);else if(picks.length<3)picks.push(k);render()}
function render(){document.querySelectorAll('.slot').forEach(el=>{const k=el.dataset.d+'|'+el.dataset.s,i=picks.indexOf(k);el.classList.toggle('sel',i>=0);let b=el.querySelector('.rank');if(i>=0){if(!b){b=document.createElement('div');b.className='rank';el.appendChild(b)}b.textContent=i+1}else if(b)b.remove()});document.getElementById('hint').textContent=picks.length?picks.length+' choix sélectionné(s) — le 1er sera priorisé.':'Touchez 1 à 3 créneaux.';document.getElementById('go').disabled=!picks.length}
async function submit(){const go=document.getElementById('go');go.disabled=true;go.textContent='Envoi…';const prefs=picks.map(k=>({date:k.split('|')[0],start:k.split('|')[1]}));const r=await fetch('/book/api/submit?token='+encodeURIComponent(token),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode:'rank',prefs})}).then(x=>x.json()).catch(()=>({ok:false}));const c=document.getElementById('content');
if(r.ok&&r.confirmed)c.innerHTML='<div class=ok>✅ Rendez-vous confirmé : <b>'+dayLabel(r.date)+' à '+r.start+'</b>.<br>Merci !</div>';
else if(r.ok)c.innerHTML='<div class=ok>Merci ! '+(r.message||'Nous vous confirmerons sous peu.')+'</div>';
else c.innerHTML='<div class=err>'+(r.message||r.error||'Une erreur est survenue.')+'</div>'}
load();
</script></body></html>`
async function handlePublicBooking (req, res, method, path, url) {
if (path === '/book' && method === 'GET') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(BOOK_HTML) }
const token = url.searchParams.get('token') || ''
if (path === '/book/api/options' && method === 'GET') {
const job = await jobByToken(token); if (!job) return json(res, 404, { ok: false, error: 'lien invalide' })
const dur = Number(job.duration_h) || 1; const skill = skillForJob(job) // seuls les techs qualifiés
const windows = await bookingSlots({ skill, duration: dur, start: todayET(), days: getBookingPolicy().horizon_days || 21, aggregate: true, limit: 60 })
return json(res, 200, { ok: true, job: { location: job.service_location || '', service_type: job.service_type || '', required_skill: skill, duration: dur, scheduled: job.scheduled_date || '' }, windows })
}
if (path === '/book/api/submit' && method === 'POST') {
const job = await jobByToken(token); if (!job) return json(res, 404, { ok: false, error: 'lien invalide' })
const b = await parseBody(req); const dur = Number(job.duration_h) || 1; const skill = skillForJob(job)
if (b.mode === 'rank' && Array.isArray(b.prefs) && b.prefs.length) {
const fit = await fitBooking({ skill, duration: dur, prefs: b.prefs })
if (fit.chosen) { const r = await confirmWindow(job.name, fit.chosen.date, fit.chosen.start, dur, skill); if (r.ok) return json(res, 200, { ...r, rank: fit.chosen.rank }) }
await retryWrite(() => erp.update('Dispatch Job', job.name, { booking_prefs: JSON.stringify(b.prefs), booking_status: 'Proposé' }))
return json(res, 200, { ok: true, confirmed: false, message: 'Vos disponibilités sont enregistrées — nous vous confirmerons sous peu.' })
}
return json(res, 400, { ok: false, error: 'requête invalide' })
}
return json(res, 404, { error: 'not found' })
}
// ── APP TECHNICIEN : capture passive du temps via « checkpoints » (GPS geofence / scan / etc.) ──────────────
// Token signé par job (HMAC, stateless, sans login ni champ DB). Voir docs/field-tech-app.md.
const FIELD_SECRET = cfg.INTERNAL_TOKEN || cfg.ERP_SERVICE_TOKEN || 'targo-field-v1'
const ON_SITE_M = Number(process.env.FIELD_ONSITE_RADIUS_M) || 250 // rayon « sur place » autour de l'adresse
function fieldSign (name) { const h = crypto.createHmac('sha256', FIELD_SECRET).update(String(name)).digest('hex').slice(0, 12); return Buffer.from(String(name)).toString('base64url') + '.' + h }
function fieldVerify (token) { try { const [b, h] = String(token || '').split('.'); if (!b || !h) return null; const name = Buffer.from(b, 'base64url').toString('utf8'); const exp = crypto.createHmac('sha256', FIELD_SECRET).update(name).digest('hex').slice(0, 12); return crypto.timingSafeEqual(Buffer.from(h), Buffer.from(exp)) ? name : null } catch (e) { return null } }
function haversineM (a1, o1, a2, o2) { const R = 6371000, t = Math.PI / 180; const dA = (a2 - a1) * t, dO = (o2 - o1) * t; const x = Math.sin(dA / 2) ** 2 + Math.cos(a1 * t) * Math.cos(a2 * t) * Math.sin(dO / 2) ** 2; return Math.round(R * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x))) }
// Token PAR TECH (préfixe 'T:') → l'app charge les jobs du jour du tech. Token Mapbox public (pk) pour la carte de l'app.
const FIELD_MAPBOX = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg'
function techFieldSign (name) { return fieldSign('T:' + name) }
function techFieldVerify (t) { const v = fieldVerify(t); return (v && v.startsWith('T:')) ? v.slice(2) : null }
function serveFieldApp (res) {
try { let html = require('fs').readFileSync('/app/public/field-app.html', 'utf8'); html = html.split('__MAPBOX_TOKEN__').join(FIELD_MAPBOX).split('__HUB__').join(cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca'); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(html) } catch (e) { res.writeHead(500); return res.end('app terrain indisponible') }
}
function savePhoto (jobName, dataUrl) {
const m = /^data:image\/(\w+);base64,(.+)$/.exec(String(dataUrl || '')); if (!m) return null
const buf = Buffer.from(m[2], 'base64'); if (!buf.length || buf.length > 6 * 1024 * 1024) return null
const fs = require('fs'); const dir = '/app/uploads/field'; try { fs.mkdirSync(dir, { recursive: true }) } catch (e) {}
const fn = 'f_' + String(jobName).replace(/[^a-zA-Z0-9_-]/g, '') + '_' + Date.now() + '.' + (m[1] === 'png' ? 'png' : 'jpg')
try { fs.writeFileSync(dir + '/' + fn, buf); return fn } catch (e) { return null }
}
async function handleFieldTech (req, res, method, path, url) {
const token = url.searchParams.get('t') || url.searchParams.get('token') || ''
if (path === '/field/job' && method === 'GET') {
const name = fieldVerify(token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['name', 'subject', 'customer_name', 'address', 'latitude', 'longitude', 'scheduled_date', 'actual_start', 'actual_end', 'status'], limit: 1 })
const j = r && r[0]; if (!j) return json(res, 404, { ok: false, error: 'job introuvable' })
return json(res, 200, { ok: true, job: { ...j, actual_minutes: minutesBetween(j.actual_start, j.actual_end) } })
}
if (path === '/field/checkpoint' && method === 'POST') {
const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['name', 'latitude', 'longitude', 'actual_start', 'actual_end', 'completion_notes', 'status'], limit: 1 })
const j = r && r[0]; if (!j) return json(res, 404, { ok: false, error: 'job introuvable' })
const ts = nowFrappe()
const lat = Number(b.lat), lon = Number(b.lon); const hasGps = isFinite(lat) && isFinite(lon)
let dist = null
if (hasGps && j.latitude != null && j.longitude != null && (Math.abs(+j.latitude) > 0.01)) dist = haversineM(+j.latitude, +j.longitude, lat, lon)
const onSite = (b.type === 'geo_exit') || dist == null || dist <= ON_SITE_M // hors-zone ignoré pour la durée, gardé en note
const patch = {}
if (onSite) { if (!j.actual_start) patch.actual_start = ts; patch.actual_end = ts; if (j.status === 'open' || j.status === 'assigned') patch.status = 'In Progress' }
const note = `${ts} ${b.type || 'checkpoint'}${b.ref ? ' ' + String(b.ref).slice(0, 40) : ''}${hasGps ? ` · GPS ${lat.toFixed(5)},${lon.toFixed(5)}${b.acc ? ' ±' + Math.round(b.acc) + 'm' : ''}${dist != null ? ` · à ${dist}m` : ''}` : ''}${onSite ? '' : ' · HORS ZONE (ignoré durée)'}`
patch.completion_notes = ((j.completion_notes || '') + '\n' + note).slice(-4000)
const up = await retryWrite(() => erp.update('Dispatch Job', name, patch))
const startTs = j.actual_start || (onSite ? ts : null)
return json(res, up.ok ? 200 : 500, { ok: !!up.ok, on_site: onSite, distance_m: dist, minutes: minutesBetween(startTs, onSite ? ts : j.actual_end), actual_start: patch.actual_start || j.actual_start || '', actual_end: patch.actual_end || j.actual_end || '' })
}
if ((path === '/field' || path === '/field/app') && method === 'GET') return serveFieldApp(res) // PWA technicien (carte + tickets + photos + scan)
if (path === '/field/tech' && method === 'GET') { // jobs du jour (+ demain) du tech, via token PAR TECH
const tech = techFieldVerify(token); if (!tech) return json(res, 403, { ok: false, error: 'lien invalide' })
const dates = rangeDates(todayET(), 2) // aujourd'hui + demain
const rows = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', tech], ['scheduled_date', 'in', dates]], fields: ['name', 'subject', 'customer_name', 'address', 'latitude', 'longitude', 'scheduled_date', 'start_time', 'status', 'actual_start', 'actual_end', 'legacy_ticket_id', 'legacy_activation_url', 'notes', 'required_skill', 'duration_h'], orderBy: 'scheduled_date asc, start_time asc', limit: 200 })
const staffId = (String(tech).match(/(\d+)$/) || [])[1] || ''
const jobs = (rows || []).map(j => ({ name: j.name, subject: j.subject, customer: j.customer_name || '', address: j.address || '', lat: j.latitude, lon: j.longitude, date: j.scheduled_date, start: j.start_time ? String(j.start_time).slice(0, 5) : '', status: j.status, started: !!j.actual_start, ended: !!j.actual_end, minutes: minutesBetween(j.actual_start, j.actual_end), token: fieldSign(j.name), reply: j.legacy_ticket_id ? `https://store.targo.ca/targo/reply_ticket.php?ticket=${j.legacy_ticket_id}&staff=${staffId}` : '', activation: j.legacy_activation_url || '', notes: j.notes || '', skill: j.required_skill || '', dur: j.duration_h || null }))
return json(res, 200, { ok: true, tech, jobs })
}
if (path === '/field/photo' && method === 'POST') { // photo (base64) → /app/uploads/field + note sur le job
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'], limit: 1 }); const j = r && r[0]
const note = `\n${nowFrappe()} 📷 photo ${fn}${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 })
}
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) }
// 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 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_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'
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 || '', 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 || '', 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) {
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
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
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
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
}
for (const d of dates) {
const o = out[d]
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') {
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' })
return json(res, 200, { capacity: await capacityByDay(start, days), segments: DAY_SEGMENTS })
}
// 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 {
const j = await osrmGet('/route/v1/driving/' + q + '?overview=full&geometries=geojson&steps=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) }) }
}
// Optimisation de TOURNÉES (VRPTW + compétences + temps sur place) — proxy vers le solveur OR-Tools /route.
// Le SPA construit le payload {jobs, vehicles, ...} (il a coords/skills/durées/domiciles/quarts) ; on relaie au solveur interne.
if (path === '/roster/optimize-plan' && method === 'POST') { // P4 — orchestration COMPLÈTE hub (pool+quarts+occupation+règles d'adresse) → plan prêt pour la revue / cron auto-plan
const b = await parseBody(req)
try { return json(res, 200, await optimizePlan(b)) }
catch (e) { log('optimize-plan err: ' + ((e && e.stack) || e)); return json(res, 500, { ok: false, error: String((e && e.message) || e) }) }
}
if (path === '/roster/optimize-routes' && method === 'POST') {
const body = await parseBody(req)
if (!Array.isArray(body.jobs) || !Array.isArray(body.vehicles)) return json(res, 400, { error: 'jobs[] et vehicles[] requis' })
try { return json(res, 200, await solveVrp(body)) } // matrice OSRM auto si absente ; repli haversine dans le solveur
catch (e) { return json(res, 502, { status: 'ERROR', message: 'solveur injoignable: ' + (e && e.message) }) }
}
// Jours fériés QC (déterministe) — sert le badge « férié » du day-strip + la réduction de capacité par défaut.
if (path === '/roster/holidays' && method === 'GET') {
const from = url.searchParams.get('from') || todayET()
const to = url.searchParams.get('to') || iso(addDays(new Date(), 120))
return json(res, 200, { holidays: require('./holidays-qc').holidaysBetween(from, to) })
}
// Préavis de disponibilité AVANT un férié (~1 mois) : GET = aperçu (qui reste à notifier, AUCUN envoi) ; POST {date} = envoie le SMS aux techs non encore notifiés (idempotent).
if (path === '/roster/holiday-notice' && (method === 'GET' || method === 'POST')) {
const days = Math.min(120, Math.max(1, Number(url.searchParams.get('days')) || 35))
const hols = require('./holidays-qc').holidaysBetween(todayET(), iso(addDays(new Date(), days)))
const sent = readJsonFile('/app/data/holiday_notices.json', {}) // { 'YYYY-MM-DD': [techId,…] } — anti-doublon
const techs = (await fetchTechnicians()).filter(t => t.phone && /\d/.test(String(t.phone)))
if (method === 'GET') {
return json(res, 200, {
window_days: days, techs_with_phone: techs.length,
holidays: hols.map(h => ({ date: h.date, name: h.name, notified: (sent[h.date] || []).length, to_notify: techs.filter(t => !(sent[h.date] || []).includes(t.id)).length })),
})
}
const b = await parseBody(req); const date = String(b.date || (hols[0] && hols[0].date) || '')
const hol = hols.find(h => h.date === date) || require('./holidays-qc').isHoliday(date)
if (!hol) return json(res, 200, { ok: false, error: 'date non fériée ou hors fenêtre' })
const already = new Set(sent[date] || []); const sendSms = require('./twilio').sendSmsInternal
let count = 0; const failed = []
for (const t of techs) {
if (already.has(t.id)) continue
const msg = `Targo — ${hol.name} (${date}) est un jour férié. Travailles-tu cette journée ? Sinon, indique ta disponibilité (et des dates de remplacement) dans ton app Targo. Merci de répondre tôt.`
try { await sendSms(t.phone, msg, null); already.add(t.id); count++ } catch (e) { failed.push(t.id) }
}
sent[date] = [...already]; writeJsonFile('/app/data/holiday_notices.json', sent)
return json(res, 200, { ok: true, date, name: hol.name, sent: count, failed })
}
// CROISEMENT temps estimé ↔ geofencing (rec C) : compare l'estimé (duration_h) au RÉEL sur site (actual_end actual_start,
// écrit par le webhook geofence /field/ts). Agrège par tech et par catégorie → calibrer les estimés + mémoire de fit.
if (path === '/roster/geofence-stats' && method === 'GET') {
const days = Math.min(365, Math.max(1, Number(url.searchParams.get('days')) || 90))
const since = iso(addDays(new Date(), -days))
let jobs = []
// ⚠️ FILTRER un datetime (actual_start) plante en v16 (HTTP 500) → on filtre par date + en CODE (minutesBetween skip les vides).
try { jobs = await erp.list('Dispatch Job', { filters: [['scheduled_date', '>', since]], fields: ['name', 'assigned_tech', 'job_type', 'duration_h', 'actual_start', 'actual_end', 'scheduled_date'], limit: 5000 }) } catch (e) { return json(res, 200, { ok: false, error: e.message }) }
const perTech = {}, perCat = {}, rows = []
for (const j of (jobs || [])) {
const actual = minutesBetween(j.actual_start, j.actual_end); if (!actual || actual <= 0) continue
const est = j.duration_h ? Math.round(j.duration_h * 60) : 0
const cat = j.job_type || 'Autre'; const tech = j.assigned_tech || '—'
rows.push({ job: j.name, tech, cat, est_min: est, actual_min: actual, variance_pct: est ? Math.round((actual - est) / est * 100) : null })
for (const [map, key] of [[perTech, tech], [perCat, cat]]) { const m = map[key] || (map[key] = { n: 0, est: 0, actual: 0 }); m.n++; m.est += est; m.actual += actual }
}
const agg = (map) => Object.entries(map).map(([k, v]) => ({ key: k, n: v.n, avg_est_min: v.n ? Math.round(v.est / v.n) : 0, avg_actual_min: v.n ? Math.round(v.actual / v.n) : 0, avg_variance_pct: v.est ? Math.round((v.actual - v.est) / v.est * 100) : null })).sort((a, b) => b.n - a.n)
return json(res, 200, { ok: true, window_days: days, geofenced_jobs: rows.length, per_tech: agg(perTech), per_category: agg(perCat), sample: rows.slice(0, 50) })
}
// Validation « service livré » d'un job : tire le statut service+modem (en ligne + signal) pour le client/adresse du
// job, via le MÊME résolveur que la Boîte (serviceStatus). Croise tech-présent (geofence) + service-en-ligne.
if (path === '/roster/job-service-status' && method === 'GET') {
const jobName = url.searchParams.get('job')
if (!jobName) return json(res, 400, { ok: false, error: 'job requis' })
let job; try { job = await erp.get('Dispatch Job', jobName) } catch (e) { return json(res, 200, { ok: false, error: 'job introuvable' }) }
const arg = job.customer ? { customer: job.customer, location: job.service_location || '' } : { q: job.address || job.customer_name || '' }
try { return json(res, 200, await require('./ticket-collab').serviceStatus(arg)) }
catch (e) { log('job-service-status ' + jobName + ': ' + e.message); return json(res, 200, { ok: false, error: e.message }) }
}
if (path === '/roster/job-characteristics' && method === 'GET') return json(res, 200, { ok: true, ...readJobChar() }) // table additive de durées (éditable)
if (path === '/roster/job-characteristics' && method === 'POST') { // sauvegarde du tableur inline
const b = await parseBody(req); if (!Array.isArray(b.items)) return json(res, 400, { ok: false, error: 'items requis' })
const clean = b.items.filter(i => i && i.id && i.label).map(i => ({ id: String(i.id), cat: ['base', 'addon', 'modifier'].includes(i.cat) ? i.cat : 'addon', label: String(i.label).slice(0, 80), minutes: Math.max(0, Math.round(Number(i.minutes) || 0)), per_qty: !!i.per_qty, keywords: String(i.keywords || '').slice(0, 160), learned_min: i.learned_min != null ? Math.round(Number(i.learned_min)) : null, samples: Number(i.samples) || 0 }))
const ok = writeJobChar(clean); return json(res, ok ? 200 : 500, { ok, count: clean.length })
}
if (path === '/roster/tech-link' && method === 'GET') { // lien app PWA par tech (à copier / SMS) + son téléphone
const tech = qs.get('tech'); if (!tech) return json(res, 400, { ok: false, error: 'tech requis' })
const rows = await erp.list('Dispatch Technician', { filters: [['name', '=', tech]], fields: ['name', 'full_name', 'phone'], limit: 1 })
const t = rows && rows[0]
return json(res, 200, { ok: true, tech, name: (t && t.full_name) || tech, phone: (t && t.phone) || '', url: techFieldLink(tech) })
}
if (path === '/roster/stats' && method === 'GET') {
if (!start) return json(res, 400, { error: 'start requis' })
return json(res, 200, { stats: await statsByDay(start, days) })
}
if (path === '/roster/occupancy' && method === 'GET') {
if (!start) return json(res, 400, { error: 'start requis' })
return json(res, 200, { occupancy: await occupancyByTechDay(start, days) })
}
if (path === '/roster/absences' && method === 'GET') {
if (!start) return json(res, 400, { error: 'start requis' })
return json(res, 200, { absences: await absencesByTechDay(start, days) })
}
// Absence d'UN JOUR depuis la grille (approuvée → hachurée tout de suite) ou retrait (jour unique seulement).
if (path === '/roster/absence/set' && method === 'POST') {
const b = await parseBody(req)
if (!b.tech || !b.date) return json(res, 400, { error: 'tech + date requis' })
if (b.remove) {
const rows = await erp.list('Tech Availability', { filters: [['technician', '=', b.tech], ['from_date', '=', b.date], ['to_date', '=', b.date]], fields: ['name'], limit: 20 })
let removed = 0; for (const r of rows) { const x = await retryWrite(() => erp.remove('Tech Availability', r.name)); if (x.ok) removed++ }
return json(res, 200, { ok: true, removed })
}
const r = await retryWrite(() => erp.create('Tech Availability', { technician: b.tech, from_date: b.date, to_date: b.date, availability_type: b.type || 'Congé', status: 'Approuvé', reason: 'Grille' }))
return json(res, r.ok ? 200 : 500, r)
}
// Prise de RDV : créneaux dispo (roster + compétence + zone) pour proposer/valider
if (path === '/roster/book/slots' && method === 'GET') {
if (!start) return json(res, 400, { error: 'start requis' })
return json(res, 200, { slots: await bookingSlots({ skill: qs.get('skill') || '', zone: qs.get('zone') || '', duration: qs.get('duration') || 1, start, days, limit: parseInt(qs.get('limit') || '24', 10), aggregate: qs.get('aggregate') === '1' }) })
}
// Jobs à planifier (worklist du répartiteur)
if (path === '/roster/book/jobs' && method === 'GET') {
const rows = await erp.list('Dispatch Job', {
filters: [['status', 'in', ['open', 'assigned']]],
fields: ['name', 'customer_name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'start_time', 'assigned_tech', 'booking_status', 'status'],
orderBy: 'modified desc', limit: 100,
})
for (const j of rows) j.required_skill = skillForJob(j) // tag requis (table service_type → compétence)
await attachLocations(rows) // adresse lisible (service_location = code)
return json(res, 200, { jobs: rows })
}
// Méta pour l'éditeur de la table « type de job → compétence requise » (#56 booking)
if (path === '/roster/book/meta' && method === 'GET') {
const tk = await fetchTechnicians()
const skills = [...new Set(tk.flatMap(t => t.skills || []))].sort()
let types = []
try { const jb = await erp.list('Dispatch Job', { fields: ['service_type'], limit: 3000 }); types = [...new Set(jb.map(j => j.service_type).filter(Boolean))].sort() } catch (e) {}
return json(res, 200, { service_types: types, skills, skill_by_type: getBookingPolicy().skill_by_type || {}, level_by_type: getBookingPolicy().level_by_type || {}, level_by_skill: getBookingPolicy().level_by_skill || {} })
}
// Générer le lien client (token) pour un job → URL publique /book?token=
if (path === '/roster/book/link' && method === 'POST') {
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
const job = await erp.get('Dispatch Job', b.job, { fields: ['name', 'booking_token'] })
if (!job) return json(res, 404, { error: 'job introuvable' })
let token = job.booking_token
if (!token) { token = crypto.randomBytes(12).toString('hex'); const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { booking_token: token })); if (!r.ok) return json(res, 500, r) }
return json(res, 200, { ok: true, token, url: (cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca') + '/book?token=' + token })
}
// Aviser le client d'un report : lien /book + SMS Twilio + statut « À reporter »
if (path === '/roster/job/notify-reschedule' && method === 'POST') {
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
const job = await erp.get('Dispatch Job', b.job, { fields: ['name', 'booking_token', 'customer', 'customer_name'] })
if (!job) return json(res, 404, { error: 'job introuvable' })
let token = job.booking_token
if (!token) { token = crypto.randomBytes(12).toString('hex'); await retryWrite(() => erp.update('Dispatch Job', b.job, { booking_token: token })) }
const url = (cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca') + '/book?token=' + token
// Désassigner (retour au pool) : on vide le créneau pour que /book repropose des options
await retryWrite(() => erp.update('Dispatch Job', b.job, { booking_status: 'À reporter', scheduled_date: null, start_time: null, assigned_tech: null, status: 'open' }))
let phone = b.phone
if (!phone && job.customer) { try { const c = await erp.get('Customer', job.customer, { fields: ['mobile_no'] }); phone = c && c.mobile_no } catch (e) {} }
if (!phone) return json(res, 200, { ok: true, url, sms: false, note: 'Statut « À reporter » posé. Aucun téléphone trouvé — fournir "phone" pour envoyer le SMS.' })
const msg = b.message || `Bonjour, votre rendez-vous Gigafibre doit être reporté. Choisissez un nouveau créneau qui vous convient : ${url} Merci de votre compréhension.`
let sid = null
try { sid = await require('./twilio').sendSmsInternal(phone, msg, job.customer) } catch (e) { return json(res, 200, { ok: true, url, sms: false, error: String(e.message || e) }) }
return json(res, 200, { ok: true, url, sms: !!sid, sid, phone })
}
// File « À reporter » (jobs à recontacter) — pour le superviseur
if (path === '/roster/jobs-to-reschedule' && method === 'GET') {
const rows = await erp.list('Dispatch Job', { filters: [['booking_status', '=', 'À reporter']], fields: ['name', 'customer_name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'assigned_tech', 'booking_token'], limit: 100 })
await attachLocations(rows || []) // adresse lisible
return json(res, 200, { jobs: rows || [] })
}
// Impact d'un retrait de compétence : jobs assignés à un tech qui EXIGENT cette compétence (devenus invalides).
if (path === '/roster/skill-impact' && method === 'GET') {
const tech = qs.get('tech'); const skill = qs.get('skill')
if (!tech || !skill) return json(res, 400, { error: 'tech + skill requis' })
const rows = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', tech], ['status', 'in', ['open', 'assigned']]], fields: ['name', 'customer_name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'start_time'], limit: 500 })
const impacted = (rows || []).filter(j => skillForJob(j) === skill)
await attachLocations(impacted)
return json(res, 200, { jobs: impacted })
}
// Impact d'une ABSENCE : jobs assignés à un tech sur des dates données (devenus à redistribuer).
if (path === '/roster/absence-impact' && method === 'GET') {
const tech = qs.get('tech'); const dates = (qs.get('dates') || '').split(',').filter(Boolean)
if (!tech || !dates.length) return json(res, 400, { error: 'tech + dates requis' })
const rows = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', tech], ['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned']]], fields: ['name', 'customer_name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'start_time'], limit: 500 })
await attachLocations(rows || [])
return json(res, 200, { jobs: rows || [] })
}
// Candidats CLASSÉS pour reprendre un job : techs qualifiés + LIBRES au même créneau (≠ l'impacté).
if (path === '/roster/job-candidates' && method === 'GET') {
const jobName = qs.get('job'); const exclude = qs.get('exclude') || ''
if (!jobName) return json(res, 400, { error: 'job requis' })
let job = null
try { job = await erp.get('Dispatch Job', jobName, { fields: ['name', 'scheduled_date', 'start_time', 'duration_h', 'service_type'] }) } catch (e) {}
if (!job || !job.scheduled_date || !job.start_time) return json(res, 200, { candidates: [], date: (job && job.scheduled_date) || '', start: '' })
const skill = skillForJob(job); const start = String(job.start_time).slice(0, 5); const dur = Number(job.duration_h) || 1
const slots = await bookingSlots({ skill, duration: dur, start: job.scheduled_date, days: 1, limit: 500, ignorePolicy: true })
const seen = new Set(); const cands = []
for (const s of slots) { if (s.start !== start || s.tech === exclude || seen.has(s.tech)) continue; seen.add(s.tech); cands.push({ tech: s.tech, tech_name: s.tech_name }) }
return json(res, 200, { candidates: cands.slice(0, 6), date: job.scheduled_date, start, skill })
}
// Redistribuer les jobs impactés. 3 voies : b.plan (réassign explicite par tech / requeue) · b.mode 'auto' (re-match
// auto au même créneau, compétence b.skill ou par job) · 'requeue' (À recontacter).
if (path === '/roster/skill-impact/redistribute' && method === 'POST') {
const b = await parseBody(req); const mode = b.mode || 'requeue'
if (Array.isArray(b.plan)) { // plan explicite : { job, tech } (réassigner) OU { job, requeue:true }
let reassigned = 0; let requeued = 0; let errors = 0
for (const p of b.plan) {
if (!p || !p.job) { errors++; continue }
if (p.tech && !p.requeue) { const r = await retryWrite(() => erp.update('Dispatch Job', p.job, { assigned_tech: p.tech, status: 'assigned', booking_status: 'Confirmé' })); if (r.ok) reassigned++; else errors++ }
else { const r = await retryWrite(() => erp.update('Dispatch Job', p.job, { booking_status: 'À reporter', scheduled_date: null, start_time: null, assigned_tech: null, status: 'open' })); if (r.ok) requeued++; else errors++ }
}
return json(res, 200, { ok: errors === 0, reassigned, requeued, errors })
}
let reassigned = 0; let requeued = 0; let errors = 0; const details = []
for (const jobName of (b.jobs || [])) {
let job = null
try { job = await erp.get('Dispatch Job', jobName, { fields: ['name', 'scheduled_date', 'start_time', 'duration_h', 'customer_name', 'service_type'] }) } catch (e) {}
if (!job) { errors++; continue }
if (mode === 'auto' && job.scheduled_date && job.start_time) { // re-match au même créneau, tech qualifié ≠ l'impacté
const sk = b.skill || skillForJob(job) // compétence requise (globale si fournie, sinon par job → cas absence)
const r = await confirmWindow(jobName, job.scheduled_date, String(job.start_time).slice(0, 5), Number(job.duration_h) || 1, sk)
if (r.ok) { reassigned++; details.push({ job: jobName, customer: job.customer_name, tech: r.tech, action: 'réassigné' }); continue }
}
const r = await retryWrite(() => erp.update('Dispatch Job', jobName, { booking_status: 'À reporter', scheduled_date: null, start_time: null, assigned_tech: null, status: 'open' }))
if (r.ok) { requeued++; details.push({ job: jobName, customer: job.customer_name, action: 'à recontacter' }) } else errors++
}
return json(res, 200, { ok: errors === 0, reassigned, requeued, errors, details })
}
// Jobs À ASSIGNER (non assignés) avec leur groupe/dépendances (parent_job, depends_on, step_order, chaîne On Hold).
if (path === '/roster/unassigned-jobs' && method === 'GET') {
const age = _poolCache.jobs ? (nowMs() - _poolCache.ts) : Infinity
if (_poolCache.jobs && age < POOL_TTL_MS) return json(res, 200, { jobs: _poolCache.jobs, cached: true }) // frais → instantané
if (_poolCache.jobs) { refreshPoolBg(); return json(res, 200, { jobs: _poolCache.jobs, cached: 'stale' }) } // périmé → sert + rafraîchit en fond
try { const jobs = await buildUnassigned(); _poolCache = { ts: nowMs(), jobs, building: null }; return json(res, 200, { jobs }) } // à froid → build live
catch (e) { log('pool build err: ' + (e && e.message)); return json(res, 200, { jobs: [] }) }
}
// Assigner un job à un tech (depuis le panneau flottant glisser-déposer) — date = case déposée.
// On pose AUSSI un start_time (premier trou libre du shift) : sans heure, le job compte dans
// les heures occupées mais n'affiche AUCUN bloc sur la timeline → la barre d'occupation semble figée.
if (path === '/roster/assign-job' && method === 'POST') {
const b = await parseBody(req); if (!b.job || !b.tech) return json(res, 400, { error: 'job + tech requis' })
let dur = 1
try { const jb = await erp.get('Dispatch Job', b.job, { fields: ['duration_h'] }); dur = Number(jb && jb.duration_h) || 1 } catch (e) {}
const patch = { assigned_tech: b.tech, status: 'assigned', duration_h: dur } // duration_h garanti → occupation comptée
if (b.date) patch.scheduled_date = b.date
let placed = null
if (b.start) patch.start_time = (String(b.start).length === 5 ? b.start + ':00' : b.start)
else if (b.date) { // premier trou libre dans le shift du tech ce jour-là
try { const d = await loadBookingData(b.date, 1); const h = firstFitStart(d, b.tech, b.date, dur); if (h != null) { placed = hToTime(h); patch.start_time = placed } } catch (e) {}
}
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
if (r.ok) invalidatePool() // le job quitte le pool → rafraîchir le cache
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, tech: b.tech, start_time: placed, duration_h: dur })
}
if (path === '/roster/job-level' && method === 'POST') { // niveau requis PERSISTANT par job (store hub durable)
const b = await parseBody(req); if (!b.name) return json(res, 400, { error: 'name requis' })
setJobLevel(b.name, b.level); invalidatePool() // le pool porte required_level → rafraîchir
return json(res, 200, { ok: true, name: b.name, level: Math.max(0, Math.min(5, Math.round(Number(b.level) || 0))) })
}
// 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 })
let cur = []
try { cur = await erp.list('Dispatch Job Assistant', { filters: [['parent', '=', job]], fields: ['tech_id', 'tech_name', 'duration_h', 'note', 'pinned'], limit: 50 }) } 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 })
}
// Situer manuellement un job « hors carte » : pose latitude/longitude (+ adresse) choisis via le sélecteur Mapbox Ops.
if (path === '/roster/job/set-location' && method === 'POST') {
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
const lat = Number(b.lat), lon = Number(b.lon)
if (!isFinite(lat) || !isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) return json(res, 400, { error: 'lat/lon invalides' })
const patch = { latitude: lat, longitude: lon }
if (b.address != null && String(b.address).trim() !== '') patch.address = String(b.address).slice(0, 140)
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, latitude: lat, longitude: lon })
}
// Actions rapides du pool (mobile, façon Gmail) : modifier des champs « dispatchables » d'un Dispatch Job — LISTE BLANCHE stricte.
// priority (urgent|high|medium|low) · status (open|On Hold|Cancelled|assigned) · required_skill · scheduled_date (YYYY-MM-DD, '' = effacer) ·
// notes = note importante du RÉPARTITEUR (ex. « appeler 30 min avant », « préfère AM ») — DISTINCTE de completion_notes (journal du technicien).
if (path === '/roster/job/update' && method === 'POST') {
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
const src = b.patch || b; const patch = {}
if (src.priority !== undefined) { const p = String(src.priority || ''); if (p && !['low', 'medium', 'high'].includes(p)) return json(res, 400, { error: 'priority invalide (low|medium|high)' }); patch.priority = p }
if (src.status !== undefined) { const s = String(src.status || ''); if (s && !['open', 'On Hold', 'Cancelled', 'assigned'].includes(s)) return json(res, 400, { error: 'status invalide' }); patch.status = s }
if (src.required_skill !== undefined) patch.required_skill = String(src.required_skill || '').slice(0, 80)
if (src.scheduled_date !== undefined) { const d = String(src.scheduled_date || ''); if (d && !/^\d{4}-\d{2}-\d{2}$/.test(d)) return json(res, 400, { error: 'date invalide' }); patch.scheduled_date = d || null }
if (src.notes !== undefined) patch.notes = String(src.notes || '').slice(0, 2000)
if (src.duration_h !== undefined) { const dh = Number(src.duration_h); if (!isFinite(dh) || dh <= 0 || dh > 24) return json(res, 400, { error: 'duration_h invalide (0-24)' }); patch.duration_h = Math.round(dh * 100) / 100 } // temps révisé par le dispatcher
if (!Object.keys(patch).length) return json(res, 400, { error: 'aucun champ modifiable' })
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
if (r.ok) invalidatePool() // priorité/statut/date/compétence/note changés → rafraîchir le cache du pool
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, patch })
}
// CHRONO (boucle de capture) : début/fin réels → durée réelle (carburant de l'apprentissage des durées par type×tech).
if (path === '/roster/job/start' && method === 'POST') {
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
const now = nowFrappe()
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { actual_start: now, actual_end: '', status: 'In Progress' }))
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, actual_start: now })
}
if (path === '/roster/job/finish' && method === 'POST') {
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
const cur = await erp.list('Dispatch Job', { filters: [['name', '=', b.job]], fields: ['actual_start'], limit: 1 })
const startTs = (cur && cur[0] && cur[0].actual_start) || null
const now = nowFrappe()
const patch = { actual_end: now, status: 'Completed' }; if (!startTs) patch.actual_start = now // borne si jamais démarré
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, actual_minutes: minutesBetween(startTs || now, now) })
}
// Backfill : pose un start_time (premier trou libre) sur les jobs DÉJÀ assignés mais SANS heure
// → leurs blocs d'occupation apparaissent enfin sur la grille. Idempotent (ne touche que start_time vide).
if (path === '/roster/backfill-start-times' && method === 'POST') {
const b = await parseBody(req)
const start = b.start || todayET(); const days = Number(b.days) || 14
const dates = rangeDates(start, days)
const rows = await erp.list('Dispatch Job', {
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress']]],
fields: ['name', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h'], limit: 5000,
})
const todo = (rows || []).filter(j => j.assigned_tech && j.scheduled_date && !j.start_time)
const d = await loadBookingData(start, days) // chargé UNE fois ; on mute d.booked au fil de l'eau pour empiler
let placed = 0; const details = []
for (const j of todo) { // SÉQUENTIEL (frappe_pg ne supporte pas la concurrence)
const dur = Number(j.duration_h) || 1
const k = j.assigned_tech + '|' + j.scheduled_date
if (!d.booked[k]) d.booked[k] = []
const h = firstFitStart(d, j.assigned_tech, j.scheduled_date, dur)
if (h == null) { details.push({ job: j.name, skipped: 'pas de shift régulier ce jour-là' }); continue }
const r = await retryWrite(() => erp.update('Dispatch Job', j.name, { start_time: hToTime(h), duration_h: dur }))
if (r.ok) { d.booked[k].push({ s: h, e: h + dur }); placed++; details.push({ job: j.name, tech: j.assigned_tech, date: j.scheduled_date, start: hToTime(h) }) }
else details.push({ job: j.name, error: r.error || 'update failed' })
}
return json(res, 200, { ok: true, candidates: todo.length, placed, details })
}
// Hold : réserver temporairement une fenêtre (agent au tél. ou client qui sélectionne)
// → la fenêtre est retirée des dispos des autres pendant `minutes` (défaut politique).
if (path === '/roster/book/hold' && method === 'POST') {
const b = await parseBody(req)
if (!b.date || !b.start) return json(res, 400, { error: 'date + start requis' })
const key = b.date + '|' + (String(b.start).length >= 5 ? String(b.start).slice(0, 5) : b.start)
if (b.release) { releaseHold(key); return json(res, 200, { ok: true, released: true, key }) }
const minutes = b.minutes || getBookingPolicy().hold_minutes
return json(res, 200, { ok: true, key, holds: addHold(key, minutes), minutes })
}
// Fit : 3 dispos classées du client → 1er choix tenable, sinon proposer
if (path === '/roster/book/fit' && method === 'POST') {
const b = await parseBody(req)
return json(res, 200, await fitBooking({ skill: b.skill || '', zone: b.zone || '', duration: b.duration || 1, prefs: b.prefs || [] }))
}
// Confirmer un RDV sur un Dispatch Job existant
if (path === '/roster/book/confirm' && method === 'POST') {
const b = await parseBody(req)
if (!b.job) return json(res, 400, { error: 'job requis' })
const st = (b.start || '').length === 5 ? b.start + ':00' : b.start
const patch = { scheduled_date: b.date, start_time: st, status: 'assigned', booking_status: 'Confirmé', booking_prefs: JSON.stringify(b.prefs || []) }
if (b.tech) patch.assigned_tech = b.tech
if (b.duration) patch.duration_h = b.duration
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
if (r.ok && b.date && b.start) releaseHold(b.date + '|' + (String(b.start).slice(0, 5)))
return json(res, r.ok ? 200 : 500, r)
}
// Créer plusieurs besoins d'un coup (depuis l'éditeur de demande)
if (path === '/roster/requirements/bulk' && method === 'POST') {
const b = await parseBody(req); const errors = []; let created = 0
for (const rq of b.requirements || []) {
const r = await retryWrite(() => erp.create('Shift Requirement', {
requirement_date: rq.requirement_date, shift_template: rq.shift_template,
zone: rq.zone || '', required_count: rq.required_count || 1, required_skills: rq.required_skills || '',
}))
if (r.ok) created++; else errors.push(rq)
}
return json(res, 200, { ok: errors.length === 0, created, errors: errors.length })
}
// Vider les besoins d'une période (avant de ré-appliquer la demande)
if (path === '/roster/requirements/clear' && method === 'POST') {
const b = await parseBody(req)
const reqs = await fetchRequirements(b.start, b.days || 7)
let deleted = 0
for (const rq of reqs) { const r = await retryWrite(() => erp.remove('Shift Requirement', rq.name)); if (r.ok) deleted++ }
return json(res, 200, { ok: true, deleted })
}
if (path === '/roster/generate' && method === 'POST') {
const b = await parseBody(req)
if (!b.start) return json(res, 400, { error: 'start requis' })
try {
return json(res, 200, await generate(b.start, b.days || 7, b.weights))
} catch (e) {
return json(res, 502, { error: 'solveur injoignable ou erreur: ' + e.message })
}
}
if (path === '/roster/publish' && method === 'POST') {
const b = await parseBody(req)
return json(res, 200, await publish(b.assignments))
}
// 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))
let deleted = 0; let created = 0; let errors = 0; let unchanged = 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) { // créer seulement les nouveaux ; ignorer les inchangés
if (existByKey[keyOf(a)]) { 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: 'Publié', source: a.source || 'solveur',
}))
if (r.ok) created++; else errors++
}
let notified = 0
if (b.notify && created) { // SMS opt-in aux techs (Twilio) — non bloquant
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, created, deleted, errors, notified, unchanged })
}
// Garde : matérialiser la rotation sur un HORIZON (plusieurs semaines) — comme un évènement récurrent.
// Wipe ROBUSTE : on supprime TOUTE garde (tout template on_call) dans la plage, pas seulement les shifts
// courants — sinon une ancienne garde sous un autre nom de template survit et fausse la rotation
// (« la suite est bousillée »). Puis recréation depuis la rotation déterministe (= le calque live).
if (path === '/roster/garde/apply' && method === 'POST') {
const b = await parseBody(req)
const dates = rangeDates(b.start, (b.weeks || 1) * 7)
const tpls = await fetchTemplates()
const gardeTpls = tpls.filter(t => t.on_call).map(t => t.name)
let deleted = 0
if (gardeTpls.length && dates.length) {
const existing = await erp.list('Shift Assignment', { filters: [['shift_template', 'in', gardeTpls], ['assignment_date', 'in', dates]], fields: ['name'], limit: 5000 })
for (const a of existing) { const r = await retryWrite(() => erp.remove('Shift Assignment', a.name)); if (r.ok) deleted++ }
}
let created = 0; let errors = 0
for (const a of (b.assignments || [])) {
const r = await retryWrite(() => erp.create('Shift Assignment', {
technician: a.tech, technician_name: a.tech_name || '', assignment_date: a.date,
shift_template: a.shift, zone: a.zone || '', hours: Number(a.hours) || 0,
status: 'Publié', source: 'manuel',
}))
if (r.ok) created++; else errors++
}
return json(res, 200, { ok: errors === 0, created, deleted, errors })
}
// Modifier / supprimer un type de shift (Shift Template)
const mTpl = path.match(/^\/roster\/template\/(.+)$/)
if (mTpl && method === 'PUT') {
const name = decodeURIComponent(mTpl[1]); const b = await parseBody(req)
const patch = {}
for (const f of ['start_time', 'end_time', 'hours', 'color', 'zone', 'default_required', 'required_skills', 'active', 'on_call']) if (b[f] !== undefined) patch[f] = b[f]
const r = await retryWrite(() => erp.update('Shift Template', name, patch))
return json(res, r.ok ? 200 : 500, r)
}
if (mTpl && method === 'DELETE') {
const name = decodeURIComponent(mTpl[1]); const r = await retryWrite(() => erp.remove('Shift Template', name))
return json(res, r.ok ? 200 : 500, r)
}
if (path === '/roster/availability' && method === 'GET') {
const status = qs.get('status') || ''
const rows = await erp.list('Tech Availability', {
filters: status ? [['status', '=', status]] : [],
fields: ['name', 'technician', 'technician_name', 'availability_type', 'from_date', 'to_date', 'reason', 'status', 'approver'],
orderBy: 'modified desc', limit: 200,
})
return json(res, 200, { availability: rows })
}
if (path === '/roster/availability' && method === 'POST') {
const b = await parseBody(req)
const r = await erp.create('Tech Availability', {
technician: b.technician, technician_name: b.technician_name || '',
availability_type: b.availability_type || 'Congé', status: 'Demandé',
from_date: b.from_date, to_date: b.to_date, reason: b.reason || '',
long_term: b.long_term ? 1 : 0,
})
return json(res, r.ok ? 200 : 500, r)
}
const mApprove = path.match(/^\/roster\/availability\/(.+)\/approve$/)
if (mApprove && method === 'POST') {
const name = decodeURIComponent(mApprove[1])
const b = await parseBody(req)
const r = await retryWrite(() => erp.update('Tech Availability', name, { status: b.reject ? 'Refusé' : 'Approuvé', approver: b.approver || '' }))
return json(res, r.ok ? 200 : 500, r)
}
const mSkills = path.match(/^\/roster\/technician\/(.+)\/skills$/)
if (mSkills && method === 'POST') {
const techId = decodeURIComponent(mSkills[1]); const b = await parseBody(req)
const techName = await resolveTechName(techId)
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const patch = { skills: (b.skills || '').trim() }
if (b.skill_levels !== undefined) patch.skill_levels = typeof b.skill_levels === 'string' ? b.skill_levels : JSON.stringify(b.skill_levels || {}) // niveaux 15 par compétence (JSON)
if (b.skill_eff !== undefined) patch.skill_eff = typeof b.skill_eff === 'string' ? b.skill_eff : JSON.stringify(b.skill_eff || {}) // efficacité (facteur vitesse) PAR compétence (JSON)
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch))
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 }))
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 }))
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))
return json(res, r.ok ? 200 : 500, { ...r, technician: techId, status: patch.status })
}
// 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 }))
return json(res, r.ok ? 200 : 500, { ...r, technician: techId, traccar_device_id: deviceId })
}
// 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 }