Accumulated work on the dispatch/legacy-writeback branch: - Communications UI: CommunicationsPage, ConversationFullPage, DepartmentBoard, PipelineBoard, ReaderStack, Orchestrator/NewTicket/ServiceStatus/Outbox dialogs; hub gmail.js, ticket-collab.js, outbox.js, coupon-triage.js, client-diag.js. - Billing/sync mirror (F→ERPNext): legacy-payments.js, legacy-sync.js, sync-orchestrator.js, supplier-invoices.js, municipality.js + incremental migration scripts; LegacySyncPage, SupplierInvoices + negative-billing / terminated-active reports. - Roster/campaigns/network/voice: roster + roster-assistant, campaigns, giftbit, olt-snmp, traccar, twilio, vision, tech-absence-sms, ai/agent/config/helpers, legacy-dispatch-sync; ops PlanificationPage, RapportsPage, Settings, Tickets, ClientDetail updates. - docs/ PLATFORM_GUIDE + UI_AND_OPTIMIZATION; .gitignore __pycache__. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
248 lines
8.9 KiB
JavaScript
248 lines
8.9 KiB
JavaScript
'use strict'
|
|
const cfg = require('./config')
|
|
const { log } = require('./helpers')
|
|
const erp = require('./erp')
|
|
const { broadcastAll } = require('./sse')
|
|
|
|
// Lazy require to avoid circular dependency with twilio.js
|
|
function sendSmsInternal (phone, message) {
|
|
return require('./twilio').sendSmsInternal(phone, message)
|
|
}
|
|
|
|
// ── Date helpers ──
|
|
|
|
const DAY_NAMES = { lun: 1, lundi: 1, mar: 2, mardi: 2, mer: 3, mercredi: 3, jeu: 4, jeudi: 4, ven: 5, vendredi: 5, sam: 6, samedi: 6, dim: 0, dimanche: 0 }
|
|
|
|
function localDateStr (d) {
|
|
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
|
|
}
|
|
|
|
function addDays (d, n) {
|
|
const r = new Date(d); r.setDate(r.getDate() + n); return r
|
|
}
|
|
|
|
function nextWeekday (dayNum) {
|
|
const now = new Date()
|
|
let diff = dayNum - now.getDay()
|
|
if (diff <= 0) diff += 7
|
|
return addDays(now, diff)
|
|
}
|
|
|
|
// ── ERPNext helpers ──
|
|
|
|
async function lookupTechByPhone (phone) {
|
|
const digits = phone.replace(/\D/g, '').slice(-10)
|
|
// Build a LIKE pattern that matches digits regardless of separators (dashes, spaces, dots)
|
|
// e.g. 5149490739 → %514%949%0739%
|
|
const pattern = '%' + digits.replace(/(\d{3})(\d{3})(\d{4})/, '$1%$2%$3') + '%'
|
|
const rows = await erp.list('Dispatch Technician', {
|
|
fields: ['name', 'full_name', 'phone', 'status'],
|
|
filters: [['phone', 'like', pattern]],
|
|
limit: 1,
|
|
})
|
|
return rows[0] || null
|
|
}
|
|
|
|
async function setTechAbsence (techName, { from, until, startTime, endTime, reason }) {
|
|
const body = {
|
|
status: 'En pause',
|
|
absence_from: from,
|
|
absence_until: until || from,
|
|
absence_reason: reason || 'personal',
|
|
absence_start_time: startTime || '',
|
|
absence_end_time: endTime || '',
|
|
}
|
|
const res = await erp.update('Dispatch Technician', techName, body)
|
|
if (res.ok) {
|
|
log(`Tech absence set: ${techName} from=${from} until=${until || from}`)
|
|
broadcastAll('tech-absence', { techName, action: 'set', from, until: until || from, startTime, endTime, reason })
|
|
return true
|
|
}
|
|
log(`Tech absence set FAILED: ${techName}`, res.error)
|
|
return false
|
|
}
|
|
|
|
async function clearTechAbsence (techName) {
|
|
const body = {
|
|
status: 'Disponible',
|
|
absence_from: '', absence_until: '', absence_reason: '',
|
|
absence_start_time: '', absence_end_time: '',
|
|
}
|
|
const res = await erp.update('Dispatch Technician', techName, body)
|
|
if (res.ok) {
|
|
log(`Tech absence cleared: ${techName}`)
|
|
broadcastAll('tech-absence', { techName, action: 'clear' })
|
|
return true
|
|
}
|
|
log(`Tech absence clear FAILED: ${techName}`, res.error)
|
|
return false
|
|
}
|
|
|
|
// ── LLM parsing (Gemini Flash via OpenAI-compatible API) ──
|
|
|
|
function buildSystemPrompt () {
|
|
return `Tu es un assistant qui analyse les SMS reçus d'une personne qui est TECHNICIEN TERRAIN et possiblement aussi CLIENT de l'entreprise (FAI/télécoms).
|
|
Aujourd'hui: ${localDateStr(new Date())}
|
|
|
|
CONTEXTE IMPORTANT: Cette personne a deux rôles possibles:
|
|
- Employé/technicien: messages liés au travail, présence, absences, disponibilité
|
|
- Client: messages liés à son service internet, facturation, problèmes techniques personnels
|
|
|
|
Tu dois déterminer si le message concerne le RÔLE EMPLOYÉ ou le RÔLE CLIENT.
|
|
|
|
Retourne UNIQUEMENT du JSON valide, rien d'autre.
|
|
|
|
Si le message concerne une ABSENCE EMPLOYÉ:
|
|
{"action":"absent","from":"YYYY-MM-DD","until":"YYYY-MM-DD","startTime":"HH:MM or null","endTime":"HH:MM or null","reason":"personal|medical|family|training|other","fullDay":true/false}
|
|
|
|
Si c'est un RETOUR au travail:
|
|
{"action":"retour"}
|
|
|
|
Si le message concerne le RÔLE CLIENT (problème internet, facture, service) ou n'est PAS lié au travail:
|
|
{"action":"none"}
|
|
|
|
EXEMPLES employé → absent/retour:
|
|
- "absent demain" → absent
|
|
- "je serai pas là vendredi" → absent
|
|
- "malade" → absent, medical
|
|
- "rdv dentiste 14h-16h" → absent partiel, medical
|
|
- "formation jeudi" → absent, training
|
|
- "mon fils est malade je reste à la maison" → absent, family
|
|
- "retour", "dispo", "de retour" → retour
|
|
|
|
EXEMPLES client → none:
|
|
- "mon internet est lent" → none (problème de service client)
|
|
- "j'ai pas reçu ma facture" → none (facturation client)
|
|
- "mon wifi ne marche plus" → none (support technique client)
|
|
- "je veux changer mon forfait" → none (demande client)
|
|
- "salut, as-tu le numéro du client?" → none (conversation générale)
|
|
|
|
Règles dates:
|
|
- "absent" sans date = aujourd'hui
|
|
- "demain" = jour suivant
|
|
- "lundi/mardi/..." = prochain jour nommé
|
|
- "3j" ou "3 jours" = aujourd'hui + n-1 jours
|
|
- startTime/endTime = null si journée complète
|
|
- Heures au format 24h (HH:MM)`
|
|
}
|
|
|
|
// Analyse SMS d'absence tech → routé par tâche 'roster' via lib/ai.js (repli Gemini auto).
|
|
function llmRequest (messages) {
|
|
return require('./ai').chat({ task: 'roster', messages, maxTokens: 1024, temperature: 0.1 })
|
|
}
|
|
|
|
async function parseWithLlm (text) {
|
|
if (!cfg.AI_API_KEY && !cfg.AI_BASE_URL) return null
|
|
try {
|
|
const raw = await llmRequest([
|
|
{ role: 'system', content: buildSystemPrompt() },
|
|
{ role: 'user', content: text },
|
|
])
|
|
// Strip markdown fences, <think> tags, extract JSON object
|
|
let cleaned = raw.replace(/<think>[\s\S]*?<\/think>/g, '').replace(/```json\s*/g, '').replace(/```/g, '').trim()
|
|
// Extract first JSON object if surrounded by text
|
|
const jsonMatch = cleaned.match(/\{[\s\S]*\}/)
|
|
if (jsonMatch) cleaned = jsonMatch[0]
|
|
const parsed = JSON.parse(cleaned)
|
|
if (parsed.action && ['absent', 'retour', 'none'].includes(parsed.action)) {
|
|
log(`LLM parsed SMS: "${text}" → ${JSON.stringify(parsed)}`)
|
|
return parsed
|
|
}
|
|
log(`LLM unexpected result: ${JSON.stringify(parsed)}`)
|
|
} catch (e) {
|
|
log(`LLM error: ${e.message}`)
|
|
}
|
|
return null
|
|
}
|
|
|
|
// ── Regex fallback ──
|
|
|
|
function parseWithRegex (text) {
|
|
const normalized = text.trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
|
|
// Return patterns
|
|
if (/^(retour|dispo|disponible|present|de retour|je reviens)$/i.test(normalized)) {
|
|
return { action: 'retour' }
|
|
}
|
|
|
|
// Absence patterns
|
|
const absMatch = normalized.match(/^(absent|malade|sick|formation)\s*(.*)$/i)
|
|
if (!absMatch) return null
|
|
|
|
const keyword = absMatch[1]
|
|
const rest = absMatch[2].trim()
|
|
const today = new Date()
|
|
let from = localDateStr(today), until = null, startTime = null, endTime = null
|
|
let reason = 'personal'
|
|
|
|
if (keyword === 'malade' || keyword === 'sick') reason = 'medical'
|
|
if (keyword === 'formation') reason = 'training'
|
|
|
|
// Time range
|
|
const timeMatch = rest.match(/(\d{1,2})[h:]?(\d{0,2})\s*[-àa]\s*(\d{1,2})[h:]?(\d{0,2})/)
|
|
if (timeMatch) {
|
|
startTime = timeMatch[1].padStart(2, '0') + ':' + (timeMatch[2] || '00').padStart(2, '0')
|
|
endTime = timeMatch[3].padStart(2, '0') + ':' + (timeMatch[4] || '00').padStart(2, '0')
|
|
}
|
|
|
|
const dateRest = rest.replace(/(\d{1,2})[h:]?\d{0,2}\s*[-àa]\s*\d{1,2}[h:]?\d{0,2}/, '').trim()
|
|
|
|
if (dateRest === 'demain' || dateRest === 'dem') {
|
|
from = localDateStr(addDays(today, 1))
|
|
} else if (dateRest.match(/^(\d+)\s*j(ours?)?$/)) {
|
|
const n = parseInt(dateRest.match(/^(\d+)/)[1], 10)
|
|
until = localDateStr(addDays(today, n - 1))
|
|
} else {
|
|
for (const [name, dayNum] of Object.entries(DAY_NAMES)) {
|
|
if (dateRest === name) { from = localDateStr(nextWeekday(dayNum)); break }
|
|
}
|
|
}
|
|
|
|
return { action: 'absent', from, until: until || from, startTime, endTime, reason, fullDay: !startTime }
|
|
}
|
|
|
|
// ── Main handler ──
|
|
|
|
async function handleAbsenceSms (from, text) {
|
|
// First: is this from a known tech?
|
|
const tech = await lookupTechByPhone(from)
|
|
if (!tech) return false
|
|
|
|
// Try LLM first (Gemini Flash), fallback to regex
|
|
let parsed = await parseWithLlm(text)
|
|
if (!parsed) {
|
|
parsed = parseWithRegex(text)
|
|
if (parsed) log(`Regex fallback parsed: "${text}" → ${JSON.stringify(parsed)}`)
|
|
}
|
|
|
|
if (!parsed || parsed.action === 'none') return false
|
|
|
|
if (parsed.action === 'retour') {
|
|
const ok = await clearTechAbsence(tech.name)
|
|
if (ok) await sendSmsInternal(from, `✅ ${tech.full_name}, ton statut est remis à Disponible. Bonne journée!`)
|
|
return true
|
|
}
|
|
|
|
if (parsed.action === 'absent') {
|
|
const fromDate = parsed.from || localDateStr(new Date())
|
|
const untilDate = parsed.until || fromDate
|
|
const ok = await setTechAbsence(tech.name, {
|
|
from: fromDate, until: untilDate,
|
|
startTime: parsed.startTime || null, endTime: parsed.endTime || null,
|
|
reason: parsed.reason || 'personal',
|
|
})
|
|
if (ok) {
|
|
let msg = `✅ ${tech.full_name}, absence enregistrée pour le ${fromDate}`
|
|
if (untilDate !== fromDate) msg = `✅ ${tech.full_name}, absence enregistrée du ${fromDate} au ${untilDate}`
|
|
if (parsed.startTime && parsed.endTime) msg += ` de ${parsed.startTime} à ${parsed.endTime}`
|
|
msg += `. Écris "retour" quand tu es disponible.`
|
|
await sendSmsInternal(from, msg)
|
|
}
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
module.exports = { handleAbsenceSms, parseWithLlm, parseWithRegex, lookupTechByPhone }
|