Bridge reason → required skill → availability so the occupancy denominator
reflects the skill a job needs ("70 h installation" vs "150 h all techs").
Hub:
- lib/skill-resolver.js (NEW, SOURCE UNIQUE): shared techHasSkill(s) predicate,
deptToSkill (moved here, re-exported from roster.js — no cycle),
DEPARTMENT_SKILLS bridge, resolveSkills({text,department,jobType,useAI})
(chip → rule → Gemini fallback, reusing classifyEmail pattern).
- roster.js: capacityByDay(start,days,skills) filters denominator to qualified
techs (+ guards used/due). New POST /roster/resolve-skills, /roster/capacity?skill=.
Backward-compatible (no skill = prior behavior).
- dispatch.js: techOccupancy/suggestSlots reuse the shared predicate.
SPA:
- config/departments.js: skills[]+dur per department + skillsForDepartment();
added Réparation reason.
- components/shared/OccupancyBands.vue (NEW): vertical-strip grid extracted from
SuggestSlotsDialog (now reuses it).
- components/shared/AvailabilityByReason.vue (NEW): reason chips + dictate→AI →
resolved skill → skill-filtered bands + adjusted-denominator summary.
Mounted in ClientDetailPage, IssueDetail, ConversationPanel.
- api/roster.js: getCapacity(start,days,skill) + resolveSkills().
- Planif + Dashboard capacity bands pass skill to /roster/capacity.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
141 lines
7.7 KiB
JavaScript
141 lines
7.7 KiB
JavaScript
// ── Résolveur « raison → compétence(s) requises » — SOURCE UNIQUE ─────────────
|
|
// Pont réutilisable entre TROIS systèmes qui, jusqu'ici, ne se parlaient pas :
|
|
// 1. la RAISON d'une demande (chip de département, type de job, ou texte dicté) ;
|
|
// 2. la/les COMPÉTENCE(S) réelle(s) d'un technicien (installation, tv, réparation…) ;
|
|
// 3. la DISPONIBILITÉ filtrée par compétence (dénominateur des bandes d'occupation).
|
|
//
|
|
// Appelable de PARTOUT (ticket, courriel, fiche client) via le composant front
|
|
// <AvailabilityByReason> et l'endpoint /roster/resolve-skills. Un seul endroit à
|
|
// éditer pour faire évoluer la correspondance raison→compétence.
|
|
//
|
|
// N'importe RIEN de roster.js/dispatch.js (pour éviter tout cycle) — au contraire,
|
|
// roster.js ré-exporte `deptToSkill` d'ici pour ne pas dupliquer la logique.
|
|
const cfg = require('./config')
|
|
const { log } = require('./helpers')
|
|
|
|
// Vocabulaire CANONIQUE des compétences terrain (= sorties possibles de deptToSkill).
|
|
// Une demande à distance / facturation / vente ne requiert AUCUNE compétence → [].
|
|
const SKILL_VOCAB = ['installation', 'réparation', 'tv', 'telephone', 'épissure', 'monteur', 'netadmin']
|
|
|
|
function normSkill (s) { return String(s || '').trim().toLowerCase() }
|
|
function stripAccents (s) { return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '') }
|
|
|
|
// ── Prédicat PARTAGÉ « ce technicien a-t-il la compétence ? » ──────────────────
|
|
// Sémantique IDENTIQUE à celle qui vivait (dupliquée) dans dispatch.js techOccupancy
|
|
// + suggestSlots : match exact OU inclusion (≥3 car.) tolérant aux variantes.
|
|
// techSkills accepte un TABLEAU (fetchTechnicians) OU une CSV brute (champ ERP / _user_tags).
|
|
function techHasSkill (techSkills, want) {
|
|
const w = normSkill(want)
|
|
if (!w) return true
|
|
const list = Array.isArray(techSkills) ? techSkills : String(techSkills || '').split(/[,;]/)
|
|
return list.some(s => {
|
|
s = normSkill(s)
|
|
return s && (s === w || (s.length >= 3 && (s.includes(w) || w.includes(s))))
|
|
})
|
|
}
|
|
// Plusieurs compétences requises → le tech doit les avoir TOUTES (aligné sur techCovers du solveur).
|
|
function techHasSkills (techSkills, wants) {
|
|
const ws = (Array.isArray(wants) ? wants : [wants]).map(normSkill).filter(Boolean)
|
|
if (!ws.length) return true
|
|
return ws.every(w => techHasSkill(techSkills, w))
|
|
}
|
|
|
|
// ── Repli déterministe : texte/département legacy → UNE compétence ─────────────
|
|
// (déplacé de roster.js — logique pure, réutilisée partout ; roster.js ré-exporte.)
|
|
function deptToSkill (txt) {
|
|
const d = stripAccents(txt).toLowerCase()
|
|
if (!d) return ''
|
|
if (/teleph/.test(d)) return 'telephone'
|
|
if (/tele|televis/.test(d)) return 'tv'
|
|
if (/fusion|episs/.test(d)) return 'épissure'
|
|
if (/monteur|aerien/.test(d)) return 'monteur'
|
|
if (/netadmin|net admin/.test(d)) return 'netadmin'
|
|
if (/repar|desinstall/.test(d)) return 'réparation'
|
|
if (/install|fibre/.test(d)) return 'installation'
|
|
return ''
|
|
}
|
|
|
|
// ── Pont DÉPARTEMENT/CATÉGORIE → compétence(s) ────────────────────────────────
|
|
// Clés = catégories de departments.js (SPA) ET valeurs CAT de categories.js (hub).
|
|
// [] = aucune compétence terrain (traité à distance / au bureau) → dénominateur = tous les techs.
|
|
const DEPARTMENT_SKILLS = {
|
|
Installation: ['installation'],
|
|
'Installation Fibre': ['installation'],
|
|
Réparation: ['réparation'],
|
|
Reparation: ['réparation'],
|
|
Télévision: ['tv'],
|
|
Television: ['tv'],
|
|
Téléphonie: ['telephone'],
|
|
Telephonie: ['telephone'],
|
|
Monteur: ['monteur'],
|
|
Épissure: ['épissure'],
|
|
Fusionneur: ['épissure'],
|
|
'Net Admin': ['netadmin'],
|
|
Support: [], // dépannage à distance → n'importe quel agent
|
|
Facturation: [],
|
|
Commercial: [], // vente / bureau
|
|
Vente: [],
|
|
Autre: [],
|
|
}
|
|
function deptSkills (dep) {
|
|
if (!dep) return null
|
|
if (Object.prototype.hasOwnProperty.call(DEPARTMENT_SKILLS, dep)) return DEPARTMENT_SKILLS[dep]
|
|
// tolérant aux accents/casse (« telephonie » → « Téléphonie »)
|
|
const key = Object.keys(DEPARTMENT_SKILLS).find(k => stripAccents(k).toLowerCase() === stripAccents(dep).toLowerCase())
|
|
return key ? DEPARTMENT_SKILLS[key] : null
|
|
}
|
|
|
|
// ── Chemin IA : texte libre dicté → compétence(s) ─────────────────────────────
|
|
// Réutilise le PATRON de inbox-triage.classifyEmail : JSON strict + reasoningEffort:'none'
|
|
// (sinon gemini « pense » et tronque le JSON → parse échoue silencieusement). Focalisé sur
|
|
// LA compétence (pas de match client), contraint au vocabulaire réel des techs.
|
|
async function aiSkills (text) {
|
|
if (!cfg.AI_API_KEY) return null
|
|
const sys = 'Tu assignes la ou les COMPÉTENCE(S) technique(s) requises pour traiter la demande d\'un client d\'un fournisseur Internet/télécom par fibre (TARGO / Gigafibre). ' +
|
|
'Choisis UNIQUEMENT parmi cette liste : ' + SKILL_VOCAB.join(', ') + '. ' +
|
|
'Un simple dépannage à distance, une question de facturation ou une vente ne requièrent AUCUNE compétence terrain → réponds "skills":[]. ' +
|
|
'Réponds UNIQUEMENT en JSON, sans texte autour : {"skills":["…"],"reason":"une courte phrase en français"}.'
|
|
try {
|
|
const out = await require('./ai').chat({ task: 'triage', maxTokens: 150, temperature: 0, reasoningEffort: 'none', messages: [{ role: 'system', content: sys }, { role: 'user', content: String(text || '').slice(0, 900) }] })
|
|
const j = JSON.parse((out.match(/\{[\s\S]*\}/) || ['{}'])[0])
|
|
const skills = (Array.isArray(j.skills) ? j.skills : []).map(normSkill).filter(s => SKILL_VOCAB.includes(s))
|
|
return { skills, reason: String(j.reason || '').slice(0, 200) }
|
|
} catch (e) { log('aiSkills error:', e.message); return null }
|
|
}
|
|
|
|
// ── Résolveur unifié ──────────────────────────────────────────────────────────
|
|
// resolveSkills({ text, department, jobType, useAI }) → { skills, primary, confidence, source, department, reason }
|
|
// Priorité : chip/département explicite (sûr) > type de job / regex sur le texte (moyen) > IA sur texte libre (moyen).
|
|
// confidence : 'high' (chip) · 'medium' (règle/IA) · 'low' (rien trouvé → aucune compétence, tous les techs).
|
|
async function resolveSkills ({ text = '', department = '', jobType = '', useAI = false } = {}) {
|
|
// 1. Département / chip explicite = intention claire de l'agent.
|
|
const ds = deptSkills(department)
|
|
if (ds) return { skills: ds, primary: ds[0] || '', confidence: 'high', source: 'chip', department, reason: '' }
|
|
|
|
// 2. Type de job explicite (Installation/Réparation…), puis regex sur le texte.
|
|
const rule = deptToSkill(jobType) || deptToSkill(text)
|
|
if (rule) return { skills: [rule], primary: rule, confidence: 'medium', source: 'rule', department, reason: '' }
|
|
|
|
// 3. IA sur le texte dicté (seulement si demandé ET clé dispo).
|
|
if (useAI && String(text || '').trim().length >= 4) {
|
|
const ai = await aiSkills(text)
|
|
if (ai && ai.skills.length) return { skills: ai.skills, primary: ai.skills[0], confidence: 'medium', source: 'ai', department, reason: ai.reason }
|
|
if (ai) return { skills: [], primary: '', confidence: 'low', source: 'ai', department, reason: ai.reason }
|
|
}
|
|
|
|
// 4. Rien de fiable → aucune compétence (le dénominateur reste « tous les techs »).
|
|
return { skills: [], primary: '', confidence: 'low', source: 'none', department, reason: '' }
|
|
}
|
|
|
|
module.exports = {
|
|
SKILL_VOCAB,
|
|
normSkill,
|
|
techHasSkill,
|
|
techHasSkills,
|
|
deptToSkill,
|
|
deptSkills,
|
|
DEPARTMENT_SKILLS,
|
|
aiSkills,
|
|
resolveSkills,
|
|
}
|