feat(dispatch): NL « Proposer un RDV au client » (agent→client, sur staff-agent déployé)
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 <noreply@anthropic.com>
This commit is contained in:
parent
21d6538e33
commit
b99cbdd6cd
|
|
@ -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": "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": "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": "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", "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"] } } }
|
{ "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"] } } }
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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) }) }
|
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 })
|
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
|
// File « À reporter » (jobs à recontacter) — pour le superviseur
|
||||||
if (path === '/roster/jobs-to-reschedule' && method === 'GET') {
|
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 })
|
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 })
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,27 @@ const WRITES = {
|
||||||
return hubResult(await hubCall('POST', '/roster/assign-job', body, email), `${p.job} assigné à ${p.tech}`)
|
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: {
|
add_assistant: {
|
||||||
permission: 'assign_jobs',
|
permission: 'assign_jobs',
|
||||||
async build (p) {
|
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.
|
Tu transformes une commande en langage naturel en APPELS D'OUTILS.
|
||||||
RÈGLES :
|
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.
|
- 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.
|
- 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.
|
- 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.`
|
- Réponds en français, bref. Après avoir proposé des actions, résume-les en une phrase et invite à confirmer.`
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user