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>
328 lines
19 KiB
JavaScript
328 lines
19 KiB
JavaScript
'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 21h–8h
|
||
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 à l’avance 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 }
|