From b99cbdd6cd2ecef7c491962610242a1b00c144fa Mon Sep 17 00:00:00 2001 From: louispaulb Date: Sun, 19 Jul 2026 14:51:51 -0400 Subject: [PATCH] =?UTF-8?q?feat(dispatch):=20NL=20=C2=AB=20Proposer=20un?= =?UTF-8?q?=20RDV=20au=20client=20=C2=BB=20(agent=E2=86=92client,=20sur=20?= =?UTF-8?q?staff-agent=20d=C3=A9ploy=C3=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pont agent→client vers la page /book livrée : au lieu d'assigner soi-même, le staff laisse le CLIENT choisir. - Hub POST /roster/book/propose : résout le job ({job}|{ticket}|{customer}) → assure booking_token → envoie le lien /book par texto (formulation « nouveau RDV »). 404 « créez d'abord l'intervention » si aucun job ouvert. - Outil NL proposer_rdv_client (agent-tools.json + WRITES staff-agent.js, plan→confirm→run) → l'agent le déclenche depuis ⌘K en langage naturel. staff-agent était DÉJÀ déployé (assign_tech direct + find_slot existaient déjà). Vérifié live : outil listé dans /staff-agent/tools ; /roster/book/propose répond (404 attendu sans job). Reste (différé, nécessite édition des parents co-édités → deploy-only) : bouton « Proposer au client » in-ticket/conversation. Co-Authored-By: Claude Fable 5 --- services/targo-hub/lib/agent-tools.json | 1 + services/targo-hub/lib/roster.js | 23 +++++++++++++++++++++++ services/targo-hub/lib/staff-agent.js | 23 ++++++++++++++++++++++- 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/services/targo-hub/lib/agent-tools.json b/services/targo-hub/lib/agent-tools.json index 27f3bfa..d25c8cb 100644 --- a/services/targo-hub/lib/agent-tools.json +++ b/services/targo-hub/lib/agent-tools.json @@ -22,6 +22,7 @@ { "audience": "staff", "mode": "write", "permission": "assign_jobs", "title": "Ajouter un assistant", "type": "function", "function": { "name": "add_assistant", "description": "STAFF/dispatch. Ajoute un technicien en renfort (assistant) sur un job — le tech assigné (lead) reste inchangé. ÉCRITURE — confirmée avant exécution.", "parameters": { "type": "object", "properties": { "job": { "type": "string", "description": "Identifiant du Dispatch Job" }, "tech_id": { "type": "string", "description": "id du technicien assistant (TECH-xxxx)" }, "tech_name": { "type": "string", "description": "Nom du technicien (optionnel, pour l'affichage)" } }, "required": ["job", "tech_id"] } } }, { "audience": "staff", "mode": "write", "permission": "assign_jobs", "title": "Changer le statut", "type": "function", "function": { "name": "set_job_status", "description": "STAFF/dispatch. Change le statut d'un Dispatch Job. ÉCRITURE — confirmée avant exécution. Cancelled = annuler l'intervention (conséquent).", "parameters": { "type": "object", "properties": { "job": { "type": "string", "description": "Identifiant du Dispatch Job" }, "status": { "type": "string", "enum": ["open", "On Hold", "Cancelled", "assigned"], "description": "Nouveau statut" } }, "required": ["job", "status"] } } }, { "audience": "staff", "mode": "write", "permission": "assign_jobs", "title": "Reporter le rendez-vous", "type": "function", "function": { "name": "reschedule_job", "description": "STAFF/dispatch. Reporte un Dispatch Job à une nouvelle date (et heure). Conserve le technicien assigné si possible. ÉCRITURE — confirmée avant exécution.", "parameters": { "type": "object", "properties": { "job": { "type": "string", "description": "Identifiant du Dispatch Job" }, "date": { "type": "string", "description": "Nouvelle date AAAA-MM-JJ" }, "start": { "type": "string", "description": "Nouvelle heure HH:MM (optionnel)" } }, "required": ["job", "date"] } } }, + { "audience": "staff", "mode": "write", "permission": "assign_jobs", "title": "Proposer un RDV au client", "type": "function", "function": { "name": "proposer_rdv_client", "description": "STAFF/dispatch. Envoie au CLIENT un lien (par texto) pour qu'il choisisse LUI-MÊME son rendez-vous : soit il prend un créneau proposé (réservé 5 min), soit il propose ses 3 disponibilités. À utiliser après un diagnostic quand une intervention est requise mais qu'on laisse le client choisir l'heure, plutôt que d'assigner soi-même un technicien. ÉCRITURE — confirmée avant exécution. Requiert un Dispatch Job OUVERT : fournis job, ou ticket (Issue source), ou customer_id pour le retrouver.", "parameters": { "type": "object", "properties": { "job": { "type": "string", "description": "Identifiant du Dispatch Job (optionnel si ticket/customer_id fourni)" }, "ticket": { "type": "string", "description": "Ticket/Issue source (optionnel) — retrouve le job ouvert lié" }, "customer_id": { "type": "string", "description": "ID client (optionnel) — retrouve son dernier job ouvert" }, "message": { "type": "string", "description": "Message texto personnalisé (optionnel)" } }, "required": [] } } }, { "audience": "staff", "mode": "write", "permission": "assign_jobs", "title": "Créer un horaire récurrent", "type": "function", "function": { "name": "create_recurring_shift", "description": "STAFF/planification. Définit le QUART RÉCURRENT (horaire hebdomadaire) d'un technicien — matérialisé automatiquement en assignations sur l'horizon. ÉCRITURE — confirmée avant exécution. days = jours travaillés parmi mon,tue,wed,thu,fri,sat,sun. « jours de semaine » = mon,tue,wed,thu,fri ; retire les exceptions demandées (ex. « sauf le vendredi » → mon,tue,wed,thu). « fin de semaine » = sat,sun. Convertis « 8-16h » en start 08:00 / end 16:00.", "parameters": { "type": "object", "properties": { "technicien_id": { "type": "string", "description": "id du technicien (TECH-xxxx), via list_technicians" }, "days": { "type": "array", "items": { "type": "string" }, "description": "Jours travaillés: mon,tue,wed,thu,fri,sat,sun" }, "start": { "type": "string", "description": "Heure de début HH:MM, ex. 08:00" }, "end": { "type": "string", "description": "Heure de fin HH:MM, ex. 16:00" } }, "required": ["technicien_id", "days", "start", "end"] } } }, { "audience": "staff", "mode": "write", "title": "Suivre", "type": "function", "function": { "name": "follow_doc", "description": "STAFF. (Dé)suivre un job ou un ticket pour recevoir les notifications de mise à jour (l'utilisateur connecté devient abonné). ÉCRITURE légère — confirmée avant exécution.", "parameters": { "type": "object", "properties": { "doctype": { "type": "string", "enum": ["Dispatch Job", "Issue"], "description": "Type de document à suivre" }, "name": { "type": "string", "description": "Identifiant du job ou du ticket" }, "follow": { "type": "boolean", "description": "true = suivre (défaut), false = ne plus suivre" } }, "required": ["doctype", "name"] } } } ] diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index 027e1a5..6752b4b 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -1720,6 +1720,29 @@ async function handle (req, res, method, path, url) { 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 }) } + // PROPOSER un RDV au client (nouveau, pas un report) : envoie le lien /book → le client CHOISIT un créneau OU + // PROPOSE 3 dispos. C'est le pont agent→client (le staff diagnostique, le client se planifie tout seul). + // Résout le job depuis {job} | {source_issue|ticket} | {customer} (1er job ouvert) → 404 sinon (« créez d'abord l'intervention »). + if (path === '/roster/book/propose' && method === 'POST') { + const b = await parseBody(req) + let jobName = b.job + if (!jobName && (b.source_issue || b.ticket)) { const rows = await erp.list('Dispatch Job', { filters: [['source_issue', '=', b.source_issue || b.ticket], ['status', 'in', ['open', 'assigned']]], fields: ['name'], limit: 1 }); jobName = rows[0] && rows[0].name } + if (!jobName && b.customer) { const rows = await erp.list('Dispatch Job', { filters: [['customer', '=', b.customer], ['status', 'in', ['open', 'assigned']]], fields: ['name'], order_by: 'creation desc', limit: 1 }); jobName = rows[0] && rows[0].name } + if (!jobName) return json(res, 404, { ok: false, error: 'aucun job ouvert à proposer — créez d\'abord l\'intervention' }) + const job = await erp.get('Dispatch Job', jobName, { fields: ['name', 'booking_token', 'customer', 'customer_name', 'scheduled_date'] }) + if (!job) return json(res, 404, { ok: false, error: 'job introuvable' }) + let token = job.booking_token + if (!token) { token = crypto.randomBytes(12).toString('hex'); await retryWrite(() => erp.update('Dispatch Job', jobName, { booking_token: token })) } + const url = (cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca') + '/book?token=' + token + if (!job.scheduled_date) { await retryWrite(() => erp.update('Dispatch Job', jobName, { booking_status: 'À reporter' })) } // = file « à planifier avec le client » + 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, job: jobName, url, sms: false, note: 'Lien prêt (aucun téléphone trouvé — copiez-le ou fournissez "phone").' }) + const msg = b.message || `Bonjour, Gigafibre souhaite planifier votre rendez-vous technique. Choisissez le créneau qui vous convient — ou proposez vos disponibilités : ${url} Merci !` + let sid = null + try { sid = await require('./twilio').sendSmsInternal(phone, msg, job.customer) } catch (e) { return json(res, 200, { ok: true, job: jobName, url, sms: false, error: String(e.message || e) }) } + return json(res, 200, { ok: true, job: jobName, 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 }) diff --git a/services/targo-hub/lib/staff-agent.js b/services/targo-hub/lib/staff-agent.js index 1ab5608..9f54946 100644 --- a/services/targo-hub/lib/staff-agent.js +++ b/services/targo-hub/lib/staff-agent.js @@ -154,6 +154,27 @@ const WRITES = { return hubResult(await hubCall('POST', '/roster/assign-job', body, email), `${p.job} assigné à ${p.tech}`) }, }, + proposer_rdv_client: { + permission: 'assign_jobs', + async build (p) { + const ref = p.job || p.ticket || p.customer_id || 'le job ouvert du client' + const params = { job: p.job || '', ticket: p.ticket || '', customer: p.customer_id || '', message: p.message || '' } + return { title: META.proposer_rdv_client.title, preview: `Envoyer au client le lien de prise de RDV (il choisit un créneau réservé 5 min, ou propose 3 dispos) — ${ref}`, params } + }, + async exec (p, email) { + const body = {} + if (p.job) body.job = p.job + if (p.ticket) body.ticket = p.ticket + if (p.customer) body.customer = p.customer + if (p.message) body.message = p.message + const r = await hubCall('POST', '/roster/book/propose', body, email) + const ok = r.ok && r.data && r.data.ok !== false && !r.data.error + const msg = ok + ? (r.data.sms ? 'Lien de RDV envoyé au client par texto.' : ('Lien de RDV prêt : ' + (r.data.url || '') + (r.data.note ? ' — ' + r.data.note : ''))) + : ((r.data && r.data.error) || `échec (HTTP ${r.status})`) + return { ok, message: msg, data: r.data } + }, + }, add_assistant: { permission: 'assign_jobs', async build (p) { @@ -232,7 +253,7 @@ Aujourd'hui = ${todayET()}. Tu agis AU NOM de l'utilisateur connecté${email ? ` Tu transformes une commande en langage naturel en APPELS D'OUTILS. RÈGLES : - Utilise d'abord les outils de LECTURE (list_technicians, get_job, find_slot) pour RÉSOUDRE les entités. Résous toujours un technicien nommé (« Simon ») en tech_id via list_technicians avant d'agir. -- Les outils d'ÉCRITURE (create_job, assign_tech, add_assistant, set_job_status, reschedule_job, create_recurring_shift, follow_doc) NE S'EXÉCUTENT PAS tout de suite : ils sont PROPOSÉS puis CONFIRMÉS par l'utilisateur. Chaque appel te renvoie un APERÇU (staged=true). N'affirme JAMAIS qu'une action est faite ; annonce ce qui SERA fait après confirmation. +- Les outils d'ÉCRITURE (create_job, assign_tech, proposer_rdv_client, add_assistant, set_job_status, reschedule_job, create_recurring_shift, follow_doc) NE S'EXÉCUTENT PAS tout de suite : ils sont PROPOSÉS puis CONFIRMÉS par l'utilisateur. Chaque appel te renvoie un APERÇU (staged=true). N'affirme JAMAIS qu'une action est faite ; annonce ce qui SERA fait après confirmation. - Horaire/quart RÉCURRENT : days parmi mon,tue,wed,thu,fri,sat,sun. « jours de semaine » = mon,tue,wed,thu,fri ; retire les exceptions (« sauf le vendredi » → mon,tue,wed,thu). « fin de semaine » = sat,sun. Convertis « 8-16h » en start 08:00 / end 16:00. Il FAUT un technicien : si aucun n'est nommé, DEMANDE-le. - S'il manque une information essentielle (quel job ? quel tech ? quelle date ?), pose UNE question courte au lieu d'appeler un outil d'écriture. - Réponds en français, bref. Après avoir proposé des actions, résume-les en une phrase et invite à confirmer.`