diff --git a/services/targo-hub/lib/roster-assistant.js b/services/targo-hub/lib/roster-assistant.js index 4a6ccfc..a1d6a50 100644 --- a/services/targo-hub/lib/roster-assistant.js +++ b/services/targo-hub/lib/roster-assistant.js @@ -115,11 +115,27 @@ const TOOLS = [ 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 : rend un technicien indisponible (maladie/congé) pour une date — crée une absence APPROUVÉE qui le retire du planning. À utiliser UNIQUEMENT sur instruction explicite (ex: « Simon est malade aujourd'hui, marque-le »).", + 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'] }, }, }, @@ -164,10 +180,65 @@ async function tool_notifier_client ({ job, phone }) { 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) @@ -197,12 +268,17 @@ function systemPrompt () { 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 obtenir les données réelles avant de répondre (ne devine pas). -- Quand on signale un tech absent/malade : appelle etat_equipe(date) pour repérer le tech (par son nom) et la dispo des autres, puis jobs_du_technicien(id, date) pour l'impact. -- Propose CONCRÈTEMENT : pour chaque job touché, suggère 1–2 techniciens candidats (Disponible, compétences compatibles, charge faible le même jour) OU un report. Mentionne les ids/noms réels. -- Si tu ne trouves pas le tech nommé, dis-le et liste les techniciens proches. -- Par défaut tu ANALYSES et PROPOSES. Mais tu peux EXÉCUTER sur instruction explicite : marquer_indisponibilite (rendre un tech absent un jour donné, ex « marque Simon malade aujourd'hui »), reassigner_job (changer le tech d'un RDV), et notifier_client_report (aviser le client par SMS qu'un RDV doit être reporté, avec lien pour choisir un nouveau créneau). Confirme TOUJOURS l'action réalisée. N'invente jamais une action non effectuée. +- 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).` } diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index 04072ba..9d1194b 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -288,6 +288,7 @@ async function loadBookingData (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', { @@ -300,12 +301,13 @@ async function loadBookingData (start, days) { 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 } + 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 @@ -737,4 +739,4 @@ async function handle (req, res, method, path, url) { return json(res, 404, { error: 'roster: route inconnue ' + path }) } -module.exports = { handle, handlePublicBooking, generate, publish, coverage, fetchTechnicians } +module.exports = { handle, handlePublicBooking, generate, publish, coverage, fetchTechnicians, fetchTemplates, bookingSlots }