gigafibre-fsm/services/targo-hub/lib/tech-absence-sms.js
louispaulb 01bb99857f refactor(targo-hub): add erp.js wrapper + migrate 7 lib files to it
Replaces hand-rolled `erpFetch` + `encodeURIComponent(JSON.stringify(...))`
URL building with a structured wrapper: erp.get/list/listRaw/create/update/
remove/getMany/hydrateLabels/raw.

Key wins:
- erp.list auto-retries up to 5 times when v16 rejects a fetched/linked
  field with "Field not permitted in query" — the field is dropped and the
  call continues, so callers don't have to know which fields v16 allows.
- erp.hydrateLabels batches link-label resolution (customer_name,
  service_location_name, …) in one query per link field — no N+1, no
  v16 breakage.
- Consistent {ok, error, status} shape for mutations.

Migrated call sites:
- otp.js: Customer email lookup + verifyOTP customer detail fetch +
  Contact email fallback + Service Location listing
- referral.js: Referral Credit fetch / update / generate
- tech-absence-sms.js: lookupTechByPhone, set/clear absence
- conversation.js: Issue archive create
- magic-link.js: Tech lookup for /refresh
- ical.js: Tech lookup + jobs listing for iCal feed
- tech-mobile.js: 13 erpFetch sites → erp wrapper

Remaining erpFetch callers (dispatch.js, acceptance.js, payments.js,
contracts.js, checkout.js, …) deliberately left untouched this pass —
they each have 10+ sites and need individual smoke-tests.

Live-tested against production ERPNext: tech-mobile page renders 54K
bytes, no runtime errors in targo-hub logs post-restart.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 23:01:27 -04:00

278 lines
10 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)`
}
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 }