gigafibre-fsm/services/targo-hub/lib/roster.js
louispaulb 1e8c434cb1 feat(dispatch): capacité AM/PM, durées additives, capture terrain (/field), sync techs
Hub (lib/roster.js, vision.js, legacy-dispatch-sync.js, server.js) + Ops + pont legacy.

- Capacité par jour AM/PM (Soir = réserve garde/urgence, jamais offerte) calculée
  client-side sur les techs visibles -> suit le filtre de compétence.
- Modèle de durée ADDITIF (caractéristiques, tableur inline) + auto-détection
  DÉTERMINISTE par mots-clés (sans IA permanente) ; est_min branché sur capacité + pool.
- Capture terrain passive : endpoints publics /field (job/tech/checkpoint/ts/photo/
  device/vision), tokens HMAC signés sans PII ; dérive actual_start/end. UI hébergée
  public/field-app.html (liste/carte Mapbox/Street View/photo/scan MLKit->Gemini).
- Chrono job (start/finish), repositionnement carte (set-location), vue satellite,
  Street View clic-droit, année devant les dates dues groupées.
- Sync techniciens : rapport de réconciliation 3 systèmes (staff legacy / Dispatch
  Technician / groupe Authentik), application MANUELLE, zéro écriture Authentik (+11 fiches).
- vision.js : extractEquipment() réutilisable (marque/modèle/série/MAC/codes-barres).
- Pont legacy (ops_reassign.php) : désassignation reflétée, fermeture ticket, retour
  au pool ; notification courriel à l'assignation.

Déployé sur le hub ; ce commit aligne le repo sur l'état en production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:47:21 -04:00

1302 lines
93 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

'use strict'
/**
* roster.js — Planification (« Roster AI »).
*
* Orchestre le solveur d'horaires OR-Tools (service roster-solver) avec les
* données réelles de l'ERPNext de facturation :
* - techniciens HUMAINS : Dispatch Technician (resource_type='human')
* - compétences : tags (_user_tags) du tech
* - disponibilité : status ('En pause'), absence_from/until, Tech Availability (Approuvé)
* - modèles de shifts : Shift Template
* - besoins de couverture : Shift Requirement (→ « dispo vs requis »)
* - assignations : Shift Assignment (statut Proposé/Publié)
*
* Le solveur ne fait QUE proposer ; /publish écrit les Shift Assignment.
* Aucune paie : on planifie + approuve, c'est tout.
*
* Routes (préfixe /roster) :
* GET /roster/technicians → techs humains + skills + indispos
* GET /roster/templates → modèles de shifts
* POST /roster/templates → créer un modèle
* GET /roster/requirements?start=&days= → besoins de couverture
* POST /roster/requirements → créer un besoin
* GET /roster/assignments?start=&days= → assignations existantes
* GET /roster/coverage?start=&days= → dispo vs requis (par besoin)
* POST /roster/generate {start,days,weights} → propose un horaire (n'écrit rien)
* POST /roster/publish {assignments} → écrit les Shift Assignment (Publié)
* POST /roster/availability {…} → demande congé/pause (Tech Availability)
* POST /roster/availability/:name/approve → approuve une demande
* POST /roster/technician/:id/pause {paused,reason} → met/retire un tech en pause
*/
const http = require('http')
const crypto = require('crypto')
const { json, parseBody } = require('./helpers')
const erp = require('./erp')
const cfg = require('./config')
const fs = require('fs')
const path = require('path')
const POLICY_FILE = path.join(__dirname, '..', 'data', 'dispatch-policy.json')
const SOLVER_URL = cfg.ROSTER_SOLVER_URL || 'http://roster-solver:8090'
const PAUSE_STATUS = 'En pause'
const AVAIL_STATUS = 'Disponible'
// ── Date helpers (local, sans dépendance) ──────────────────────────────────
function iso (d) { return d.toISOString().slice(0, 10) }
function parseISO (s) { const [y, m, dd] = s.split('-').map(Number); return new Date(Date.UTC(y, m - 1, dd)) }
function addDays (d, n) { const r = new Date(d); r.setUTCDate(r.getUTCDate() + n); return r }
function rangeDates (start, days) {
const s = parseISO(start); const out = []
for (let i = 0; i < days; i++) out.push(iso(addDays(s, i)))
return out
}
function splitCsv (s) {
return String(s || '').split(',').map(x => x.trim()).filter(Boolean)
}
// POST au solveur via le module http natif (comme erpFetch — fiable dans le
// process long du hub, contrairement au fetch global undici).
function postSolver (path, body) {
const data = JSON.stringify(body)
const u = new URL(SOLVER_URL + path)
return new Promise((resolve, reject) => {
const req = http.request({
hostname: u.hostname, port: u.port || 80, path: u.pathname + u.search, method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
timeout: 30000,
}, (res) => {
let d = ''
res.on('data', c => { d += c })
res.on('end', () => { try { resolve(JSON.parse(d)) } catch { resolve({ status: 'ERROR', message: 'réponse solveur invalide' }) } })
})
req.on('error', reject)
req.on('timeout', () => { req.destroy(); reject(new Error('solveur: timeout')) })
req.write(data); req.end()
})
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
// Réessai des écritures ERPNext. Le shim frappe_pg tourne en SERIALIZABLE → sur
// les lignes chaudes (Dispatch Technician maj en continu par le GPS/dispatch) un
// SELECT…FOR UPDATE peut lever "could not serialize access due to concurrent
// update" (HTTP 500). La requête a été rollback → réessayer est sûr (idempotent).
async function retryWrite (fn, tries = 5) {
let r
for (let i = 0; i < tries; i++) {
r = await fn()
if (r.ok || (r.status && r.status < 500)) return r
await sleep(120 * (i + 1))
}
return r
}
// ── Lecture des techniciens humains + compétences + indisponibilités ────────
async function fetchTechnicians () {
const rows = await erp.list('Dispatch Technician', {
filters: [['resource_type', '=', 'human']],
fields: ['name', 'technician_id', 'full_name', 'status', 'color_hex', 'tech_group', 'efficiency', 'skills',
'cost_salary_h', 'cost_charges_pct', 'cost_other_h',
'absence_from', 'absence_until', 'employee', 'phone', '_user_tags', 'skill_levels', 'skill_eff'],
limit: 500,
})
return rows.map(t => ({
id: t.technician_id || t.name,
name: t.full_name || t.technician_id,
status: t.status,
group: t.tech_group || '',
efficiency: Number(t.efficiency) || 1,
cost_salary_h: Number(t.cost_salary_h) || 0,
cost_charges_pct: Number(t.cost_charges_pct) || 0,
cost_other_h: Number(t.cost_other_h) || 0,
cost_h: Math.round(((Number(t.cost_salary_h) || 0) * (1 + (Number(t.cost_charges_pct) || 0) / 100) + (Number(t.cost_other_h) || 0)) * 100) / 100,
color: t.color_hex || '#1976d2',
phone: t.phone,
employee: t.employee,
skills: splitCsv(t.skills || t._user_tags), // champ skills (ou tags Frappe)
skill_levels: (() => { try { return JSON.parse(t.skill_levels || '{}') } catch { return {} } })(), // {compétence: niveau 15}
skill_eff: (() => { try { return JSON.parse(t.skill_eff || '{}') } catch { return {} } })(), // {compétence: facteur d'efficacité (vitesse)}
absence_from: t.absence_from,
absence_until: t.absence_until,
}))
}
// Construit, pour chaque tech, la liste des dates indisponibles dans l'horizon.
async function buildUnavailability (techs, dateList) {
const start = dateList[0]
const end = dateList[dateList.length - 1]
const byTech = {}
for (const t of techs) byTech[t.id] = new Set()
// 1) status « En pause » → indispo sur tout l'horizon (pause active)
for (const t of techs) {
if (t.status === PAUSE_STATUS) dateList.forEach(d => byTech[t.id].add(d))
// 2) fenêtre d'absence du Dispatch Technician
if (t.absence_from && t.absence_until) {
for (const d of dateList) if (d >= t.absence_from && d <= t.absence_until) byTech[t.id].add(d)
}
}
// 3) Tech Availability approuvées qui chevauchent l'horizon
const avs = await erp.list('Tech Availability', {
filters: [['status', '=', 'Approuvé'], ['from_date', '<=', end], ['to_date', '>=', start]],
fields: ['technician', 'from_date', 'to_date', 'availability_type'],
limit: 500,
})
for (const a of avs) {
if (!byTech[a.technician]) continue
for (const d of dateList) if (d >= a.from_date && d <= a.to_date) byTech[a.technician].add(d)
}
return byTech
}
// ── Modèles + besoins ───────────────────────────────────────────────────────
async function fetchTemplates () {
const rows = await erp.list('Shift Template', {
filters: [['active', '=', 1]],
fields: ['name', 'template_name', 'start_time', 'end_time', 'hours', 'color', 'zone', 'default_required', 'required_skills', 'on_call'],
limit: 100,
})
return rows
}
async function fetchRequirements (start, days) {
const dates = rangeDates(start, days)
return erp.list('Shift Requirement', {
filters: [['requirement_date', 'in', dates]],
fields: ['name', 'requirement_date', 'shift_template', 'zone', 'required_count', 'required_skills'],
limit: 1000,
})
}
async function fetchAssignments (start, days) {
const dates = rangeDates(start, days)
const rows = await erp.list('Shift Assignment', {
filters: [['assignment_date', 'in', dates]],
fields: ['name', 'technician', 'technician_name', 'assignment_date', 'shift_template', 'zone', 'hours', 'status', 'source'],
limit: 2000,
})
// Normaliser vers la forme canonique {tech, date, shift} (= sortie du solveur + UI)
return rows.map(r => ({
name: r.name, tech: r.technician, tech_name: r.technician_name, date: r.assignment_date,
shift: r.shift_template, zone: r.zone, hours: r.hours, status: r.status, source: r.source,
}))
}
// ── Construit le payload du solveur + l'appelle ─────────────────────────────
async function generate (start, days, weights) {
const dateList = rangeDates(start, days)
// Séquentiel volontaire : le backend frappe (peu de workers) reset des
// connexions sous rafale concurrente → erp.list renvoie [] par intermittence.
const techs = await fetchTechnicians()
const templates = await fetchTemplates()
const requirements = await fetchRequirements(start, days)
const unavail = await buildUnavailability(techs, dateList)
const shift_templates = templates.map(t => ({
id: t.name, name: t.template_name || t.name,
hours: Number(t.hours) || 8,
}))
const technicians = techs.map(t => ({
id: t.id, name: t.name, skills: t.skills,
max_hours_week: 40, max_days: 5, cost_per_h: t.cost_h || 0,
zone_home: null, preferred_off: [], time_factor: t.efficiency || 1,
unavailable: [...unavail[t.id]],
}))
const coverage = requirements.map(r => ({
date: r.requirement_date, shift: r.shift_template, zone: r.zone || '—',
required: Number(r.required_count) || 1,
required_skills: splitCsv(r.required_skills),
}))
const payload = { horizon: { start, days }, shift_templates, technicians, coverage, weights: weights || undefined, max_seconds: 12 }
const result = await postSolver('/solve', payload)
// enrichir avec le nom + couleur pour l'UI
const nameById = Object.fromEntries(techs.map(t => [t.id, t.name]))
const colorByTpl = Object.fromEntries(templates.map(t => [t.name, t.color || '#1976d2']))
for (const a of (result.assignments || [])) {
a.tech_name = nameById[a.tech] || a.tech
a.color = colorByTpl[a.shift] || '#1976d2'
}
return { ...result, counts: { technicians: technicians.length, templates: shift_templates.length, requirements: coverage.length } }
}
// Écrit les assignations retenues comme Shift Assignment (Publié).
async function publish (assignments) {
const created = []; const errors = []
for (const a of assignments || []) {
const r = await retryWrite(() => erp.create('Shift Assignment', {
technician: a.tech, technician_name: a.tech_name || '',
assignment_date: a.date, shift_template: a.shift, zone: a.zone || '',
hours: Number(a.hours) || 0, status: 'Publié', source: a.source || 'solveur',
}))
if (r.ok) created.push(r.name); else errors.push({ a, error: r.error })
}
return { ok: errors.length === 0, created: created.length, errors }
}
// dispo vs requis : pour chaque besoin, compte les assignations publiées correspondantes
async function coverage (start, days) {
const reqs = await fetchRequirements(start, days)
const asgs = await fetchAssignments(start, days)
const key = (d, s, z) => `${d}|${s}|${z || '—'}`
const counts = {}
for (const a of asgs) {
if (a.status === 'Annulé') continue
counts[key(a.date, a.shift, a.zone)] = (counts[key(a.date, a.shift, a.zone)] || 0) + 1
}
return reqs.map(r => {
const assigned = counts[key(r.requirement_date, r.shift_template, r.zone)] || 0
const required = Number(r.required_count) || 0
return { date: r.requirement_date, shift: r.shift_template, zone: r.zone || '—', required, assigned, shortfall: Math.max(0, required - assigned) }
})
}
// ── Prise de RDV : disponibilité consciente du roster ──────────────────────
// Renvoie les fenêtres libres où un tech EN SHIFT publié ce jour-là, avec la
// compétence requise, est disponible (trous dans son shift moins les jobs déjà
// pointés). Sert aux 2 canaux : on propose au client, ou on valide son choix.
function timeToH (t) { if (!t) return 0; const [h, m] = String(t).split(':').map(Number); return (h || 0) + (m || 0) / 60 }
function nowFrappe () { return new Date().toLocaleString('sv-SE', { timeZone: 'America/Toronto' }).replace('T', ' ') } // "YYYY-MM-DD HH:MM:SS"
function minutesBetween (a, b) { if (!a || !b) return null; const ms = Date.parse(String(b).replace(' ', 'T')) - Date.parse(String(a).replace(' ', 'T')); return isFinite(ms) ? Math.max(0, Math.round(ms / 60000)) : null }
function hToTime (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') }
// ── DURÉE D'UNE JOB = modèle ADDITIF par caractéristiques (hors transport) ───────────────────────────────
// Base (1) + Σ caractéristiques cochées (×qté), × vitesse tech. Table ÉDITABLE (tableur inline Ops), persistée en JSON.
// `minutes` = seed (réglé à la main, inspiré du tableur dispatch) ; `learned_min`+`samples` = calibration future par
// régression sur les durées RÉELLES (geofence/checkpoints) — « triangulation » des temps par caractéristique.
const JOBCHAR_PATH = '/app/data/job-characteristics.json'
const JOBCHAR_SEED = { items: [
{ id: 'install_resid', cat: 'base', label: 'Installation fibre résidentielle', minutes: 120, per_qty: false, keywords: 'installation,raccordement' },
{ id: 'install_comm', cat: 'base', label: 'Installation commerciale', minutes: 240, per_qty: false, keywords: 'commercial' },
{ id: 'predrop', cat: 'base', label: 'Pré-drop (pose)', minutes: 120, per_qty: false, keywords: 'pre-drop,predrop,pré-drop' },
{ id: 'repar_fibre', cat: 'base', label: 'Réparation / bris de fibre', minutes: 105, per_qty: false, keywords: 'réparation,reparation,bris,fibre' },
{ id: 'depan_wifi', cat: 'base', label: 'Dépannage Wifi / réseau', minutes: 90, per_qty: false, keywords: 'wifi,sans-fil,internet' },
{ id: 'changer_secteur', cat: 'base', label: 'Changer de secteur', minutes: 75, per_qty: false, keywords: 'secteur' },
{ id: 'retrait', cat: 'base', label: 'Retrait / désinstallation', minutes: 60, per_qty: false, keywords: 'retrait,désinstall,desinstall' },
{ id: 'chgmt_equip', cat: 'base', label: "Changement d'équipement (ONU/modem/Hx)", minutes: 45, per_qty: false, keywords: 'changement,onu,modem,équipement,equipement,hx' },
{ id: 'migration_tv', cat: 'base', label: 'Migration TV / boîtier', minutes: 45, per_qty: false, keywords: 'migration,tv,boîtier,boitier' },
{ id: 'ata', cat: 'base', label: 'Activation téléphonie (ATA)', minutes: 45, per_qty: false, keywords: 'ata,téléphonie,telephonie' },
{ id: 'enfoui', cat: 'addon', label: 'Drop enfoui (au lieu daérien)', minutes: 75, per_qty: false, keywords: 'enfoui,enterré,enterre,souterrain' },
{ id: 'echelle', cat: 'addon', label: 'Aérien avec échelle / poteau', minutes: 30, per_qty: false, keywords: 'aérien,aerien,poteau,échelle,echelle' },
{ id: 'dist_poteau', cat: 'addon', label: 'Distance poteau → maison > 60 m', minutes: 30, per_qty: false, keywords: '' },
{ id: 'boitier_tv', cat: 'addon', label: 'Boîtier TV / STB supplémentaire', minutes: 30, per_qty: true, keywords: 'stb,boîtier tv,boitier tv' },
{ id: 'mesh', cat: 'addon', label: "Point d'accès mesh supplémentaire", minutes: 20, per_qty: true, keywords: 'mesh,borne' },
{ id: 'tel_ata', cat: 'addon', label: 'Ligne téléphonie / ATA ajoutée', minutes: 30, per_qty: true, keywords: '' },
{ id: 'epissure', cat: 'addon', label: 'Épissure / fusion (par connecteur)', minutes: 20, per_qty: true, keywords: 'épissure,epissure,fusion' },
{ id: 'wifi_complexe', cat: 'addon', label: 'Config Wifi complexe (multi-SSID/VLAN)', minutes: 30, per_qty: false, keywords: '' },
{ id: 'tel_complexe', cat: 'addon', label: 'Téléphonie complexe (PBX/multi-lignes)', minutes: 30, per_qty: false, keywords: 'pbx' },
{ id: 'test_otdr', cat: 'addon', label: 'Test / certification (OTDR, photos)', minutes: 20, per_qty: false, keywords: 'otdr,certification' },
{ id: 'acces_difficile', cat: 'addon', label: 'Accès difficile (hauteur, vide sanitaire)', minutes: 30, per_qty: false, keywords: '' },
{ id: 'tampon_client', cat: 'modifier', label: 'Tampon coordination client', minutes: 15, per_qty: false, keywords: '' },
{ id: 'junior_install', cat: 'modifier', label: 'Tech junior sur install (pénalité)', minutes: 90, per_qty: false, keywords: '' },
] }
function readJobChar () { try { return JSON.parse(require('fs').readFileSync(JOBCHAR_PATH, 'utf8')) } catch (e) { return JSON.parse(JSON.stringify(JOBCHAR_SEED)) } }
function writeJobChar (items) { try { require('fs').writeFileSync(JOBCHAR_PATH, JSON.stringify({ items, updated: nowFrappe() }, null, 2)); return true } catch (e) { return false } }
// Durée estimée (minutes, hors transport) à partir des caractéristiques cochées : picks = [{id, qty}].
function estimateMinutes (picks, items) {
const by = {}; for (const i of (items || readJobChar().items)) by[i.id] = i
let m = 0; for (const p of (picks || [])) { const it = by[p.id]; if (!it) continue; const mn = (it.learned_min != null ? it.learned_min : it.minutes) || 0; m += mn * (it.per_qty ? (Number(p.qty) || 1) : 1) }
return Math.round(m)
}
// Auto-détection DÉTERMINISTE (mots-clés / regex, AUCUNE IA) des caractéristiques d'un job depuis son texte.
function detectCharacteristics (job, items) {
const strip = s => String(s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '')
const text = strip([job.subject, job.legacy_detail, job.legacy_dept, job.job_type, job.service_type].join(' '))
const hit = kw => String(kw || '').split(',').map(x => strip(x).trim()).filter(Boolean).some(k => new RegExp('\\b' + k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b').test(text)) // limites de mots → pas de faux positif « ap » dans « après »
const bases = items.filter(i => i.cat === 'base')
let base = bases.find(b => hit(b.keywords))
if (!base) { const jt = strip(job.job_type); base = bases.find(b => (jt.includes('install') && b.id === 'install_resid') || (jt.includes('repar') && b.id === 'repar_fibre') || (jt.includes('retrait') && b.id === 'retrait')) }
const picks = base ? [{ id: base.id, qty: 1 }] : []
for (const a of items.filter(i => i.cat === 'addon')) if (hit(a.keywords)) picks.push({ id: a.id, qty: 1 })
return picks
}
function estimateForJob (job, items) {
items = items || readJobChar().items
const picks = detectCharacteristics(job, items); const by = {}; for (const i of items) by[i.id] = i
return { minutes: estimateMinutes(picks, items), picks, labels: picks.map(p => (by[p.id] || {}).label).filter(Boolean) }
}
// ── #56 Politique de créneaux offerts + holds temporaires ────────────────────
// Persistée dans le même fichier que la politique de reprise (sous-objet `booking`),
// éditée via /roster/policy (lib/roster-assistant.js). Appliquée ici à TOUTE source
// de créneaux (page /book, vue agent, fit) → comportement cohérent partout.
const BOOKING_DEFAULTS = { lead_hours: 24, day_start: 8, day_end: 18, days_offered: [1, 2, 3, 4, 5], horizon_days: 21, max_per_day: 0, hold_minutes: 10, skill_by_type: {} }
function getBookingPolicy () {
try { const p = JSON.parse(fs.readFileSync(POLICY_FILE, 'utf8')); return { ...BOOKING_DEFAULTS, ...(p.booking || {}) } } catch { return { ...BOOKING_DEFAULTS } }
}
// Compétence REQUISE pour un job → seuls les techs qui l'ont produisent des créneaux (techGaps filtre).
// Un ajout TV/borne WiFi peut mapper à '' (n'importe qui) ; une installation/réparation à 'installateur'.
// Source : champ explicite Dispatch Job.required_skill, sinon table service_type → tag (politique booking).
function skillForJob (job) {
if (!job) return ''
if (job.required_skill) return String(job.required_skill).trim()
const map = getBookingPolicy().skill_by_type || {}
return String(map[job.service_type] || '').trim()
}
// Repli : déduit une COMPÉTENCE (parmi les skills réels des techs) depuis le département/type legacy.
// Sert à colorer les tickets par la couleur de leur compétence (éditable via le gestionnaire de tags).
function deptToSkill (txt) {
const d = String(txt || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
if (!d) return ''
if (/teleph/.test(d)) return 'telephone'
if (/tele|televis/.test(d)) return 'tv'
if (/fusion|episs/.test(d)) return 'épissure'
if (/monteur|aerien/.test(d)) return 'monteur'
if (/netadmin|net admin/.test(d)) return 'netadmin'
if (/repar|desinstall/.test(d)) return 'réparation'
if (/install|fibre/.test(d)) return 'installation'
return ''
}
// Enrichit des jobs avec une adresse LISIBLE (le champ service_location est un code « LOC-… »).
// Batch : 1 seule requête sur Service Location pour tous les codes distincts.
async function attachLocations (jobs) {
const codes = [...new Set((jobs || []).map(j => j.service_location).filter(Boolean))]
if (!codes.length) return jobs
let locs = []
try { locs = await erp.list('Service Location', { filters: [['name', 'in', codes]], fields: ['name', 'address_line', 'city', 'location_name'], limit: codes.length + 10 }) } catch (e) { return jobs }
const m = {}; for (const l of locs) m[l.name] = l
for (const j of jobs) { const l = m[j.service_location]; if (l) j.location_label = [l.address_line, l.city].filter(Boolean).join(', ') || l.location_name || '' }
return jobs
}
function nowMs () { return new Date().getTime() }
// Holds en mémoire : quand un client/agent sélectionne une fenêtre, on la réserve
// quelques minutes pour éviter qu'un autre la prenne pendant la confirmation.
// Transitoire (perdu au redémarrage du hub = acceptable pour un blocage de ~10 min).
const bookingHolds = new Map() // clé 'YYYY-MM-DD|HH:MM' → [expiryMs, …]
function holdCount (key) {
const now = nowMs(); const arr = (bookingHolds.get(key) || []).filter(t => t > now)
if (arr.length) bookingHolds.set(key, arr); else bookingHolds.delete(key)
return arr.length
}
function addHold (key, minutes) {
const arr = (bookingHolds.get(key) || []).filter(t => t > nowMs())
arr.push(nowMs() + Math.max(1, minutes || 10) * 60000); bookingHolds.set(key, arr)
return arr.length
}
function releaseHold (key) { bookingHolds.delete(key) }
async function loadBookingData (start, days) {
const dates = rangeDates(start, days)
const asgs = await fetchAssignments(start, days)
const techs = await fetchTechnicians()
const templates = await fetchTemplates()
const unavail = await buildUnavailability(techs, dates) // En pause + absence_from/until + Tech Availability approuvées (par jour)
const techById = Object.fromEntries(techs.map(t => [t.id, t]))
const tplByName = Object.fromEntries(templates.map(t => [t.name, t]))
const jobs = await erp.list('Dispatch Job', {
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned']]],
fields: ['assigned_tech', 'scheduled_date', 'start_time', 'duration_h'], limit: 2000,
})
const booked = {}
for (const j of jobs) {
if (!j.start_time) continue
const k = j.assigned_tech + '|' + j.scheduled_date
;(booked[k] || (booked[k] = [])).push({ s: timeToH(j.start_time), e: timeToH(j.start_time) + (Number(j.duration_h) || 1) })
}
return { asgs, techById, tplByName, booked, unavail }
}
// Trous libres d'un tech (dans son shift, moins jobs pointés), filtrés compétence/zone.
function techGaps (a, d, skill, zone) {
const t = d.techById[a.tech]; if (!t || t.status === PAUSE_STATUS) return null
if (d.unavail && d.unavail[a.tech] && d.unavail[a.tech].has(a.date)) return null // absence/congé approuvé ce jour-là → pas de créneaux
if (skill && !(t.skills || []).includes(skill)) return null
if (zone && a.zone && a.zone !== zone) return null
const tpl = d.tplByName[a.shift]; if (!tpl) return null
if (tpl.on_call) return null // garde (sur appel) = capacité d'urgence, JAMAIS offerte au booking client
const sh = timeToH(tpl.start_time) || 8; const eh = timeToH(tpl.end_time) || (sh + (Number(tpl.hours) || 8))
const day = (d.booked[a.tech + '|' + a.date] || []).slice().sort((x, y) => x.s - y.s)
let cursor = sh; const gaps = []
for (const b of day) { if (b.s > cursor) gaps.push([cursor, b.s]); cursor = Math.max(cursor, b.e) }
if (cursor < eh) gaps.push([cursor, eh])
return { tech: t, gaps }
}
// Premier créneau libre (en heures, ex 8.5) d'un tech un jour donné pour une durée `dur`.
// Aucun trou assez large → empile après le dernier bloc (surbook visible) ; pas de shift régulier → null.
// Partagé par /roster/assign-job (drag-drop) et /roster/backfill-start-times.
function firstFitStart (d, techId, date, dur) {
const mine = d.asgs.filter(a => a.tech === techId && a.date === date && !(d.tplByName[a.shift] && d.tplByName[a.shift].on_call))
if (!mine.length) return null
let best = null
for (const a of mine) { const g = techGaps(a, d, null, null); if (!g) continue; for (const [gs, ge] of g.gaps) if (ge - gs >= dur - 1e-6) { if (best == null || gs < best) best = gs; break } }
if (best == null) { const tpl = d.tplByName[mine[0].shift]; const sh = timeToH(tpl && tpl.start_time) || 8; const day = (d.booked[techId + '|' + date] || []).slice().sort((x, y) => x.e - y.e); best = day.length ? day[day.length - 1].e : sh }
return best
}
async function bookingSlots ({ skill, zone, duration = 1, start, days = 7, limit = 24, aggregate = false, ignorePolicy = false } = {}) {
const dur = Number(duration) || 1
const pol = getBookingPolicy()
const d = await loadBookingData(start, days)
const leadCut = ignorePolicy ? 0 : nowMs() + (pol.lead_hours || 0) * 3600000
const dayStart = ignorePolicy ? 0 : (Number.isFinite(pol.day_start) ? pol.day_start : 0)
const dayEnd = ignorePolicy ? 24 : (Number.isFinite(pol.day_end) ? pol.day_end : 24)
const offered = !ignorePolicy && Array.isArray(pol.days_offered) && pol.days_offered.length ? pol.days_offered : null
const out = []
for (const a of d.asgs) {
if (a.status === 'Annulé') continue
if (offered && !offered.includes(parseISO(a.date).getUTCDay())) continue // jour non offert par la politique
const g = techGaps(a, d, skill, zone); if (!g) continue
for (const [gs, ge] of g.gaps) {
let s = Math.max(gs, dayStart); const end = Math.min(ge, dayEnd) // borné à la plage horaire offerte
while (s + dur <= end) {
const slotMs = parseISO(a.date).getTime() + s * 3600000 // approx UTC — suffisant pour un délai exprimé en heures
if (slotMs >= leadCut) out.push({ date: a.date, start: hToTime(s), end: hToTime(s + dur), start_h: s, tech: a.tech, tech_name: g.tech.name, zone: a.zone || '', shift: a.shift })
s += dur
}
}
}
if (aggregate) { // client : 1 fenêtre par (date,heure) + nb de techs dispo, moins les holds actifs
const byWin = {}
for (const s of out) { const k = s.date + '|' + s.start; (byWin[k] || (byWin[k] = { date: s.date, start: s.start, end: s.end, start_h: s.start_h, available: 0 })).available++ }
let arr = Object.values(byWin).map(w => ({ ...w, available: w.available - holdCount(w.date + '|' + w.start) })).filter(w => w.available > 0)
arr.sort((x, y) => x.date.localeCompare(y.date) || x.start_h - y.start_h)
if (!ignorePolicy && pol.max_per_day > 0) { const c = {}; arr = arr.filter(w => { c[w.date] = (c[w.date] || 0) + 1; return c[w.date] <= pol.max_per_day }) } // plafond/jour
return arr.slice(0, limit)
}
out.sort((x, y) => x.date.localeCompare(y.date) || x.start_h - y.start_h)
return out.slice(0, limit)
}
// Fit : le client fournit 3 dispos classées → on place dans le 1er choix tenable,
// sinon 2e, sinon 3e. Si aucune ne tient → on PROPOSE nos créneaux (fallback).
async function fitBooking ({ skill, zone, duration = 1, prefs = [] } = {}) {
const dur = Number(duration) || 1
const dates = [...new Set((prefs || []).map(p => p.date).filter(Boolean))].sort()
if (!dates.length) return { chosen: null, proposed: [] }
const span = Math.max(1, Math.round((parseISO(dates[dates.length - 1]) - parseISO(dates[0])) / 86400000) + 1)
const d = await loadBookingData(dates[0], span)
const byDate = {}; for (const a of d.asgs) (byDate[a.date] || (byDate[a.date] = [])).push(a)
for (let i = 0; i < prefs.length; i++) {
const p = prefs[i]; const ps = timeToH(p.start); const pe = ps + dur
for (const a of (byDate[p.date] || [])) {
if (a.status === 'Annulé') continue
const g = techGaps(a, d, skill, zone); if (!g) continue
if (g.gaps.some(([gs, ge]) => gs <= ps && ge >= pe)) {
return { chosen: { rank: i + 1, date: p.date, start: p.start, end: hToTime(pe), tech: a.tech, tech_name: g.tech.name, zone: a.zone || '', shift: a.shift }, proposed: [] }
}
}
}
const proposed = await bookingSlots({ skill, zone, duration: dur, start: dates[0], days: 14, limit: 6, aggregate: true })
return { chosen: null, proposed }
}
// ── Portail public de prise de RDV (staging — PAS sur Lovable tant que non validé) ──
function todayET () { return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) }
async function jobByToken (token) {
if (!token) return null
const rows = await erp.list('Dispatch Job', { filters: [['booking_token', '=', token]], fields: ['name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'start_time', 'booking_status'], limit: 1 })
return rows[0] || null
}
async function confirmWindow (jobName, date, start, duration, skill) {
// À la confirmation on veut juste vérifier que le tech est ENCORE physiquement libre
// (pas re-filtrer par la politique d'offre) → ignorePolicy. MAIS on garde le filtre COMPÉTENCE
// (skill) sinon on pourrait assigner un tech non qualifié au créneau choisi.
const day = await bookingSlots({ skill, duration, start: date, days: 1, limit: 300, ignorePolicy: true })
const slot = day.find(s => s.start === start)
if (!slot) return { ok: false, message: 'Ce créneau vient d\'être pris — choisissez-en un autre.' }
const st = start.length === 5 ? start + ':00' : start
const r = await retryWrite(() => erp.update('Dispatch Job', jobName, { scheduled_date: date, start_time: st, assigned_tech: slot.tech, status: 'assigned', booking_status: 'Confirmé' }))
if (r.ok) releaseHold(date + '|' + start)
return r.ok ? { ok: true, confirmed: true, date, start, tech: slot.tech_name } : { ok: false, message: r.error || 'échec' }
}
const BOOK_HTML = `<!doctype html><html lang=fr><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>Prendre rendez-vous — Gigafibre</title>
<style>body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;margin:0;background:#f4f6f8;color:#1a1a1a}.wrap{max-width:560px;margin:0 auto;padding:16px}.card{background:#fff;border-radius:12px;box-shadow:0 1px 4px rgba(0,0,0,.08);padding:20px;margin-bottom:12px}h1{font-size:20px;margin:0 0 4px}.sub{color:#666;font-size:13px;margin-bottom:8px}.brand{color:#1565c0;font-weight:800}.day{font-weight:700;margin:14px 0 6px;font-size:13px;color:#444}.slots{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:8px}.slot{border:1px solid #d0d7de;border-radius:8px;padding:10px 6px;text-align:center;cursor:pointer;font-size:14px;position:relative}.slot:hover{border-color:#1565c0}.slot.sel{background:#e3f2fd;border-color:#1565c0;font-weight:700}.rank{position:absolute;top:-8px;right:-8px;background:#1565c0;color:#fff;width:20px;height:20px;border-radius:50%;font-size:12px;line-height:20px}.btn{background:#1565c0;color:#fff;border:0;border-radius:8px;padding:13px 18px;font-size:15px;font-weight:600;cursor:pointer;width:100%;margin-top:12px}.btn:disabled{background:#b0bec5}.hint{font-size:12px;color:#666;margin:8px 0}.ok{background:#e8f5e9;color:#1b5e20;padding:16px;border-radius:8px;text-align:center}.err{background:#ffebee;color:#b71c1c;padding:12px;border-radius:8px}.week{background:#1565c0;color:#fff;font-weight:700;font-size:13px;padding:8px 12px;border-radius:8px;margin:18px 0 4px}.ampm{font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.5px;margin:10px 0 4px;font-weight:700}.empty{color:#999;font-size:12px;font-style:italic}</style></head>
<body><div class=wrap><div class=card><h1>Prendre <span class=brand>rendez-vous</span></h1><div class=sub id=jobinfo>Chargement…</div><div id=content></div></div><div class=sub style=text-align:center>Gigafibre · propulsé par Targo</div></div>
<script>
const token=new URLSearchParams(location.search).get('token')||'';
const FR=['dimanche','lundi','mardi','mercredi','jeudi','vendredi','samedi'];
const MO=['janv.','févr.','mars','avr.','mai','juin','juil.','août','sept.','oct.','nov.','déc.'];
let picks=[];
function d2(iso){const a=iso.split('-').map(Number);return new Date(Date.UTC(a[0],a[1]-1,a[2]))}
function dayLabel(iso){const dt=d2(iso);return FR[dt.getUTCDay()]+' '+dt.getUTCDate()+' '+MO[dt.getUTCMonth()]}
function wkMon(iso){const dt=d2(iso);const off=(dt.getUTCDay()+6)%7;dt.setUTCDate(dt.getUTCDate()-off);return dt.toISOString().slice(0,10)}
function wkLabel(m){const a=d2(m);const b=new Date(a);b.setUTCDate(b.getUTCDate()+6);return 'Semaine du '+a.getUTCDate()+' '+MO[a.getUTCMonth()]+' '+b.getUTCDate()+' '+MO[b.getUTCMonth()]}
async function load(){const r=await fetch('/book/api/options?token='+encodeURIComponent(token)).then(x=>x.json()).catch(()=>({ok:false}));const info=document.getElementById('jobinfo'),c=document.getElementById('content');
if(!r.ok){info.textContent='';c.innerHTML='<div class=err>Lien invalide ou expiré. Contactez-nous.</div>';return}
if(r.job.scheduled){info.innerHTML='Votre rendez-vous est déjà confirmé : <b>'+dayLabel(r.job.scheduled)+'</b>.';c.innerHTML='';return}
info.innerHTML='Choisissez vos disponibilités <b>en ordre de préférence</b> (jusquà 3). On confirme le 1er créneau possible.';
if(!r.windows.length){c.innerHTML='<div class=err>Aucune disponibilité pour le moment — nous vous contacterons.</div>';return}
const weeks={};r.windows.forEach(w=>{const mk=wkMon(w.date);(weeks[mk]=weeks[mk]||{});(weeks[mk][w.date]=weeks[mk][w.date]||[]).push(w)});let h='';
Object.keys(weeks).sort().forEach(mk=>{h+='<div class=week>'+wkLabel(mk)+'</div>';Object.keys(weeks[mk]).sort().forEach(d=>{const ws=weeks[mk][d].sort((a,b)=>a.start_h-b.start_h);const am=ws.filter(w=>w.start_h<12),pm=ws.filter(w=>w.start_h>=12);h+='<div class=day>'+dayLabel(d)+'</div>';const sect=(lab,arr)=>arr.length?('<div class=ampm>'+lab+'</div><div class=slots>'+arr.map(w=>'<div class=slot data-d="'+w.date+'" data-s="'+w.start+'">'+w.start+'</div>').join('')+'</div>'):'';h+=sect('Matin',am)+sect('Après-midi',pm)})});
h+='<div class=hint id=hint>Touchez 1 à 3 créneaux.</div><button class=btn id=go disabled>Confirmer mes disponibilités</button>';c.innerHTML=h;
c.querySelectorAll('.slot').forEach(el=>el.onclick=()=>toggle(el));document.getElementById('go').onclick=submit}
function toggle(el){const k=el.dataset.d+'|'+el.dataset.s,i=picks.indexOf(k);if(i>=0)picks.splice(i,1);else if(picks.length<3)picks.push(k);render()}
function render(){document.querySelectorAll('.slot').forEach(el=>{const k=el.dataset.d+'|'+el.dataset.s,i=picks.indexOf(k);el.classList.toggle('sel',i>=0);let b=el.querySelector('.rank');if(i>=0){if(!b){b=document.createElement('div');b.className='rank';el.appendChild(b)}b.textContent=i+1}else if(b)b.remove()});document.getElementById('hint').textContent=picks.length?picks.length+' choix sélectionné(s) — le 1er sera priorisé.':'Touchez 1 à 3 créneaux.';document.getElementById('go').disabled=!picks.length}
async function submit(){const go=document.getElementById('go');go.disabled=true;go.textContent='Envoi…';const prefs=picks.map(k=>({date:k.split('|')[0],start:k.split('|')[1]}));const r=await fetch('/book/api/submit?token='+encodeURIComponent(token),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode:'rank',prefs})}).then(x=>x.json()).catch(()=>({ok:false}));const c=document.getElementById('content');
if(r.ok&&r.confirmed)c.innerHTML='<div class=ok>✅ Rendez-vous confirmé : <b>'+dayLabel(r.date)+' à '+r.start+'</b>.<br>Merci !</div>';
else if(r.ok)c.innerHTML='<div class=ok>Merci ! '+(r.message||'Nous vous confirmerons sous peu.')+'</div>';
else c.innerHTML='<div class=err>'+(r.message||r.error||'Une erreur est survenue.')+'</div>'}
load();
</script></body></html>`
async function handlePublicBooking (req, res, method, path, url) {
if (path === '/book' && method === 'GET') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(BOOK_HTML) }
const token = url.searchParams.get('token') || ''
if (path === '/book/api/options' && method === 'GET') {
const job = await jobByToken(token); if (!job) return json(res, 404, { ok: false, error: 'lien invalide' })
const dur = Number(job.duration_h) || 1; const skill = skillForJob(job) // seuls les techs qualifiés
const windows = await bookingSlots({ skill, duration: dur, start: todayET(), days: getBookingPolicy().horizon_days || 21, aggregate: true, limit: 60 })
return json(res, 200, { ok: true, job: { location: job.service_location || '', service_type: job.service_type || '', required_skill: skill, duration: dur, scheduled: job.scheduled_date || '' }, windows })
}
if (path === '/book/api/submit' && method === 'POST') {
const job = await jobByToken(token); if (!job) return json(res, 404, { ok: false, error: 'lien invalide' })
const b = await parseBody(req); const dur = Number(job.duration_h) || 1; const skill = skillForJob(job)
if (b.mode === 'rank' && Array.isArray(b.prefs) && b.prefs.length) {
const fit = await fitBooking({ skill, duration: dur, prefs: b.prefs })
if (fit.chosen) { const r = await confirmWindow(job.name, fit.chosen.date, fit.chosen.start, dur, skill); if (r.ok) return json(res, 200, { ...r, rank: fit.chosen.rank }) }
await retryWrite(() => erp.update('Dispatch Job', job.name, { booking_prefs: JSON.stringify(b.prefs), booking_status: 'Proposé' }))
return json(res, 200, { ok: true, confirmed: false, message: 'Vos disponibilités sont enregistrées — nous vous confirmerons sous peu.' })
}
return json(res, 400, { ok: false, error: 'requête invalide' })
}
return json(res, 404, { error: 'not found' })
}
// ── APP TECHNICIEN : capture passive du temps via « checkpoints » (GPS geofence / scan / etc.) ──────────────
// Token signé par job (HMAC, stateless, sans login ni champ DB). Voir docs/field-tech-app.md.
const FIELD_SECRET = cfg.INTERNAL_TOKEN || cfg.ERP_SERVICE_TOKEN || 'targo-field-v1'
const ON_SITE_M = Number(process.env.FIELD_ONSITE_RADIUS_M) || 250 // rayon « sur place » autour de l'adresse
function fieldSign (name) { const h = crypto.createHmac('sha256', FIELD_SECRET).update(String(name)).digest('hex').slice(0, 12); return Buffer.from(String(name)).toString('base64url') + '.' + h }
function fieldVerify (token) { try { const [b, h] = String(token || '').split('.'); if (!b || !h) return null; const name = Buffer.from(b, 'base64url').toString('utf8'); const exp = crypto.createHmac('sha256', FIELD_SECRET).update(name).digest('hex').slice(0, 12); return crypto.timingSafeEqual(Buffer.from(h), Buffer.from(exp)) ? name : null } catch (e) { return null } }
function haversineM (a1, o1, a2, o2) { const R = 6371000, t = Math.PI / 180; const dA = (a2 - a1) * t, dO = (o2 - o1) * t; const x = Math.sin(dA / 2) ** 2 + Math.cos(a1 * t) * Math.cos(a2 * t) * Math.sin(dO / 2) ** 2; return Math.round(R * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x))) }
// Token PAR TECH (préfixe 'T:') → l'app charge les jobs du jour du tech. Token Mapbox public (pk) pour la carte de l'app.
const FIELD_MAPBOX = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg'
function techFieldSign (name) { return fieldSign('T:' + name) }
function techFieldVerify (t) { const v = fieldVerify(t); return (v && v.startsWith('T:')) ? v.slice(2) : null }
function serveFieldApp (res) {
try { let html = require('fs').readFileSync('/app/public/field-app.html', 'utf8'); html = html.split('__MAPBOX_TOKEN__').join(FIELD_MAPBOX).split('__HUB__').join(cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca'); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(html) } catch (e) { res.writeHead(500); return res.end('app terrain indisponible') }
}
function savePhoto (jobName, dataUrl) {
const m = /^data:image\/(\w+);base64,(.+)$/.exec(String(dataUrl || '')); if (!m) return null
const buf = Buffer.from(m[2], 'base64'); if (!buf.length || buf.length > 6 * 1024 * 1024) return null
const fs = require('fs'); const dir = '/app/uploads/field'; try { fs.mkdirSync(dir, { recursive: true }) } catch (e) {}
const fn = 'f_' + String(jobName).replace(/[^a-zA-Z0-9_-]/g, '') + '_' + Date.now() + '.' + (m[1] === 'png' ? 'png' : 'jpg')
try { fs.writeFileSync(dir + '/' + fn, buf); return fn } catch (e) { return null }
}
async function handleFieldTech (req, res, method, path, url) {
const token = url.searchParams.get('t') || url.searchParams.get('token') || ''
if (path === '/field/job' && method === 'GET') {
const name = fieldVerify(token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['name', 'subject', 'customer_name', 'address', 'latitude', 'longitude', 'scheduled_date', 'actual_start', 'actual_end', 'status'], limit: 1 })
const j = r && r[0]; if (!j) return json(res, 404, { ok: false, error: 'job introuvable' })
return json(res, 200, { ok: true, job: { ...j, actual_minutes: minutesBetween(j.actual_start, j.actual_end) } })
}
if (path === '/field/checkpoint' && method === 'POST') {
const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['name', 'latitude', 'longitude', 'actual_start', 'actual_end', 'completion_notes', 'status'], limit: 1 })
const j = r && r[0]; if (!j) return json(res, 404, { ok: false, error: 'job introuvable' })
const ts = nowFrappe()
const lat = Number(b.lat), lon = Number(b.lon); const hasGps = isFinite(lat) && isFinite(lon)
let dist = null
if (hasGps && j.latitude != null && j.longitude != null && (Math.abs(+j.latitude) > 0.01)) dist = haversineM(+j.latitude, +j.longitude, lat, lon)
const onSite = (b.type === 'geo_exit') || dist == null || dist <= ON_SITE_M // hors-zone ignoré pour la durée, gardé en note
const patch = {}
if (onSite) { if (!j.actual_start) patch.actual_start = ts; patch.actual_end = ts; if (j.status === 'open' || j.status === 'assigned') patch.status = 'In Progress' }
const note = `${ts} ${b.type || 'checkpoint'}${b.ref ? ' ' + String(b.ref).slice(0, 40) : ''}${hasGps ? ` · GPS ${lat.toFixed(5)},${lon.toFixed(5)}${b.acc ? ' ±' + Math.round(b.acc) + 'm' : ''}${dist != null ? ` · à ${dist}m` : ''}` : ''}${onSite ? '' : ' · HORS ZONE (ignoré durée)'}`
patch.completion_notes = ((j.completion_notes || '') + '\n' + note).slice(-4000)
const up = await retryWrite(() => erp.update('Dispatch Job', name, patch))
const startTs = j.actual_start || (onSite ? ts : null)
return json(res, up.ok ? 200 : 500, { ok: !!up.ok, on_site: onSite, distance_m: dist, minutes: minutesBetween(startTs, onSite ? ts : j.actual_end), actual_start: patch.actual_start || j.actual_start || '', actual_end: patch.actual_end || j.actual_end || '' })
}
if ((path === '/field' || path === '/field/app') && method === 'GET') return serveFieldApp(res) // PWA technicien (carte + tickets + photos + scan)
if (path === '/field/tech' && method === 'GET') { // jobs du jour (+ demain) du tech, via token PAR TECH
const tech = techFieldVerify(token); if (!tech) return json(res, 403, { ok: false, error: 'lien invalide' })
const dates = rangeDates(todayET(), 2) // aujourd'hui + demain
const rows = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', tech], ['scheduled_date', 'in', dates]], fields: ['name', 'subject', 'customer_name', 'address', 'latitude', 'longitude', 'scheduled_date', 'start_time', 'status', 'actual_start', 'actual_end', 'legacy_ticket_id', 'legacy_activation_url'], 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 || '' }))
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', {
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress']]],
fields: ['name', 'subject', 'customer_name', 'service_type', 'job_type', 'legacy_dept', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'route_order', 'latitude', 'longitude', 'booking_status', 'legacy_detail', 'legacy_ticket_id', 'address', 'service_location', 'actual_start', 'actual_end'], 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)
o.h += dur
const skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject) // compétence → couleur du bloc (palette skills)
const s = j.start_time ? timeToH(j.start_time) : null
if (s != null) o.blocks.push({ s, e: s + dur, skill, job: j.name }) // 1 bloc = 1 job, coloré par sa compétence
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 || '', legacy_id: j.legacy_ticket_id || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: j.actual_start || '', actual_end: j.actual_end || '', actual_min: actualMin })
}
// 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 })
}
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 || {} })
}
// Générer le lien client (token) pour un job → URL publique /book?token=
if (path === '/roster/book/link' && method === 'POST') {
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
const job = await erp.get('Dispatch Job', b.job, { fields: ['name', 'booking_token'] })
if (!job) return json(res, 404, { error: 'job introuvable' })
let token = job.booking_token
if (!token) { token = crypto.randomBytes(12).toString('hex'); const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { booking_token: token })); if (!r.ok) return json(res, 500, r) }
return json(res, 200, { ok: true, token, url: (cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca') + '/book?token=' + token })
}
// Aviser le client d'un report : lien /book + SMS Twilio + statut « À reporter »
if (path === '/roster/job/notify-reschedule' && method === 'POST') {
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
const job = await erp.get('Dispatch Job', b.job, { fields: ['name', 'booking_token', 'customer', 'customer_name'] })
if (!job) return json(res, 404, { error: 'job introuvable' })
let token = job.booking_token
if (!token) { token = crypto.randomBytes(12).toString('hex'); await retryWrite(() => erp.update('Dispatch Job', b.job, { booking_token: token })) }
const url = (cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca') + '/book?token=' + token
// Désassigner (retour au pool) : on vide le créneau pour que /book repropose des options
await retryWrite(() => erp.update('Dispatch Job', b.job, { booking_status: 'À reporter', scheduled_date: null, start_time: null, assigned_tech: null, status: 'open' }))
let phone = b.phone
if (!phone && job.customer) { try { const c = await erp.get('Customer', job.customer, { fields: ['mobile_no'] }); phone = c && c.mobile_no } catch (e) {} }
if (!phone) return json(res, 200, { ok: true, url, sms: false, note: 'Statut « À reporter » posé. Aucun téléphone trouvé — fournir "phone" pour envoyer le SMS.' })
const msg = b.message || `Bonjour, votre rendez-vous Gigafibre doit être reporté. Choisissez un nouveau créneau qui vous convient : ${url} Merci de votre compréhension.`
let sid = null
try { sid = await require('./twilio').sendSmsInternal(phone, msg, job.customer) } catch (e) { return json(res, 200, { ok: true, url, sms: false, error: String(e.message || e) }) }
return json(res, 200, { ok: true, url, sms: !!sid, sid, phone })
}
// File « À reporter » (jobs à recontacter) — pour le superviseur
if (path === '/roster/jobs-to-reschedule' && method === 'GET') {
const rows = await erp.list('Dispatch Job', { filters: [['booking_status', '=', 'À reporter']], fields: ['name', 'customer_name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'assigned_tech', 'booking_token'], limit: 100 })
await attachLocations(rows || []) // adresse lisible
return json(res, 200, { jobs: rows || [] })
}
// Impact d'un retrait de compétence : jobs assignés à un tech qui EXIGENT cette compétence (devenus invalides).
if (path === '/roster/skill-impact' && method === 'GET') {
const tech = qs.get('tech'); const skill = qs.get('skill')
if (!tech || !skill) return json(res, 400, { error: 'tech + skill requis' })
const rows = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', tech], ['status', 'in', ['open', 'assigned']]], fields: ['name', 'customer_name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'start_time'], limit: 500 })
const impacted = (rows || []).filter(j => skillForJob(j) === skill)
await attachLocations(impacted)
return json(res, 200, { jobs: impacted })
}
// Impact d'une ABSENCE : jobs assignés à un tech sur des dates données (devenus à redistribuer).
if (path === '/roster/absence-impact' && method === 'GET') {
const tech = qs.get('tech'); const dates = (qs.get('dates') || '').split(',').filter(Boolean)
if (!tech || !dates.length) return json(res, 400, { error: 'tech + dates requis' })
const rows = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', tech], ['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned']]], fields: ['name', 'customer_name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'start_time'], limit: 500 })
await attachLocations(rows || [])
return json(res, 200, { jobs: rows || [] })
}
// Candidats CLASSÉS pour reprendre un job : techs qualifiés + LIBRES au même créneau (≠ l'impacté).
if (path === '/roster/job-candidates' && method === 'GET') {
const jobName = qs.get('job'); const exclude = qs.get('exclude') || ''
if (!jobName) return json(res, 400, { error: 'job requis' })
let job = null
try { job = await erp.get('Dispatch Job', jobName, { fields: ['name', 'scheduled_date', 'start_time', 'duration_h', 'service_type'] }) } catch (e) {}
if (!job || !job.scheduled_date || !job.start_time) return json(res, 200, { candidates: [], date: (job && job.scheduled_date) || '', start: '' })
const skill = skillForJob(job); const start = String(job.start_time).slice(0, 5); const dur = Number(job.duration_h) || 1
const slots = await bookingSlots({ skill, duration: dur, start: job.scheduled_date, days: 1, limit: 500, ignorePolicy: true })
const seen = new Set(); const cands = []
for (const s of slots) { if (s.start !== start || s.tech === exclude || seen.has(s.tech)) continue; seen.add(s.tech); cands.push({ tech: s.tech, tech_name: s.tech_name }) }
return json(res, 200, { candidates: cands.slice(0, 6), date: job.scheduled_date, start, skill })
}
// Redistribuer les jobs impactés. 3 voies : b.plan (réassign explicite par tech / requeue) · b.mode 'auto' (re-match
// auto au même créneau, compétence b.skill ou par job) · 'requeue' (À recontacter).
if (path === '/roster/skill-impact/redistribute' && method === 'POST') {
const b = await parseBody(req); const mode = b.mode || 'requeue'
if (Array.isArray(b.plan)) { // plan explicite : { job, tech } (réassigner) OU { job, requeue:true }
let reassigned = 0; let requeued = 0; let errors = 0
for (const p of b.plan) {
if (!p || !p.job) { errors++; continue }
if (p.tech && !p.requeue) { const r = await retryWrite(() => erp.update('Dispatch Job', p.job, { assigned_tech: p.tech, status: 'assigned', booking_status: 'Confirmé' })); if (r.ok) reassigned++; else errors++ }
else { const r = await retryWrite(() => erp.update('Dispatch Job', p.job, { booking_status: 'À reporter', scheduled_date: null, start_time: null, assigned_tech: null, status: 'open' })); if (r.ok) requeued++; else errors++ }
}
return json(res, 200, { ok: errors === 0, reassigned, requeued, errors })
}
let reassigned = 0; let requeued = 0; let errors = 0; const details = []
for (const jobName of (b.jobs || [])) {
let job = null
try { job = await erp.get('Dispatch Job', jobName, { fields: ['name', 'scheduled_date', 'start_time', 'duration_h', 'customer_name', 'service_type'] }) } catch (e) {}
if (!job) { errors++; continue }
if (mode === 'auto' && job.scheduled_date && job.start_time) { // re-match au même créneau, tech qualifié ≠ l'impacté
const sk = b.skill || skillForJob(job) // compétence requise (globale si fournie, sinon par job → cas absence)
const r = await confirmWindow(jobName, job.scheduled_date, String(job.start_time).slice(0, 5), Number(job.duration_h) || 1, sk)
if (r.ok) { reassigned++; details.push({ job: jobName, customer: job.customer_name, tech: r.tech, action: 'réassigné' }); continue }
}
const r = await retryWrite(() => erp.update('Dispatch Job', jobName, { booking_status: 'À reporter', scheduled_date: null, start_time: null, assigned_tech: null, status: 'open' }))
if (r.ok) { requeued++; details.push({ job: jobName, customer: job.customer_name, action: 'à recontacter' }) } else errors++
}
return json(res, 200, { ok: errors === 0, reassigned, requeued, errors, details })
}
// Jobs À ASSIGNER (non assignés) avec leur groupe/dépendances (parent_job, depends_on, step_order, chaîne On Hold).
if (path === '/roster/unassigned-jobs' && method === 'GET') {
const rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'subject', 'customer_name', 'service_location', 'service_type', 'job_type', 'legacy_dept', 'legacy_detail', 'legacy_ticket_id', 'legacy_activation_url', 'priority', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech', 'latitude', 'longitude', 'address'], orderBy: 'modified desc', limit: 400 })
const jobs = (rows || []).filter(j => !j.assigned_tech) // non assignés
const chars = readJobChar().items // table additive (1 lecture)
for (const j of jobs) {
j.required_skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject) // skill explicite, sinon déduit du type → couleur
const est = estimateForJob(j, chars) // auto-détection DÉTERMINISTE (mots-clés) → durée additive estimée
j.est_min = est.minutes; j.est_labels = est.labels
}
await attachLocations(jobs)
return json(res, 200, { jobs })
}
// Assigner un job à un tech (depuis le panneau flottant glisser-déposer) — date = case déposée.
// On pose AUSSI un start_time (premier trou libre du shift) : sans heure, le job compte dans
// les heures occupées mais n'affiche AUCUN bloc sur la timeline → la barre d'occupation semble figée.
if (path === '/roster/assign-job' && method === 'POST') {
const b = await parseBody(req); if (!b.job || !b.tech) return json(res, 400, { error: 'job + tech requis' })
let dur = 1
try { const jb = await erp.get('Dispatch Job', b.job, { fields: ['duration_h'] }); dur = Number(jb && jb.duration_h) || 1 } catch (e) {}
const patch = { assigned_tech: b.tech, status: 'assigned', duration_h: dur } // duration_h garanti → occupation comptée
if (b.date) patch.scheduled_date = b.date
let placed = null
if (b.start) patch.start_time = (String(b.start).length === 5 ? b.start + ':00' : b.start)
else if (b.date) { // premier trou libre dans le shift du tech ce jour-là
try { const d = await loadBookingData(b.date, 1); const h = firstFitStart(d, b.tech, b.date, dur); if (h != null) { placed = hToTime(h); patch.start_time = placed } } catch (e) {}
}
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, tech: b.tech, start_time: placed, duration_h: dur })
}
// Réordonner / re-prioriser les jobs d'un tech×jour (depuis le menu de la cellule).
// body.updates = [{ job, route_order, priority? }] — SÉQUENTIEL (frappe_pg).
if (path === '/roster/reorder-jobs' && method === 'POST') {
const b = await parseBody(req); const ups = b.updates || []
let ok = 0; let errors = 0
for (const u of ups) {
if (!u.job) continue
const patch = {}
if (u.route_order != null) patch.route_order = Number(u.route_order) || 0
if (u.priority) patch.priority = u.priority
if (u.duration_h != null && Number(u.duration_h) > 0) patch.duration_h = Number(u.duration_h) // durée éditée dans le timeline
if (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 }))
return json(res, r.ok ? 200 : 500, { ...r, job: b.job })
}
// 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 })
}
// CHRONO (boucle de capture) : début/fin réels → durée réelle (carburant de l'apprentissage des durées par type×tech).
if (path === '/roster/job/start' && method === 'POST') {
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
const now = nowFrappe()
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { actual_start: now, actual_end: '', status: 'In Progress' }))
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, actual_start: now })
}
if (path === '/roster/job/finish' && method === 'POST') {
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
const cur = await erp.list('Dispatch Job', { filters: [['name', '=', b.job]], fields: ['actual_start'], limit: 1 })
const startTs = (cur && cur[0] && cur[0].actual_start) || null
const now = nowFrappe()
const patch = { actual_end: now, status: 'Completed' }; if (!startTs) patch.actual_start = now // borne si jamais démarré
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, actual_minutes: minutesBetween(startTs || now, now) })
}
// Backfill : pose un start_time (premier trou libre) sur les jobs DÉJÀ assignés mais SANS heure
// → leurs blocs d'occupation apparaissent enfin sur la grille. Idempotent (ne touche que start_time vide).
if (path === '/roster/backfill-start-times' && method === 'POST') {
const b = await parseBody(req)
const start = b.start || todayET(); const days = Number(b.days) || 14
const dates = rangeDates(start, days)
const rows = await erp.list('Dispatch Job', {
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress']]],
fields: ['name', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h'], limit: 5000,
})
const todo = (rows || []).filter(j => j.assigned_tech && j.scheduled_date && !j.start_time)
const d = await loadBookingData(start, days) // chargé UNE fois ; on mute d.booked au fil de l'eau pour empiler
let placed = 0; const details = []
for (const j of todo) { // SÉQUENTIEL (frappe_pg ne supporte pas la concurrence)
const dur = Number(j.duration_h) || 1
const k = j.assigned_tech + '|' + j.scheduled_date
if (!d.booked[k]) d.booked[k] = []
const h = firstFitStart(d, j.assigned_tech, j.scheduled_date, dur)
if (h == null) { details.push({ job: j.name, skipped: 'pas de shift régulier ce jour-là' }); continue }
const r = await retryWrite(() => erp.update('Dispatch Job', j.name, { start_time: hToTime(h), duration_h: dur }))
if (r.ok) { d.booked[k].push({ s: h, e: h + dur }); placed++; details.push({ job: j.name, tech: j.assigned_tech, date: j.scheduled_date, start: hToTime(h) }) }
else details.push({ job: j.name, error: r.error || 'update failed' })
}
return json(res, 200, { ok: true, candidates: todo.length, placed, details })
}
// Hold : réserver temporairement une fenêtre (agent au tél. ou client qui sélectionne)
// → la fenêtre est retirée des dispos des autres pendant `minutes` (défaut politique).
if (path === '/roster/book/hold' && method === 'POST') {
const b = await parseBody(req)
if (!b.date || !b.start) return json(res, 400, { error: 'date + start requis' })
const key = b.date + '|' + (String(b.start).length >= 5 ? String(b.start).slice(0, 5) : b.start)
if (b.release) { releaseHold(key); return json(res, 200, { ok: true, released: true, key }) }
const minutes = b.minutes || getBookingPolicy().hold_minutes
return json(res, 200, { ok: true, key, holds: addHold(key, minutes), minutes })
}
// Fit : 3 dispos classées du client → 1er choix tenable, sinon proposer
if (path === '/roster/book/fit' && method === 'POST') {
const b = await parseBody(req)
return json(res, 200, await fitBooking({ skill: b.skill || '', zone: b.zone || '', duration: b.duration || 1, prefs: b.prefs || [] }))
}
// Confirmer un RDV sur un Dispatch Job existant
if (path === '/roster/book/confirm' && method === 'POST') {
const b = await parseBody(req)
if (!b.job) return json(res, 400, { error: 'job requis' })
const st = (b.start || '').length === 5 ? b.start + ':00' : b.start
const patch = { scheduled_date: b.date, start_time: st, status: 'assigned', booking_status: 'Confirmé', booking_prefs: JSON.stringify(b.prefs || []) }
if (b.tech) patch.assigned_tech = b.tech
if (b.duration) patch.duration_h = b.duration
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
if (r.ok && b.date && b.start) releaseHold(b.date + '|' + (String(b.start).slice(0, 5)))
return json(res, r.ok ? 200 : 500, r)
}
// Créer plusieurs besoins d'un coup (depuis l'éditeur de demande)
if (path === '/roster/requirements/bulk' && method === 'POST') {
const b = await parseBody(req); const errors = []; let created = 0
for (const rq of b.requirements || []) {
const r = await retryWrite(() => erp.create('Shift Requirement', {
requirement_date: rq.requirement_date, shift_template: rq.shift_template,
zone: rq.zone || '', required_count: rq.required_count || 1, required_skills: rq.required_skills || '',
}))
if (r.ok) created++; else errors.push(rq)
}
return json(res, 200, { ok: errors.length === 0, created, errors: errors.length })
}
// Vider les besoins d'une période (avant de ré-appliquer la demande)
if (path === '/roster/requirements/clear' && method === 'POST') {
const b = await parseBody(req)
const reqs = await fetchRequirements(b.start, b.days || 7)
let deleted = 0
for (const rq of reqs) { const r = await retryWrite(() => erp.remove('Shift Requirement', rq.name)); if (r.ok) deleted++ }
return json(res, 200, { ok: true, deleted })
}
if (path === '/roster/generate' && method === 'POST') {
const b = await parseBody(req)
if (!b.start) return json(res, 400, { error: 'start requis' })
try {
return json(res, 200, await generate(b.start, b.days || 7, b.weights))
} catch (e) {
return json(res, 502, { error: 'solveur injoignable ou erreur: ' + e.message })
}
}
if (path === '/roster/publish' && method === 'POST') {
const b = await parseBody(req)
return json(res, 200, await publish(b.assignments))
}
// Publier = réécrire la semaine (efface tout sur la période, recrée la grille).
// Idempotent + anti-doublons (contrairement au diff par case).
if (path === '/roster/publish-week' && method === 'POST') {
const b = await parseBody(req)
const existing = await fetchAssignments(b.start, b.days || 7)
const desired = b.assignments || []
// DIFF par clé tech|date|shift : on ne touche QUE ce qui a changé (≫ rapide vs wipe+recreate complet).
const keyOf = (a) => a.tech + '|' + a.date + '|' + a.shift
const existByKey = {}; for (const a of existing) existByKey[keyOf(a)] = a
const desiredKeys = new Set(desired.map(keyOf))
let deleted = 0; let created = 0; let errors = 0; let unchanged = 0
for (const a of existing) { // supprimer ceux qui ne sont plus voulus
if (desiredKeys.has(keyOf(a))) continue
const r = await retryWrite(() => erp.remove('Shift Assignment', a.name)); if (r.ok) deleted++
}
for (const a of desired) { // créer seulement les nouveaux ; ignorer les inchangés
if (existByKey[keyOf(a)]) { unchanged++; continue }
const r = await retryWrite(() => erp.create('Shift Assignment', {
technician: a.tech, technician_name: a.tech_name || '', assignment_date: a.date,
shift_template: a.shift, zone: a.zone || '', hours: Number(a.hours) || 0,
status: 'Publié', source: a.source || 'solveur',
}))
if (r.ok) created++; else errors++
}
let notified = 0
if (b.notify && created) { // SMS opt-in aux techs (Twilio) — non bloquant
try {
const techs = await fetchTechnicians()
const phoneById = Object.fromEntries(techs.map(t => [t.id, t.phone]))
const tplName = Object.fromEntries((await fetchTemplates()).map(t => [t.name, t.template_name || t.name]))
const byTech = {}
for (const a of (b.assignments || [])) (byTech[a.tech] || (byTech[a.tech] = [])).push(a)
const sendSms = require('./twilio').sendSmsInternal
for (const tid in byTech) {
const phone = phoneById[tid]; if (!phone) continue
const lines = byTech[tid].slice().sort((x, y) => x.date.localeCompare(y.date)).map(a => a.date.slice(5) + ' ' + (tplName[a.shift] || a.shift)).join(' · ')
try { await sendSms(phone, 'Targo — votre horaire publié : ' + lines); notified++ } catch (e) { /* skip ce tech */ }
}
} catch (e) { /* notif non bloquante */ }
}
return json(res, 200, { ok: errors === 0, created, deleted, errors, notified, unchanged })
}
// Garde : matérialiser la rotation sur un HORIZON (plusieurs semaines) — comme un évènement récurrent.
// Wipe ROBUSTE : on supprime TOUTE garde (tout template on_call) dans la plage, pas seulement les shifts
// courants — sinon une ancienne garde sous un autre nom de template survit et fausse la rotation
// (« la suite est bousillée »). Puis recréation depuis la rotation déterministe (= le calque live).
if (path === '/roster/garde/apply' && method === 'POST') {
const b = await parseBody(req)
const dates = rangeDates(b.start, (b.weeks || 1) * 7)
const tpls = await fetchTemplates()
const gardeTpls = tpls.filter(t => t.on_call).map(t => t.name)
let deleted = 0
if (gardeTpls.length && dates.length) {
const existing = await erp.list('Shift Assignment', { filters: [['shift_template', 'in', gardeTpls], ['assignment_date', 'in', dates]], fields: ['name'], limit: 5000 })
for (const a of existing) { const r = await retryWrite(() => erp.remove('Shift Assignment', a.name)); if (r.ok) deleted++ }
}
let created = 0; let errors = 0
for (const a of (b.assignments || [])) {
const r = await retryWrite(() => erp.create('Shift Assignment', {
technician: a.tech, technician_name: a.tech_name || '', assignment_date: a.date,
shift_template: a.shift, zone: a.zone || '', hours: Number(a.hours) || 0,
status: 'Publié', source: 'manuel',
}))
if (r.ok) created++; else errors++
}
return json(res, 200, { ok: errors === 0, created, deleted, errors })
}
// Modifier / supprimer un type de shift (Shift Template)
const mTpl = path.match(/^\/roster\/template\/(.+)$/)
if (mTpl && method === 'PUT') {
const name = decodeURIComponent(mTpl[1]); const b = await parseBody(req)
const patch = {}
for (const f of ['start_time', 'end_time', 'hours', 'color', 'zone', 'default_required', 'required_skills', 'active', 'on_call']) if (b[f] !== undefined) patch[f] = b[f]
const r = await retryWrite(() => erp.update('Shift Template', name, patch))
return json(res, r.ok ? 200 : 500, r)
}
if (mTpl && method === 'DELETE') {
const name = decodeURIComponent(mTpl[1]); const r = await retryWrite(() => erp.remove('Shift Template', name))
return json(res, r.ok ? 200 : 500, r)
}
if (path === '/roster/availability' && method === 'GET') {
const status = qs.get('status') || ''
const rows = await erp.list('Tech Availability', {
filters: status ? [['status', '=', status]] : [],
fields: ['name', 'technician', 'technician_name', 'availability_type', 'from_date', 'to_date', 'reason', 'status', 'approver'],
orderBy: 'modified desc', limit: 200,
})
return json(res, 200, { availability: rows })
}
if (path === '/roster/availability' && method === 'POST') {
const b = await parseBody(req)
const r = await erp.create('Tech Availability', {
technician: b.technician, technician_name: b.technician_name || '',
availability_type: b.availability_type || 'Congé', status: 'Demandé',
from_date: b.from_date, to_date: b.to_date, reason: b.reason || '',
long_term: b.long_term ? 1 : 0,
})
return json(res, r.ok ? 200 : 500, r)
}
const mApprove = path.match(/^\/roster\/availability\/(.+)\/approve$/)
if (mApprove && method === 'POST') {
const name = decodeURIComponent(mApprove[1])
const b = await parseBody(req)
const r = await retryWrite(() => erp.update('Tech Availability', name, { status: b.reject ? 'Refusé' : 'Approuvé', approver: b.approver || '' }))
return json(res, r.ok ? 200 : 500, r)
}
const mSkills = path.match(/^\/roster\/technician\/(.+)\/skills$/)
if (mSkills && method === 'POST') {
const techId = decodeURIComponent(mSkills[1]); const b = await parseBody(req)
const techName = await resolveTechName(techId)
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const patch = { skills: (b.skills || '').trim() }
if (b.skill_levels !== undefined) patch.skill_levels = typeof b.skill_levels === 'string' ? b.skill_levels : JSON.stringify(b.skill_levels || {}) // niveaux 15 par compétence (JSON)
if (b.skill_eff !== undefined) patch.skill_eff = typeof b.skill_eff === 'string' ? b.skill_eff : JSON.stringify(b.skill_eff || {}) // efficacité (facteur vitesse) PAR compétence (JSON)
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch))
return json(res, r.ok ? 200 : 500, { ...r, technician: techId })
}
const mCost = path.match(/^\/roster\/technician\/(.+)\/cost$/)
if (mCost && method === 'POST') {
const techId = decodeURIComponent(mCost[1]); const b = await parseBody(req)
const techName = await resolveTechName(techId)
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { cost_salary_h: Number(b.salary) || 0, cost_charges_pct: Number(b.charges) || 0, cost_other_h: Number(b.other) || 0 }))
return json(res, r.ok ? 200 : 500, { ...r, technician: techId })
}
const mEff = path.match(/^\/roster\/technician\/(.+)\/efficiency$/)
if (mEff && method === 'POST') {
const techId = decodeURIComponent(mEff[1]); const b = await parseBody(req)
const techName = await resolveTechName(techId)
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { efficiency: Number(b.efficiency) || 1 }))
return json(res, r.ok ? 200 : 500, { ...r, technician: techId, efficiency: Number(b.efficiency) || 1 })
}
const mPause = path.match(/^\/roster\/technician\/(.+)\/pause$/)
if (mPause && method === 'POST') {
const techId = decodeURIComponent(mPause[1])
const b = await parseBody(req)
// technician_id n'est pas le docname → retrouver le doc
const techName = await resolveTechName(techId)
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const patch = { status: b.paused ? PAUSE_STATUS : AVAIL_STATUS }
if (b.paused && b.reason) patch.absence_reason = b.reason
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch))
return json(res, r.ok ? 200 : 500, { ...r, technician: techId, status: patch.status })
}
// 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 }