- Remove apps/dispatch/ (100% replaced by ops dispatch module, unmaintained) - Commit services/targo-hub/lib/ (24 modules, 6290 lines — was never tracked) - Commit services/docuseal + services/legacy-db docker-compose configs - Extract client app composables: useOTP, useAddressSearch, catalog data, format utils - Refactor CartPage.vue 630→175 lines, CatalogPage.vue 375→95 lines - Clean hardcoded credentials from config.js fallback values - Add client portal: catalog, cart, checkout, OTP verification, address search - Add ops: NetworkPage, AgentFlowsPage, ConversationPanel, UnifiedCreateModal - Add ops composables: useBestTech, useConversations, usePermissions, useScanner - Add field app: scanner composable, docker/nginx configs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
286 lines
11 KiB
JavaScript
286 lines
11 KiB
JavaScript
'use strict'
|
|
const cfg = require('./config')
|
|
const { log, erpFetch } = require('./helpers')
|
|
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 fields = JSON.stringify(['name', 'full_name', 'phone', 'status'])
|
|
const filters = JSON.stringify([['phone', 'like', pattern]])
|
|
try {
|
|
const res = await erpFetch(`/api/resource/Dispatch Technician?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&limit_page_length=1`)
|
|
if (res.status === 200 && res.data?.data?.length > 0) return res.data.data[0]
|
|
} catch (e) { log('lookupTechByPhone error:', e.message) }
|
|
return 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 || '',
|
|
}
|
|
try {
|
|
const res = await erpFetch(`/api/resource/Dispatch Technician/${encodeURIComponent(techName)}`, {
|
|
method: 'PUT', body: JSON.stringify(body),
|
|
})
|
|
if (res.status === 200) {
|
|
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.data)
|
|
} catch (e) { log('setTechAbsence error:', e.message) }
|
|
return false
|
|
}
|
|
|
|
async function clearTechAbsence (techName) {
|
|
const body = {
|
|
status: 'Disponible',
|
|
absence_from: '', absence_until: '', absence_reason: '',
|
|
absence_start_time: '', absence_end_time: '',
|
|
}
|
|
try {
|
|
const res = await erpFetch(`/api/resource/Dispatch Technician/${encodeURIComponent(techName)}`, {
|
|
method: 'PUT', body: JSON.stringify(body),
|
|
})
|
|
if (res.status === 200) {
|
|
log(`Tech absence cleared: ${techName}`)
|
|
broadcastAll('tech-absence', { techName, action: 'clear' })
|
|
return true
|
|
}
|
|
log(`Tech absence clear FAILED: ${techName}`, res.data)
|
|
} catch (e) { log('clearTechAbsence error:', e.message) }
|
|
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)`
|
|
}
|
|
|
|
function llmRequest (messages) {
|
|
return new Promise((resolve, reject) => {
|
|
const baseUrl = cfg.AI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta/openai/'
|
|
const url = new URL(baseUrl + 'chat/completions')
|
|
const isHttps = url.protocol === 'https:'
|
|
const client = isHttps ? require('https') : require('http')
|
|
const payload = JSON.stringify({
|
|
model: cfg.AI_MODEL || 'gemini-2.5-flash',
|
|
messages,
|
|
temperature: 0.1,
|
|
max_tokens: 1024,
|
|
})
|
|
const headers = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
|
|
if (cfg.AI_API_KEY) headers['Authorization'] = 'Bearer ' + cfg.AI_API_KEY
|
|
const req = client.request({
|
|
hostname: url.hostname, port: url.port || (isHttps ? 443 : 80),
|
|
path: url.pathname + url.search, method: 'POST', headers, timeout: 15000,
|
|
}, (res) => {
|
|
let body = ''
|
|
res.on('data', c => body += c)
|
|
res.on('end', () => {
|
|
try {
|
|
const data = JSON.parse(body)
|
|
const content = data.choices?.[0]?.message?.content || ''
|
|
resolve(content)
|
|
} catch { reject(new Error('LLM parse error: ' + body.substring(0, 200))) }
|
|
})
|
|
})
|
|
req.on('error', reject)
|
|
req.on('timeout', () => { req.destroy(); reject(new Error('LLM timeout')) })
|
|
req.write(payload)
|
|
req.end()
|
|
})
|
|
}
|
|
|
|
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 }
|