Bug Gigafibre.ca — corrigés/ajoutés : - P1 réassignation « Non assignés » (le menu placeholder n'exige plus l'union des compétences → assignable à la main, ⚠ par job) - P2 tech en congé/vacances exclu du pool de dispatch (garde suggestUnavailable + pastille congé) - P3 cadence MASQUÉE par défaut (tous 100 %), révélée seulement si nettement plus lent/rapide - P5 l'auto-save (draft) ne supprime plus un quart PUBLIÉ (cause du « publié puis à republier ») - P6 récap jobs simplifié (type·ville·rue·AM/PM·#) dans la modale Techniciens disponibles - P8 plages perso Jour/Soir PAR TECH (presets personnalisables, ex. 7:30–17:30) - A1 re-clic sur un tech (q-menu no-parent-event) · A4 type de job au survol · A5 « Vider » visible - A3 modem/ONU/remplacement → réparation (skill-resolver = source unique ; copie legacy périmée supprimée) - A6 clip colonne Ressource + label de rôle (barre de défilement horizontale proportionnelle) - A7 techs par compétence (survol) · A8 « Donner à » trié par compétences en commun Note : ces fichiers portaient déjà des changements déployés-non-commités ; ce commit les RÉCONCILIE avec le lot ci-dessus (co-édition — cf. reference_coedit_uncommitted_audit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
285 lines
17 KiB
JavaScript
285 lines
17 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 fs = require('fs')
|
|
const path = require('path')
|
|
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', 'support']
|
|
|
|
function normSkill (s) { return String(s || '').trim().toLowerCase() }
|
|
function stripAccents (s) { return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '') }
|
|
// Liste depuis un tableau OU une CSV brute (champ ERP / _user_tags), triée par ordre (= priorité).
|
|
function toSkillList (v) { return Array.isArray(v) ? v.filter(Boolean) : String(v || '').split(/[,;]/).map(s => s.trim()).filter(Boolean) }
|
|
// 'HH:MM' → heure décimale (pour borner un quart) ; '' → NaN.
|
|
function hmToH (t) { const p = String(t || '').split(':'); const h = Number(p[0]); const m = Number(p[1]); return Number.isFinite(h) ? h + (Number.isFinite(m) ? m / 60 : 0) : NaN }
|
|
|
|
// ── 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 (/\btv\b|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'
|
|
// Diagnostic / panne / dépannage → réparation, AVANT l'installation : « voir signal fibre », « panne », « internet lent »,
|
|
// « coupé », « ne fonctionne pas » = une RÉPARATION, pas une pose. (« fibre » seul = une techno, PAS une intention d'installer.)
|
|
// Échange/remplacement d'équipement (modem / ONU / borne) = une RÉPARATION fibre, pas une nouvelle pose : « changer modem »,
|
|
// « remplacement ONU », « swap borne » (accents déjà retirés par stripAccents → tokens sans accent).
|
|
if (/repar|desinstall|panne|signal|lent|coupe|coupure|hors.?service|ne fonctionne|fonctionne pas|marche pas|sans internet|pas d.?internet|no internet|deconnect|intermitten|verifi|diagnostic|depann|probleme|trouble|\bbris\b|defect|\bmodem\b|\bonu\b|remplac|\bswap\b|echange/.test(d)) return 'réparation'
|
|
if (/install|raccord|activ|nouveau|nouvelle|mise en service|branchement/.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: ['support'], // ne lister que les agents ayant la compétence « support » (pas les techs sans compétence)
|
|
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: '' }
|
|
}
|
|
|
|
// ── FONCTIONS (skillsets nommés, réutilisables) — catalogue hub durable ───────
|
|
// Une « fonction » = un rôle terrain porté par un QUART : elle définit ce que le
|
|
// dispatch TERRAIN peut envoyer au tech pendant ce quart.
|
|
// mode 'replace' (défaut) : remplace les compétences de base pour ce quart
|
|
// (« lundi = support » → ce jour-là il ne fait QUE support) ;
|
|
// mode 'add' : s'ajoute aux compétences de base (accorde une capacité en plus).
|
|
// Le helpdesk/support est géré SÉPARÉMENT — une fonction « hors terrain » (ex. support/remote)
|
|
// ne fait que RETIRER le tech du dispatch terrain ; on n'injecte personne dans l'horaire du helpdesk.
|
|
// Store JSON durable (même patron que job-skills.json ; l'API Custom Field ERPNext v16 est cassée
|
|
// et de toute façon un catalogue de rôles n'est pas de la donnée transactionnelle).
|
|
const FUNCTIONS_FILE = path.join(__dirname, '..', 'data', 'functions.json')
|
|
function getFunctionsMap () { try { return JSON.parse(fs.readFileSync(FUNCTIONS_FILE, 'utf8')) || {} } catch { return {} } }
|
|
function _normFn (id, raw) {
|
|
return {
|
|
id,
|
|
name: String(raw.name || id).trim(),
|
|
skills: [...new Set(toSkillList(raw.skills).map(s => s.trim()).filter(Boolean))],
|
|
mode: raw.mode === 'add' ? 'add' : 'replace',
|
|
color: raw.color || '',
|
|
icon: raw.icon || '',
|
|
}
|
|
}
|
|
function listFunctions () { const m = getFunctionsMap(); return Object.keys(m).map(id => _normFn(id, m[id])) }
|
|
// Résolution TOLÉRANTE : par id, sinon par nom (casse/accents ignorés) — un quart stocke le nom ou l'id.
|
|
function getFunction (idOrName) {
|
|
if (!idOrName) return null
|
|
const m = getFunctionsMap()
|
|
if (m[idOrName]) return _normFn(idOrName, m[idOrName])
|
|
const want = stripAccents(idOrName).toLowerCase()
|
|
const k = Object.keys(m).find(k => stripAccents(k).toLowerCase() === want || stripAccents(m[k].name || '').toLowerCase() === want)
|
|
return k ? _normFn(k, m[k]) : null
|
|
}
|
|
function setFunction (fn) {
|
|
const m = getFunctionsMap()
|
|
const id = String((fn && (fn.id || fn.name)) || '').trim()
|
|
if (!id) return m
|
|
m[id] = { name: String(fn.name || id).trim(), skills: [...new Set(toSkillList(fn.skills))], mode: fn.mode === 'add' ? 'add' : 'replace', color: fn.color || '', icon: fn.icon || '' }
|
|
try { fs.mkdirSync(path.dirname(FUNCTIONS_FILE), { recursive: true }) } catch (e) {}
|
|
fs.writeFileSync(FUNCTIONS_FILE, JSON.stringify(m)); return m
|
|
}
|
|
function deleteFunction (id) {
|
|
const m = getFunctionsMap(); if (!m[id]) return m
|
|
delete m[id]
|
|
try { fs.writeFileSync(FUNCTIONS_FILE, JSON.stringify(m)) } catch (e) {}
|
|
return m
|
|
}
|
|
|
|
// ── EXCEPTIONS de compétence PONCTUELLES (par tech, plage de dates) ────────────
|
|
// [{ from, to, mode:'remove'|'add', skills:[], reason }]. Ex. « mal de dos → pas d'installation
|
|
// cette semaine » = {mode:'remove', skills:['installation']}. (Le cas « reste à la maison
|
|
// aujourd'hui, prend le support » se fait plutôt via la FONCTION du quart de ce jour.)
|
|
const SKILL_EXC_FILE = path.join(__dirname, '..', 'data', 'skill-exceptions.json')
|
|
function getSkillExceptionsMap () { try { return JSON.parse(fs.readFileSync(SKILL_EXC_FILE, 'utf8')) || {} } catch { return {} } }
|
|
function getSkillExceptions (techId) { const v = getSkillExceptionsMap()[String(techId)]; return Array.isArray(v) ? v : [] }
|
|
function setSkillExceptions (techId, list) {
|
|
const m = getSkillExceptionsMap()
|
|
const arr = (Array.isArray(list) ? list : []).map(e => ({
|
|
from: String(e.from || ''), to: String(e.to || e.from || ''),
|
|
mode: e.mode === 'add' ? 'add' : 'remove',
|
|
skills: [...new Set(toSkillList(e.skills))],
|
|
reason: String(e.reason || '').slice(0, 200),
|
|
})).filter(e => e.from && e.skills.length)
|
|
if (arr.length) m[String(techId)] = arr; else delete m[String(techId)]
|
|
try { fs.mkdirSync(path.dirname(SKILL_EXC_FILE), { recursive: true }) } catch (e) {}
|
|
fs.writeFileSync(SKILL_EXC_FILE, JSON.stringify(m)); return m
|
|
}
|
|
|
|
// Compétences effectives d'UN quart : la valeur portée par le quart est un ENSEMBLE de compétences EXPLICITE
|
|
// (les « chips » curés par le répartiteur). Une fonction du catalogue n'est qu'un GABARIT qui PRÉ-REMPLIT ces
|
|
// chips côté UI ; on retire/ajoute ensuite à la main (ex. Technicien = installation+réparation+tv+téléphone,
|
|
// puis on retire « installation » car mal de dos). L'ensemble curé REMPLACE les compétences de base ce quart-là
|
|
// (pas de réconciliation automatique — le répartiteur voit et contrôle exactement ce qui est posé).
|
|
// • valeur vide → compétences de base.
|
|
// • RÉTRO-COMPAT : si la valeur est le NOM d'une fonction du catalogue, on l'étend à ses compétences.
|
|
// • sinon (CSV de compétences) → ces compétences telles quelles.
|
|
function _shiftEffective (base, fnVal) {
|
|
if (!fnVal) return base.slice()
|
|
const f = getFunction(fnVal)
|
|
const skills = f ? f.skills.slice() : toSkillList(fnVal)
|
|
return skills.length ? [...new Set(skills)] : base.slice()
|
|
}
|
|
|
|
// ── COMPÉTENCES EFFECTIVES d'un tech pour un JOUR (point d'entrée UNIQUE) ──────
|
|
// Le dispatch terrain, l'occupation, le solveur et la capacité doivent TOUS voir les mêmes
|
|
// compétences dépendantes du jour. Retourne un objet tech « shadow » : skills restreint +
|
|
// skill_levels / skill_eff RÉDUITS aux seules compétences effectives — les ★ (niveau) et la
|
|
// cadence d'une compétence retirée disparaissent aussi (elles ne doivent PAS peser sur l'offre).
|
|
//
|
|
// tech : objet tech ({ skills[], skill_levels{}, skill_eff{}, id })
|
|
// iso : 'YYYY-MM-DD'
|
|
// opts.shifts : quarts du tech CE JOUR = [{ function|fn, start, end }] (fournis par l'appelant :
|
|
// fetchAssignments enrichi du champ `function`, repli patron weekly_schedule).
|
|
// opts.time : 'HH:MM' → restreint au quart couvrant cette heure ; sinon UNION des quarts du jour.
|
|
//
|
|
// Aucun quart fourni (ou aucune fonction) → compétences de BASE (rétro-compat total : les
|
|
// appelants qui ne passent pas `shifts` obtiennent exactement le comportement d'avant).
|
|
function effectiveSkills (tech, iso, opts = {}) {
|
|
const base = toSkillList(tech && tech.skills)
|
|
const shifts = Array.isArray(opts.shifts) ? opts.shifts : []
|
|
let eff
|
|
if (!shifts.length) {
|
|
eff = base.slice()
|
|
} else {
|
|
let use = shifts
|
|
if (opts.time) {
|
|
const th = hmToH(opts.time)
|
|
const covering = shifts.filter(s => { const a = hmToH(s.start), b = hmToH(s.end); return !Number.isFinite(a) || !Number.isFinite(b) || (a <= th && th < b) })
|
|
if (covering.length) use = covering // si l'heure ne tombe dans aucun quart → repli sur tous les quarts du jour
|
|
}
|
|
eff = [...new Set(use.flatMap(s => _shiftEffective(base, s.function || s.fn || '')))]
|
|
}
|
|
// Exceptions ponctuelles (après la fonction : elles ajustent l'ensemble de travail du jour).
|
|
const exs = (tech && tech.id != null) ? getSkillExceptions(tech.id).filter(e => iso >= e.from && iso <= e.to) : []
|
|
for (const e of exs) {
|
|
if (e.mode === 'add') eff = [...new Set([...eff, ...e.skills])]
|
|
else eff = eff.filter(s => !e.skills.some(x => normSkill(x) === normSkill(s)))
|
|
}
|
|
// Réduire niveaux ★ + cadence aux seules compétences effectives (sinon une compétence retirée
|
|
// continuerait de peser sur le scoring / l'offre — ce que l'utilisateur ne veut pas).
|
|
const inEff = (k) => eff.some(s => normSkill(s) === normSkill(k))
|
|
const scope = (obj) => { const o = {}; if (obj) for (const k of Object.keys(obj)) if (inEff(k)) o[k] = obj[k]; return o }
|
|
return { ...tech, skills: eff, skill_levels: scope(tech && tech.skill_levels), skill_eff: scope(tech && tech.skill_eff) }
|
|
}
|
|
|
|
module.exports = {
|
|
SKILL_VOCAB,
|
|
normSkill,
|
|
toSkillList,
|
|
techHasSkill,
|
|
techHasSkills,
|
|
deptToSkill,
|
|
deptSkills,
|
|
DEPARTMENT_SKILLS,
|
|
aiSkills,
|
|
resolveSkills,
|
|
// Fonctions (catalogue) + exceptions + résolveur jour-conscient :
|
|
listFunctions,
|
|
getFunction,
|
|
setFunction,
|
|
deleteFunction,
|
|
getSkillExceptions,
|
|
getSkillExceptionsMap,
|
|
setSkillExceptions,
|
|
effectiveSkills,
|
|
}
|