From 097e0566eca3d81c90ca79e40879322ec97a020b Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 4 Jun 2026 13:28:45 -0400 Subject: [PATCH] =?UTF-8?q?Reschedule:=20endpoint=20aviser-client=20(lien?= =?UTF-8?q?=20/book=20+=20SMS=20Twilio=20+=20statut=20'=C3=80=20reporter')?= =?UTF-8?q?=20+=20file=20'=C3=80=20reporter'=20+=20outil=20copilote?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /roster/job/notify-reschedule (job,phone?) → token /book + SMS + booking_status='À reporter'. /roster/jobs-to-reschedule → file superviseur. Copilote: outil notifier_client_report. Testé OK (chemin sans téléphone = sûr). Brique P5 de #55, déclenchable depuis le flux de pause. Co-Authored-By: Claude Opus 4.8 (1M context) --- services/targo-hub/lib/roster-assistant.js | 20 +++++++++++++++++++- services/targo-hub/lib/roster.js | 22 ++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/services/targo-hub/lib/roster-assistant.js b/services/targo-hub/lib/roster-assistant.js index 166ce52..caae86d 100644 --- a/services/targo-hub/lib/roster-assistant.js +++ b/services/targo-hub/lib/roster-assistant.js @@ -117,6 +117,14 @@ const TOOLS = [ 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 }) { @@ -133,12 +141,22 @@ async function tool_reassigner ({ job, technicien_id }) { 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 +} + 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 === '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) } } } @@ -170,7 +188,7 @@ RÈGLES : - 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 ») et reassigner_job (changer le tech d'un RDV, ex « réassigne DJ-… à Jean-Pierre »). Confirme TOUJOURS l'action réalisée (id + date). N'invente jamais une action non effectuée. (SMS client de report + escalade superviseur = à venir.) +- 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. - 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 de7e20d..f2f488d 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -483,6 +483,28 @@ async function handle (req, res, method, path, url) { if (!token) { token = crypto.randomBytes(12).toString('hex'); const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { booking_token: token })); if (!r.ok) return json(res, 500, r) } return json(res, 200, { ok: true, token, url: (cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca') + '/book?token=' + token }) } + // Aviser le client d'un report : lien /book + SMS Twilio + statut « À reporter » + if (path === '/roster/job/notify-reschedule' && method === 'POST') { + const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' }) + const job = await erp.get('Dispatch Job', b.job, { fields: ['name', 'booking_token', 'customer', 'customer_name'] }) + if (!job) return json(res, 404, { error: 'job introuvable' }) + let token = job.booking_token + if (!token) { token = crypto.randomBytes(12).toString('hex'); await retryWrite(() => erp.update('Dispatch Job', b.job, { booking_token: token })) } + const url = (cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca') + '/book?token=' + token + await retryWrite(() => erp.update('Dispatch Job', b.job, { booking_status: 'À reporter' })) + 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, url, sms: false, note: 'Statut « À reporter » posé. Aucun téléphone trouvé — fournir "phone" pour envoyer le SMS.' }) + const msg = b.message || `Bonjour, votre rendez-vous Gigafibre doit être reporté. Choisissez un nouveau créneau qui vous convient : ${url} Merci de votre compréhension.` + let sid = null + 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 }) + } + // 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', 'scheduled_date', 'assigned_tech', 'booking_token'], limit: 100 }) + return json(res, 200, { jobs: rows || [] }) + } // Fit : 3 dispos classées du client → 1er choix tenable, sinon proposer if (path === '/roster/book/fit' && method === 'POST') { const b = await parseBody(req)