'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 } = 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')
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 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',
'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,
employee: t.employee,
skills: splitCsv(t.skills || t._user_tags), // champ skills (ou tags Frappe)
skill_levels: (() => { try { return JSON.parse(t.skill_levels || '{}') } catch { return {} } })(), // {compétence: niveau 1–5}
skill_eff: (() => { try { return JSON.parse(t.skill_eff || '{}') } catch { return {} } })(), // {compétence: facteur d'efficacité (vitesse)}
absence_from: t.absence_from,
absence_until: t.absence_until,
}))
}
// 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 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') }
// ── #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))]
if (!codes.length) return jobs
let locs = []
try { locs = await erp.list('Service Location', { filters: [['name', 'in', codes]], fields: ['name', 'address_line', 'city', 'location_name'], limit: codes.length + 10 }) } catch (e) { return jobs }
const m = {}; for (const l of locs) m[l.name] = l
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 || '' }
return jobs
}
function nowMs () { return new Date().getTime() }
// 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 = `
Prendre rendez-vous — Gigafibre
Prendre rendez-vous
Chargement…
Gigafibre · propulsé par Targo
`
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' })
}
// 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', {
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress']]],
fields: ['name', 'subject', 'customer_name', 'service_type', 'job_type', 'legacy_dept', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'route_order', 'latitude', 'longitude'], limit: 5000,
})
const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 } // ordre d'affichage
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 dur = Number(j.duration_h) || 0
o.h += dur
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) o.blocks.push({ s, e: s + dur, skill, job: j.name }) // 1 bloc = 1 job, coloré par sa compétence
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, 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 })
}
// 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
}
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/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 || {} })
}
// 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 rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'subject', 'customer_name', 'service_location', 'service_type', 'job_type', 'legacy_dept', 'legacy_detail', 'legacy_ticket_id', 'legacy_activation_url', 'priority', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech'], orderBy: 'modified desc', limit: 400 })
const jobs = (rows || []).filter(j => !j.assigned_tech) // non assignés
for (const j of jobs) j.required_skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject) // skill explicite, sinon déduit du type → couleur
await attachLocations(jobs)
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))
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, tech: b.tech, start_time: placed, duration_h: dur })
}
// 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 (!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 }))
return json(res, r.ok ? 200 : 500, { ...r, job: b.job })
}
// 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 1–5 par compétence (JSON)
if (b.skill_eff !== undefined) patch.skill_eff = typeof b.skill_eff === 'string' ? b.skill_eff : JSON.stringify(b.skill_eff || {}) // efficacité (facteur vitesse) PAR compétence (JSON)
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch))
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 })
}
// 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, generate, publish, coverage, fetchTechnicians, fetchTemplates, bookingSlots }