gigafibre-fsm/services/targo-hub/lib/roster-assistant.js
louispaulb f1204ed459 roster(planif): assignation drag-drop + timeline ressource + occupation + nettoyage
Panneau « jobs à assigner » v2 : multi-sélection (cases), groupes parent-enfant
surlignés, heuristique terrain/à distance (activation/netadmin pré-décochés),
pré-total d'heures, aperçu d'occupation PROJETÉE au survol (barre fantôme + badge).

Fix barre d'occupation figée après drop : /roster/assign-job pose désormais un
start_time (premier-trou-libre dans le shift) + garantit duration_h, sinon le job
compte dans les heures mais n'affiche aucun bloc. Nouvel endpoint
/roster/backfill-start-times (idempotent) pour rattraper l'historique.

Infobulle de cellule : nb de jobs + liste triée par priorité (occupancyByTechDay
renvoie jobs[]). Timeline contextuelle par ressource (dialogue, 0 appel réseau).

Lisibilité du drag : fantôme compact semi-transparent décalé sous le curseur
(ne masque plus l'aperçu) + source estompée.

Scoring de priorité : hook proximité (neutre — secteur géré manuellement),
réservé à 20% du score quand la géoloc arrivera.

Refactor hub : helper partagé firstFitStart (assign-job + backfill).
Nettoyage : retrait code mort (onDeleteRosterTag, projUsedH), carte des sections
en tête de PlanificationPage. Doc : docs/features/roster.md + index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:50:17 -04:00

328 lines
19 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-assistant.js — Copilote de répartition (Gemini Flash, function-calling).
*
* Couche CONVERSATIONNELLE par-dessus le roster. À NE PAS confondre avec le
* « Roster AI » = solveur OR-Tools (optimisation sous contraintes). Ici on traduit
* du langage naturel ("Simon est malade aujourd'hui, quel impact ?") en :
* 1) appels d'outils de données réelles (équipe du jour, jobs d'un tech),
* 2) une réponse + des propositions de réassignation dans les créneaux dispo.
* Le copilote PROPOSE ; le superviseur confirme (pas d'écriture auto pour l'instant).
*
* Réutilise le pattern geminiChat de lib/agent.js (endpoint OpenAI-compat Gemini).
*
* Routes :
* POST /roster/assistant { message, history? } → { reply }
* GET /roster/policy → { policy, options }
* POST /roster/policy { ...politique } → { ok, policy }
*/
const fs = require('fs')
const path = require('path')
const cfg = require('./config')
const { json, parseBody } = require('./helpers')
const erp = require('./erp')
const roster = require('./roster')
// ── Politique de reprise (tech absent) — persistée fichier ──────────────────
const POLICY_FILE = path.join(__dirname, '..', 'data', 'dispatch-policy.json')
const DEFAULT_POLICY = {
reschedule: 'auto_then_sms_then_super', // réassign auto → sinon SMS client → sinon superviseur
sms_enabled: true,
sms_quiet_hours: true, // pas d'envoi 21h8h
sms_max: 1,
escalation: 'queue_sms', // file Ops + SMS superviseur
// #56 — créneaux offerts au booking (page /book + vue agent). Lu par lib/roster.js getBookingPolicy().
// skill_by_type : table type de job (service_type) → compétence requise. Le client ne voit que les
// créneaux des techs qui ont ce tag. '' = aucun tag requis (ex. ajout TV/borne WiFi → n'importe qui).
booking: { 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: {} },
}
const POLICY_OPTIONS = {
reschedule: [
{ value: 'auto_then_sms_then_super', label: 'Réassigner auto → sinon SMS client → sinon superviseur (recommandé)' },
{ value: 'ask_client_first', label: 'Toujours demander au client (SMS lien de report) en premier' },
{ value: 'escalate_first', label: 'Toujours escalader au superviseur en premier' },
],
escalation: [
{ value: 'queue', label: 'File « À recontacter » dans Ops seulement' },
{ value: 'queue_sms', label: 'File Ops + SMS au superviseur' },
],
// #56 — réglages des créneaux offerts (rendus en champs dans l'UI)
booking_fields: [
{ key: 'lead_hours', label: 'Délai minimum avant un RDV', unit: 'heures', min: 0, max: 168, hint: 'Aucun créneau offert avant ce délai (ex. 24 h pour préparer la tournée).' },
{ key: 'day_start', label: 'Première heure offerte', unit: 'h', min: 0, max: 23 },
{ key: 'day_end', label: 'Dernière heure offerte', unit: 'h', min: 1, max: 24 },
{ key: 'horizon_days', label: 'Fenêtre de réservation', unit: 'jours', min: 1, max: 90, hint: 'Jusquà combien de jours à lavance on propose.' },
{ key: 'max_per_day', label: 'Max de créneaux affichés / jour', unit: '0 = illimité', min: 0, max: 50 },
{ key: 'hold_minutes', label: 'Réservation temporaire (hold)', unit: 'minutes', min: 1, max: 60, hint: 'Durée pendant laquelle un créneau sélectionné est bloqué pour les autres.' },
],
weekdays: [{ v: 1, l: 'Lun' }, { v: 2, l: 'Mar' }, { v: 3, l: 'Mer' }, { v: 4, l: 'Jeu' }, { v: 5, l: 'Ven' }, { v: 6, l: 'Sam' }, { v: 0, l: 'Dim' }],
}
function getPolicy () {
try { const f = JSON.parse(fs.readFileSync(POLICY_FILE, 'utf8')); return { ...DEFAULT_POLICY, ...f, booking: { ...DEFAULT_POLICY.booking, ...(f.booking || {}) } } }
catch { return { ...DEFAULT_POLICY } }
}
function setPolicy (p) {
const cur = getPolicy()
const next = { ...cur, ...p }
if (p && p.booking) next.booking = { ...cur.booking, ...p.booking } // fusion fine des réglages booking
try { fs.mkdirSync(path.dirname(POLICY_FILE), { recursive: true }) } catch {}
fs.writeFileSync(POLICY_FILE, JSON.stringify(next, null, 2))
return next
}
// ── Outils de données (réels, lecture) ──────────────────────────────────────
async function jobsOnDate (date, techId) {
const filters = [['scheduled_date', '=', date]]
if (techId) filters.push(['assigned_tech', '=', techId])
return (await erp.list('Dispatch Job', {
filters, fields: ['name', 'assigned_tech', 'service_location', 'start_time', 'duration_h', 'status', 'booking_status'],
limit: 200,
})) || []
}
async function tool_etat_equipe ({ date }) {
const techs = await roster.fetchTechnicians()
const jobs = await jobsOnDate(date)
const load = {}
for (const j of jobs) load[j.assigned_tech] = (load[j.assigned_tech] || 0) + 1
return techs.map(t => ({
id: t.id, nom: t.name || t.id, statut: t.status,
competences: t.skills || [], groupe: t.group || null,
jobs_ce_jour: load[t.id] || 0,
}))
}
async function tool_jobs_du_technicien ({ technicien_id, date }) {
const jobs = await jobsOnDate(date, technicien_id)
return jobs.map(j => ({
job: j.name, lieu: j.service_location, debut: j.start_time, duree_h: j.duration_h,
statut: j.status, rdv: j.booking_status,
}))
}
const TOOLS = [
{
type: 'function',
function: {
name: 'etat_equipe',
description: "État de l'équipe pour une date : tous les techniciens avec statut (Disponible/En pause), compétences, groupe/zone, et nombre de jobs déjà assignés ce jour-là (charge). Sert à voir qui est disponible et qui a de la capacité.",
parameters: { type: 'object', properties: { date: { type: 'string', description: 'Date AAAA-MM-JJ' } }, required: ['date'] },
},
},
{
type: 'function',
function: {
name: 'jobs_du_technicien',
description: "Liste des rendez-vous/installations assignés à un technicien pour une date donnée (l'impact d'une absence). Donne lieu, heure, durée, statut.",
parameters: { type: 'object', properties: { technicien_id: { type: 'string' }, date: { type: 'string', description: 'AAAA-MM-JJ' } }, required: ['technicien_id', 'date'] },
},
},
{
type: 'function',
function: {
name: 'gerer_absence',
description: "ACTION PRINCIPALE quand un tech est absent/malade/ne peut se présenter. Fait TOUT d'un coup : (1) crée l'absence approuvée pour la date (le retire des créneaux ce jour), (2) trouve ses RDV impactés, (3) RÉASSIGNE automatiquement chaque RDV à un autre tech libre du même créneau, (4) renvoie ceux sans couverture (à reporter). À appeler dès qu'on te signale une absence (ex: « Simon est absent aujourd'hui »), date = aujourd'hui si non précisée.",
parameters: { type: 'object', properties: { technicien_id: { type: 'string', description: 'id du tech (ex TECH-4693), obtenu via etat_equipe' }, date: { type: 'string', description: 'AAAA-MM-JJ (defaut = date du jour)' }, type: { type: 'string', enum: ['Maladie', 'Congé', 'Indisponible'], description: 'defaut Maladie' } }, required: ['technicien_id'] },
},
},
{
type: 'function',
function: {
name: 'ajouter_disponibilite',
description: "ACTION : un technicien (souvent payé à l'acte) se rend DISPONIBLE et ajoute un créneau pour une date → ouvre de nouveaux créneaux au booking. À appeler quand on te dit « X est dispo demain » / « ajoute X tel jour ».",
parameters: { type: 'object', properties: { technicien_id: { type: 'string' }, date: { type: 'string', description: 'AAAA-MM-JJ' }, shift_template: { type: 'string', description: 'optionnel — nom du modèle de shift (sinon un modèle Jour par défaut)' } }, required: ['technicien_id', 'date'] },
},
},
{
type: 'function',
function: {
name: 'marquer_indisponibilite',
description: "ACTION (bas niveau) : crée seulement une absence approuvée, SANS calculer l'impact ni réassigner. Préférer gerer_absence dans la plupart des cas.",
parameters: { type: 'object', properties: { technicien_id: { type: 'string' }, date: { type: 'string', description: 'AAAA-MM-JJ' }, type: { type: 'string', enum: ['Maladie', 'Congé', 'Indisponible', 'Pause'], description: 'défaut Maladie' } }, required: ['technicien_id', 'date'] },
},
},
{
type: 'function',
function: {
name: 'reassigner_job',
description: "ACTION : réassigne un rendez-vous/installation (Dispatch Job) à un autre technicien. UNIQUEMENT sur instruction explicite (ex: « réassigne DJ-… à TECH-… »).",
parameters: { type: 'object', properties: { job: { type: 'string' }, technicien_id: { type: 'string' } }, required: ['job', 'technicien_id'] },
},
},
{
type: 'function',
function: {
name: 'notifier_client_report',
description: "ACTION : avise le client qu'un rendez-vous doit être reporté — envoie un SMS avec un lien pour qu'il choisisse un nouveau créneau, et passe le job en « À reporter ». Sur instruction explicite. Fournir job (et phone si connu).",
parameters: { type: 'object', properties: { job: { type: 'string' }, phone: { type: 'string' } }, required: ['job'] },
},
},
]
// ── Outils d'ACTION (écriture, sur instruction explicite) ───────────────────
async function tool_marquer_indispo ({ technicien_id, date, type }) {
const r = await erp.create('Tech Availability', {
technician: technicien_id, from_date: date, to_date: date,
availability_type: type || 'Maladie', status: 'Approuvé', reason: 'Copilote',
})
if (r && r.ok === false) return { error: r.error || 'échec' }
return { ok: true, message: `${technicien_id} marqué ${type || 'Maladie'} le ${date} (absence approuvée — retiré du planning)` }
}
async function tool_reassigner ({ job, technicien_id }) {
const r = await erp.update('Dispatch Job', job, { assigned_tech: technicien_id })
if (r && r.ok === false) return { error: r.error || 'échec' }
return { ok: true, message: `${job} réassigné à ${technicien_id}` }
}
async function tool_notifier_client ({ job, phone }) {
// Réutilise l'endpoint hub (même process) pour le lien /book + SMS + statut.
const r = await fetch('http://localhost:3300/roster/job/notify-reschedule', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job, phone }),
}).then(x => x.json()).catch(e => ({ ok: false, error: String(e.message || e) }))
return r
}
// ACTION COMBINÉE : tech absent → crée l'absence (par jour) + calcule l'impact +
// réassigne automatiquement chaque RDV à un autre tech LIBRE du même créneau (IROPS),
// sinon le marque « à reporter » (le superviseur décidera d'aviser le client).
async function tool_gerer_absence ({ technicien_id, date, type }) {
const today = new Date().toISOString().slice(0, 10)
const d = date || today
const av = await erp.create('Tech Availability', {
technician: technicien_id, from_date: d, to_date: d,
availability_type: type || 'Maladie', status: 'Approuvé', reason: 'Copilote',
})
if (av && av.ok === false) return { error: av.error || 'échec création absence' }
const jobs = (await erp.list('Dispatch Job', {
filters: [['assigned_tech', '=', technicien_id], ['scheduled_date', '=', d]],
fields: ['name', 'service_location', 'start_time', 'duration_h', 'booking_status'], limit: 200,
})) || []
const planifies = jobs.filter(j => j.start_time)
const reassignes = []; const a_reporter = []
for (const j of planifies) {
const start = (j.start_time || '').slice(0, 5); const dur = Number(j.duration_h) || 1
// bookingSlots exclut déjà le tech absent (absence approuvée ci-dessus) → cherche un autre tech libre ce créneau
const slots = await roster.bookingSlots({ duration: dur, start: d, days: 1, limit: 300, ignorePolicy: true })
const cand = slots.find(s => s.start === start && s.tech !== technicien_id)
if (cand) {
const u = await erp.update('Dispatch Job', j.name, { assigned_tech: cand.tech })
if (u && u.ok === false) a_reporter.push({ job: j.name, lieu: j.service_location, heure: start, raison: 'échec réassignation' })
else reassignes.push({ job: j.name, lieu: j.service_location, heure: start, vers: cand.tech_name || cand.tech })
} else {
a_reporter.push({ job: j.name, lieu: j.service_location, heure: start, raison: 'aucun tech libre ce créneau' })
}
}
return {
ok: true, technicien: technicien_id, date: d, type: type || 'Maladie',
jobs_impactes: planifies.length, reassignes, a_reporter,
message: `${technicien_id} marqué « ${type || 'Maladie'} » le ${d} (retiré des créneaux ce jour). ${planifies.length} RDV impacté(s) : ${reassignes.length} réassigné(s) automatiquement à un autre tech, ${a_reporter.length} sans couverture (à reporter — proposer d'aviser le client).`,
}
}
// ACTION : un tech à l'acte (gig) se rend disponible → ajoute un créneau (Shift Assignment
// publié) sur une date → de nouveaux créneaux apparaissent immédiatement au booking.
async function tool_ajouter_disponibilite ({ technicien_id, date, shift_template }) {
const tpls = await roster.fetchTemplates()
if (!tpls || !tpls.length) return { error: 'aucun modèle de shift actif — créez-en un dans Planification' }
const tpl = (shift_template && tpls.find(t => t.name === shift_template || t.template_name === shift_template)) ||
tpls.find(t => /jour|day/i.test(t.template_name || '')) || tpls[0]
const r = await erp.create('Shift Assignment', {
technician: technicien_id, assignment_date: date,
shift_template: tpl.name, zone: tpl.zone || '', hours: tpl.hours || 8,
status: 'Publié', source: 'Copilote',
})
if (r && r.ok === false) return { error: r.error || 'échec' }
return { ok: true, message: `${technicien_id} ajouté en disponibilité le ${date} sur « ${tpl.template_name || tpl.name} » (${tpl.start_time || '?'}${tpl.end_time || '?'}) — créneaux ouverts au booking.` }
}
async function execTool (name, args) {
try {
if (name === 'etat_equipe') return await tool_etat_equipe(args)
if (name === 'jobs_du_technicien') return await tool_jobs_du_technicien(args)
if (name === 'gerer_absence') return await tool_gerer_absence(args)
if (name === 'ajouter_disponibilite') return await tool_ajouter_disponibilite(args)
if (name === 'marquer_indisponibilite') return await tool_marquer_indispo(args)
if (name === 'reassigner_job') return await tool_reassigner(args)
if (name === 'notifier_client_report') return await tool_notifier_client(args)
return { error: 'outil inconnu' }
} catch (e) { return { error: String(e.message || e) } }
}
// ── Appel Gemini (OpenAI-compat) — même config que agent.js ─────────────────
async function geminiChat (messages) {
const url = `${cfg.AI_BASE_URL}chat/completions`
const body = { model: cfg.AI_MODEL, max_tokens: 700, messages, tools: TOOLS }
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.AI_API_KEY}` },
body: JSON.stringify(body),
})
if (res.status === 429 && attempt < 2) { await new Promise(r => setTimeout(r, (attempt + 1) * 1500)); continue }
if (!res.ok) throw new Error(`Gemini ${res.status}: ${(await res.text().catch(() => '')).slice(0, 200)}`)
return await res.json()
}
}
function systemPrompt () {
const today = new Date().toISOString().slice(0, 10)
const pol = getPolicy()
return `Tu es le COPILOTE DE RÉPARTITION (dispatch) de Gigafibre/TARGO, fournisseur Internet/TV/téléphonie au Québec.
Aujourd'hui = ${today}. Politique de reprise active : ${pol.reschedule}.
Tu aides le superviseur à gérer les techniciens et les rendez-vous d'installation/service.
Tu es ORIENTÉ ACTION : quand on te signale un fait, tu AGIS (tu n'accuses pas seulement réception).
RÈGLES :
- Utilise TOUJOURS les outils pour les données réelles (ne devine pas). Déduis la date = aujourd'hui si non précisée.
- ⚠️ Signaler une ABSENCE = une INSTRUCTION D'EXÉCUTION. Dès qu'on dit qu'un tech est absent/malade/ne peut venir :
1) etat_equipe(date) pour trouver son id (par son nom),
2) appelle IMMÉDIATEMENT gerer_absence(technicien_id, date) — qui crée l'absence, réassigne ses RDV aux techs libres, et renvoie ceux sans couverture.
Ne te contente JAMAIS de dire « c'est noté » sans appeler gerer_absence.
- Un tech qui se rend DISPONIBLE / qu'on veut ajouter un jour = appelle ajouter_disponibilite(technicien_id, date).
- Après gerer_absence : résume ce qui a été RÉASSIGNÉ (job → tech) et liste les RDV « à reporter ». Pour ceux-là, PROPOSE d'aviser le client (notifier_client_report) mais ne l'exécute QUE si le superviseur dit oui (un SMS part au client).
- reassigner_job (changer le tech d'un RDV précis) sur demande. Si tu ne trouves pas le tech nommé, dis-le et liste les proches.
- Confirme TOUJOURS l'action réalisée avec les vrais ids/noms. N'invente jamais une action non effectuée.
- Réponds en français, bref et actionnable (puces).`
}
async function assistant (message, history = []) {
if (!cfg.AI_API_KEY) return { reply: "Le service IA n'est pas configuré (AI_API_KEY manquante)." }
const messages = [{ role: 'system', content: systemPrompt() }]
for (const m of (history || []).slice(-8)) {
if (m.role === 'user' || m.role === 'assistant') messages.push({ role: m.role, content: String(m.content || '') })
}
messages.push({ role: 'user', content: String(message || '') })
let response = await geminiChat(messages)
const cur = [...messages]
for (let i = 0; i < 5; i++) {
const choice = response.choices?.[0]
if (!choice || choice.finish_reason !== 'tool_calls') break
const calls = choice.message?.tool_calls
if (!calls?.length) break
cur.push(choice.message)
for (const tc of calls) {
const args = (() => { try { return JSON.parse(tc.function.arguments || '{}') } catch { return {} } })()
const result = await execTool(tc.function.name, args)
cur.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result).slice(0, 6000) })
}
response = await geminiChat(cur)
}
return { reply: response.choices?.[0]?.message?.content || 'Aucune réponse.' }
}
async function handle (req, res, method, path) {
if (path === '/roster/assistant' && method === 'POST') {
const b = await parseBody(req)
try { return json(res, 200, await assistant(b.message, b.history)) }
catch (e) { return json(res, 200, { reply: 'Erreur copilote : ' + (e.message || e) }) }
}
if (path === '/roster/policy' && method === 'GET') return json(res, 200, { policy: getPolicy(), options: POLICY_OPTIONS })
if (path === '/roster/policy' && method === 'POST') {
const b = await parseBody(req)
return json(res, 200, { ok: true, policy: setPolicy(b || {}) })
}
return json(res, 404, { error: 'not found' })
}
module.exports = { handle, assistant, getPolicy, setPolicy }