Copilote: agit vraiment sur les absences (gerer_absence + IROPS rematch) + gig dispo
Bug: le copilote comprenait l'absence mais n'agissait pas → aucun impact. Causes: 1) prompt « par défaut analyse » → ne déclenchait pas l'action ; 2) marquer une absence n'excluait PAS des créneaux (techGaps ne testait que le statut global En pause, pas les Tech Availability approuvées par jour) ; 3) loadBookingData calculait unavail mais ne le retournait pas (oubli) → garde inerte. Fixes: - roster.js: loadBookingData inclut unavail (buildUnavailability = En pause + absence_from/until + Tech Availability approuvées) ; techGaps exclut le tech absent ce jour-là ; export bookingSlots/fetchTemplates. - roster-assistant.js: nouvel outil gerer_absence = crée l'absence (par jour) + trouve les RDV impactés + RÉASSIGNE auto à un autre tech libre du même créneau (IROPS), renvoie les « à reporter ». Nouvel outil ajouter_disponibilite (tech à l'acte ouvre un créneau). Prompt orienté ACTION (signaler une absence = instruction d'exécution). - Validé prod (lab): copilote crée l'absence ✓, booking exclut le tech absent (109→104) ✓, rematch DJ-…→Antoine même créneau ✓ ; données de test nettoyées. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
43c67e3a18
commit
88b2702489
|
|
@ -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).`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user