'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 dispatch persistante par job (même notion que la priorité des conversations : urgent|high|normal|low). // Store durable = SURCHARGE de la priorité ERPNext pour le dispatch (client commercial/SLA/contrainte). const JOB_FLAGS_FILE = path.join(__dirname, '..', 'data', 'job-flags.json') function getJobFlags () { try { return JSON.parse(fs.readFileSync(JOB_FLAGS_FILE, 'utf8')) || {} } catch { return {} } } const PRIO_LEVELS = ['urgent', 'high', 'normal', 'low'] function setJobFlag (name, priority) { const m = getJobFlags(); const p = String(priority || '').toLowerCase() if (PRIO_LEVELS.includes(p)) m[String(name)] = p; else delete m[String(name)] // niveau valide → surcharge ; sinon (vide/« aucune ») → retire try { fs.mkdirSync(path.dirname(JOB_FLAGS_FILE), { recursive: true }) } catch (e) {} fs.writeFileSync(JOB_FLAGS_FILE, JSON.stringify(m)); return m } const SOLVER_URL = cfg.ROSTER_SOLVER_URL || 'http://roster-solver:8090' const PAUSE_STATUS = 'En pause' const AVAIL_STATUS = 'Disponible' // ── 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 } 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 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 nowFrappe () { return new Date().toLocaleString('sv-SE', { timeZone: 'America/Toronto' }).replace('T', ' ') } // "YYYY-MM-DD HH:MM:SS" function minutesBetween (a, b) { if (!a || !b) return null; const ms = Date.parse(String(b).replace(' ', 'T')) - Date.parse(String(a).replace(' ', 'T')); return isFinite(ms) ? Math.max(0, Math.round(ms / 60000)) : null } function hToTime (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') } // ── DURÉE D'UNE JOB = modèle ADDITIF par caractéristiques (hors transport) ─────────────────────────────── // Base (1) + Σ caractéristiques cochées (×qté), × vitesse tech. Table ÉDITABLE (tableur inline Ops), persistée en JSON. // `minutes` = seed (réglé à la main, inspiré du tableur dispatch) ; `learned_min`+`samples` = calibration future par // régression sur les durées RÉELLES (geofence/checkpoints) — « triangulation » des temps par caractéristique. const JOBCHAR_PATH = '/app/data/job-characteristics.json' const JOBCHAR_SEED = { items: [ { id: 'install_resid', cat: 'base', label: 'Installation fibre résidentielle', minutes: 120, per_qty: false, keywords: 'installation,raccordement' }, { id: 'install_comm', cat: 'base', label: 'Installation commerciale', minutes: 240, per_qty: false, keywords: 'commercial' }, { id: 'predrop', cat: 'base', label: 'Pré-drop (pose)', minutes: 120, per_qty: false, keywords: 'pre-drop,predrop,pré-drop' }, { id: 'repar_fibre', cat: 'base', label: 'Réparation / bris de fibre', minutes: 105, per_qty: false, keywords: 'réparation,reparation,bris,fibre' }, { id: 'depan_wifi', cat: 'base', label: 'Dépannage Wifi / réseau', minutes: 90, per_qty: false, keywords: 'wifi,sans-fil,internet' }, { id: 'changer_secteur', cat: 'base', label: 'Changer de secteur', minutes: 75, per_qty: false, keywords: 'secteur' }, { id: 'retrait', cat: 'base', label: 'Retrait / désinstallation', minutes: 60, per_qty: false, keywords: 'retrait,désinstall,desinstall' }, { id: 'chgmt_equip', cat: 'base', label: "Changement d'équipement (ONU/modem/Hx)", minutes: 45, per_qty: false, keywords: 'changement,onu,modem,équipement,equipement,hx' }, { id: 'migration_tv', cat: 'base', label: 'Migration TV / boîtier', minutes: 45, per_qty: false, keywords: 'migration,tv,boîtier,boitier' }, { id: 'ata', cat: 'base', label: 'Activation téléphonie (ATA)', minutes: 45, per_qty: false, keywords: 'ata,téléphonie,telephonie' }, { id: 'enfoui', cat: 'addon', label: 'Drop enfoui (au lieu d’aérien)', minutes: 75, per_qty: false, keywords: 'enfoui,enterré,enterre,souterrain' }, { id: 'echelle', cat: 'addon', label: 'Aérien avec échelle / poteau', minutes: 30, per_qty: false, keywords: 'aérien,aerien,poteau,échelle,echelle' }, { id: 'dist_poteau', cat: 'addon', label: 'Distance poteau → maison > 60 m', minutes: 30, per_qty: false, keywords: '' }, { id: 'boitier_tv', cat: 'addon', label: 'Boîtier TV / STB supplémentaire', minutes: 30, per_qty: true, keywords: 'stb,boîtier tv,boitier tv' }, { id: 'mesh', cat: 'addon', label: "Point d'accès mesh supplémentaire", minutes: 20, per_qty: true, keywords: 'mesh,borne' }, { id: 'tel_ata', cat: 'addon', label: 'Ligne téléphonie / ATA ajoutée', minutes: 30, per_qty: true, keywords: '' }, { id: 'epissure', cat: 'addon', label: 'Épissure / fusion (par connecteur)', minutes: 20, per_qty: true, keywords: 'épissure,epissure,fusion' }, { id: 'wifi_complexe', cat: 'addon', label: 'Config Wifi complexe (multi-SSID/VLAN)', minutes: 30, per_qty: false, keywords: '' }, { id: 'tel_complexe', cat: 'addon', label: 'Téléphonie complexe (PBX/multi-lignes)', minutes: 30, per_qty: false, keywords: 'pbx' }, { id: 'test_otdr', cat: 'addon', label: 'Test / certification (OTDR, photos)', minutes: 20, per_qty: false, keywords: 'otdr,certification' }, { id: 'acces_difficile', cat: 'addon', label: 'Accès difficile (hauteur, vide sanitaire)', minutes: 30, per_qty: false, keywords: '' }, { id: 'tampon_client', cat: 'modifier', label: 'Tampon coordination client', minutes: 15, per_qty: false, keywords: '' }, { id: 'junior_install', cat: 'modifier', label: 'Tech junior sur install (pénalité)', minutes: 90, per_qty: false, keywords: '' }, ] } function readJobChar () { try { return JSON.parse(require('fs').readFileSync(JOBCHAR_PATH, 'utf8')) } catch (e) { return JSON.parse(JSON.stringify(JOBCHAR_SEED)) } } function writeJobChar (items) { try { require('fs').writeFileSync(JOBCHAR_PATH, JSON.stringify({ items, updated: nowFrappe() }, null, 2)); return true } catch (e) { return false } } // Durée estimée (minutes, hors transport) à partir des caractéristiques cochées : picks = [{id, qty}]. function estimateMinutes (picks, items) { const by = {}; for (const i of (items || readJobChar().items)) by[i.id] = i let m = 0; for (const p of (picks || [])) { const it = by[p.id]; if (!it) continue; const mn = (it.learned_min != null ? it.learned_min : it.minutes) || 0; m += mn * (it.per_qty ? (Number(p.qty) || 1) : 1) } return Math.round(m) } // Auto-détection DÉTERMINISTE (mots-clés / regex, AUCUNE IA) des caractéristiques d'un job depuis son texte. function detectCharacteristics (job, items) { const strip = s => String(s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '') const text = strip([job.subject, job.legacy_detail, job.legacy_dept, job.job_type, job.service_type].join(' ')) const hit = kw => String(kw || '').split(',').map(x => strip(x).trim()).filter(Boolean).some(k => new RegExp('\\b' + k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b').test(text)) // limites de mots → pas de faux positif « ap » dans « après » const bases = items.filter(i => i.cat === 'base') let base = bases.find(b => hit(b.keywords)) if (!base) { const jt = strip(job.job_type); base = bases.find(b => (jt.includes('install') && b.id === 'install_resid') || (jt.includes('repar') && b.id === 'repar_fibre') || (jt.includes('retrait') && b.id === 'retrait')) } const picks = base ? [{ id: base.id, qty: 1 }] : [] for (const a of items.filter(i => i.cat === 'addon')) if (hit(a.keywords)) picks.push({ id: a.id, qty: 1 }) return picks } // Sites PRÉ-CÂBLÉS : une INSTALLATION y prend ~¼ du temps normal (infra déjà en place → branchement rapide). const FAST_INSTALL_SITES = ['lac des pins', 'camping', 'domaine dauphinais'] function estimateForJob (job, items) { items = items || readJobChar().items const picks = detectCharacteristics(job, items); const by = {}; for (const i of items) by[i.id] = i let minutes = estimateMinutes(picks, items) const labels = picks.map(p => (by[p.id] || {}).label).filter(Boolean) // Installation à un site pré-câblé → temps /4 (Lac des pins / Camping / Domaine Dauphinais). const isInstall = picks.some(p => String(p.id).startsWith('install')) || String(job.required_skill || '').toLowerCase().includes('install') if (isInstall && minutes > 0) { const loc = [job.address, job.location_label, job.service_location, job.subject, job.municipalite].join(' ').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '') if (FAST_INSTALL_SITES.some(s => loc.includes(s))) { minutes = Math.max(15, Math.round(minutes / 4)); labels.push('site pré-câblé ×¼') } } return { minutes, picks, labels } } // ── #56 Politique de créneaux offerts + holds temporaires ──────────────────── // Persistée dans le même fichier que la politique de reprise (sous-objet `booking`), // éditée via /roster/policy (lib/roster-assistant.js). Appliquée ici à TOUTE source // de créneaux (page /book, vue agent, fit) → comportement cohérent partout. 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 ( !j.assigned_tech) const chars = readJobChar().items const jlv = getJobLevels(); const jfl = getJobFlags() 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; if (jfl[j.name]) j.priority = jfl[j.name]; const est = estimateForJob(j, chars); j.est_min = est.minutes; j.est_labels = est.labels } await attachLocations(jobs) log(`pool: construit ${jobs.length} jobs en ${nowMs() - t0}ms`) // mesure réelle (requête + enrichissement) return jobs } function refreshPoolBg () { if (_poolCache.building) return; _poolCache.building = buildUnassigned().then(j => { _poolCache = { ts: nowMs(), jobs: j, building: null } }).catch(e => { _poolCache.building = null; log('pool refresh err: ' + (e && e.message)) }) } function invalidatePool () { _poolCache.ts = 0; refreshPoolBg() } // après une écriture pool → forcer le rafraîchissement // Holds en mémoire : quand un client/agent sélectionne une fenêtre, on la réserve // quelques minutes pour éviter qu'un autre la prenne pendant la confirmation. // Transitoire (perdu au redémarrage du hub = acceptable pour un blocage de ~10 min). const bookingHolds = new Map() // clé 'YYYY-MM-DD|HH:MM' → [expiryMs, …] function holdCount (key) { const now = nowMs(); const arr = (bookingHolds.get(key) || []).filter(t => t > now) if (arr.length) bookingHolds.set(key, arr); else bookingHolds.delete(key) return arr.length } function addHold (key, minutes) { const arr = (bookingHolds.get(key) || []).filter(t => t > nowMs()) arr.push(nowMs() + Math.max(1, minutes || 10) * 60000); bookingHolds.set(key, arr) return arr.length } function releaseHold (key) { bookingHolds.delete(key) } async function loadBookingData (start, days) { const dates = rangeDates(start, days) const asgs = await fetchAssignments(start, days) const techs = await fetchTechnicians() const templates = await fetchTemplates() const unavail = await buildUnavailability(techs, dates) // En pause + absence_from/until + Tech Availability approuvées (par jour) const techById = Object.fromEntries(techs.map(t => [t.id, t])) const tplByName = Object.fromEntries(templates.map(t => [t.name, t])) const jobs = await erp.list('Dispatch Job', { filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned']]], fields: ['assigned_tech', 'scheduled_date', 'start_time', 'duration_h'], limit: 2000, }) const booked = {} for (const j of jobs) { if (!j.start_time) continue const k = j.assigned_tech + '|' + j.scheduled_date ;(booked[k] || (booked[k] = [])).push({ s: timeToH(j.start_time), e: timeToH(j.start_time) + (Number(j.duration_h) || 1) }) } return { asgs, techById, tplByName, booked, unavail } } // Trous libres d'un tech (dans son shift, moins jobs pointés), filtrés compétence/zone. function techGaps (a, d, skill, zone) { const t = d.techById[a.tech]; if (!t || t.status === PAUSE_STATUS) return null if (d.unavail && d.unavail[a.tech] && d.unavail[a.tech].has(a.date)) return null // absence/congé approuvé ce jour-là → pas de créneaux if (skill && !(t.skills || []).includes(skill)) return null if (zone && a.zone && a.zone !== zone) return null const tpl = d.tplByName[a.shift]; if (!tpl) return null if (tpl.on_call) return null // garde (sur appel) = capacité d'urgence, JAMAIS offerte au booking client const sh = timeToH(tpl.start_time) || 8; const eh = timeToH(tpl.end_time) || (sh + (Number(tpl.hours) || 8)) const day = (d.booked[a.tech + '|' + a.date] || []).slice().sort((x, y) => x.s - y.s) let cursor = sh; const gaps = [] for (const b of day) { if (b.s > cursor) gaps.push([cursor, b.s]); cursor = Math.max(cursor, b.e) } if (cursor < eh) gaps.push([cursor, eh]) return { tech: t, gaps } } // Premier créneau libre (en heures, ex 8.5) d'un tech un jour donné pour une durée `dur`. // Aucun trou assez large → empile après le dernier bloc (surbook visible) ; pas de shift régulier → null. // Partagé par /roster/assign-job (drag-drop) et /roster/backfill-start-times. function firstFitStart (d, techId, date, dur) { const mine = d.asgs.filter(a => a.tech === techId && a.date === date && !(d.tplByName[a.shift] && d.tplByName[a.shift].on_call)) if (!mine.length) return null let best = null for (const a of mine) { const g = techGaps(a, d, null, null); if (!g) continue; for (const [gs, ge] of g.gaps) if (ge - gs >= dur - 1e-6) { if (best == null || gs < best) best = gs; break } } if (best == null) { const tpl = d.tplByName[mine[0].shift]; const sh = timeToH(tpl && tpl.start_time) || 8; const day = (d.booked[techId + '|' + date] || []).slice().sort((x, y) => x.e - y.e); best = day.length ? day[day.length - 1].e : sh } return best } async function bookingSlots ({ skill, zone, duration = 1, start, days = 7, limit = 24, aggregate = false, ignorePolicy = false } = {}) { const dur = Number(duration) || 1 const pol = getBookingPolicy() const d = await loadBookingData(start, days) const leadCut = ignorePolicy ? 0 : nowMs() + (pol.lead_hours || 0) * 3600000 const dayStart = ignorePolicy ? 0 : (Number.isFinite(pol.day_start) ? pol.day_start : 0) const dayEnd = ignorePolicy ? 24 : (Number.isFinite(pol.day_end) ? pol.day_end : 24) const offered = !ignorePolicy && Array.isArray(pol.days_offered) && pol.days_offered.length ? pol.days_offered : null const out = [] for (const a of d.asgs) { if (a.status === 'Annulé') continue if (offered && !offered.includes(parseISO(a.date).getUTCDay())) continue // jour non offert par la politique const g = techGaps(a, d, skill, zone); if (!g) continue for (const [gs, ge] of g.gaps) { let s = Math.max(gs, dayStart); const end = Math.min(ge, dayEnd) // borné à la plage horaire offerte while (s + dur <= end) { const slotMs = parseISO(a.date).getTime() + s * 3600000 // approx UTC — suffisant pour un délai exprimé en heures if (slotMs >= leadCut) out.push({ date: a.date, start: hToTime(s), end: hToTime(s + dur), start_h: s, tech: a.tech, tech_name: g.tech.name, zone: a.zone || '', shift: a.shift }) s += dur } } } if (aggregate) { // client : 1 fenêtre par (date,heure) + nb de techs dispo, moins les holds actifs const byWin = {} for (const s of out) { const k = s.date + '|' + s.start; (byWin[k] || (byWin[k] = { date: s.date, start: s.start, end: s.end, start_h: s.start_h, available: 0 })).available++ } let arr = Object.values(byWin).map(w => ({ ...w, available: w.available - holdCount(w.date + '|' + w.start) })).filter(w => w.available > 0) arr.sort((x, y) => x.date.localeCompare(y.date) || x.start_h - y.start_h) if (!ignorePolicy && pol.max_per_day > 0) { const c = {}; arr = arr.filter(w => { c[w.date] = (c[w.date] || 0) + 1; return c[w.date] <= pol.max_per_day }) } // plafond/jour return arr.slice(0, limit) } out.sort((x, y) => x.date.localeCompare(y.date) || x.start_h - y.start_h) return out.slice(0, limit) } // Fit : le client fournit 3 dispos classées → on place dans le 1er choix tenable, // sinon 2e, sinon 3e. Si aucune ne tient → on PROPOSE nos créneaux (fallback). async function fitBooking ({ skill, zone, duration = 1, prefs = [] } = {}) { const dur = Number(duration) || 1 const dates = [...new Set((prefs || []).map(p => p.date).filter(Boolean))].sort() if (!dates.length) return { chosen: null, proposed: [] } const span = Math.max(1, Math.round((parseISO(dates[dates.length - 1]) - parseISO(dates[0])) / 86400000) + 1) const d = await loadBookingData(dates[0], span) const byDate = {}; for (const a of d.asgs) (byDate[a.date] || (byDate[a.date] = [])).push(a) for (let i = 0; i < prefs.length; i++) { const p = prefs[i]; const ps = timeToH(p.start); const pe = ps + dur for (const a of (byDate[p.date] || [])) { if (a.status === 'Annulé') continue const g = techGaps(a, d, skill, zone); if (!g) continue if (g.gaps.some(([gs, ge]) => gs <= ps && ge >= pe)) { return { chosen: { rank: i + 1, date: p.date, start: p.start, end: hToTime(pe), tech: a.tech, tech_name: g.tech.name, zone: a.zone || '', shift: a.shift }, proposed: [] } } } } const proposed = await bookingSlots({ skill, zone, duration: dur, start: dates[0], days: 14, limit: 6, aggregate: true }) return { chosen: null, proposed } } // ── Portail public de prise de RDV (staging — PAS sur Lovable tant que non validé) ── function todayET () { return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) } async function jobByToken (token) { if (!token) return null const rows = await erp.list('Dispatch Job', { filters: [['booking_token', '=', token]], fields: ['name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'start_time', 'booking_status'], limit: 1 }) return rows[0] || null } async function confirmWindow (jobName, date, start, duration, skill) { // À la confirmation on veut juste vérifier que le tech est ENCORE physiquement libre // (pas re-filtrer par la politique d'offre) → ignorePolicy. MAIS on garde le filtre COMPÉTENCE // (skill) sinon on pourrait assigner un tech non qualifié au créneau choisi. const day = await bookingSlots({ skill, duration, start: date, days: 1, limit: 300, ignorePolicy: true }) const slot = day.find(s => s.start === start) if (!slot) return { ok: false, message: 'Ce créneau vient d\'être pris — choisissez-en un autre.' } const st = start.length === 5 ? start + ':00' : start const r = await retryWrite(() => erp.update('Dispatch Job', jobName, { scheduled_date: date, start_time: st, assigned_tech: slot.tech, status: 'assigned', booking_status: 'Confirmé' })) if (r.ok) releaseHold(date + '|' + start) return r.ok ? { ok: true, confirmed: true, date, start, tech: slot.tech_name } : { ok: false, message: r.error || 'échec' } } const BOOK_HTML = `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' }) } // ── 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 }) } // 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-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' }) if (!body.matrix) { // temps de ROUTE réels via OSRM si dispo ; sinon le solveur retombe sur le haversine 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)) } } try { return json(res, 200, await postSolver('/route', body)) } 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))) }) } if (path === '/roster/job-flag' && method === 'POST') { // PRIORITÉ de dispatch persistante par job (SLA/commercial) → store hub durable (surcharge) const b = await parseBody(req); if (!b.name) return json(res, 400, { error: 'name requis' }) const p = String(b.priority || '').toLowerCase() setJobFlag(b.name, p); invalidatePool() // le pool porte j.priority → rafraîchir return json(res, 200, { ok: true, name: b.name, priority: PRIO_LEVELS.includes(p) ? p : '' }) } // 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 (!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 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 }) } // 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 }