'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'], 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) 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'], 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 } function getBookingPolicy () { try { const p = JSON.parse(fs.readFileSync(POLICY_FILE, 'utf8')); return { ...BOOKING_DEFAULTS, ...(p.booking || {}) } } catch { return { ...BOOKING_DEFAULTS } } } 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 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 } } // 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 (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 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 } } 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', 'duration_h', 'scheduled_date', 'start_time', 'booking_status'], limit: 1 }) return rows[0] || null } async function confirmWindow (jobName, date, start, duration) { // À la confirmation on veut juste vérifier que le tech est ENCORE physiquement libre // (pas re-filtrer par la politique d'offre) → ignorePolicy. const day = await bookingSlots({ 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 = `