feat(dispatch): lot d'améliorations Planification (doc « Remarques » + captures)

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>
This commit is contained in:
louispaulb 2026-07-23 11:03:24 -04:00
parent 80a059f50c
commit 71285e4ef8
7 changed files with 1314 additions and 269 deletions

View File

@ -1,85 +1,197 @@
<template>
<!-- Contenu du menu de cellule PARTAGÉ (grille semaine/jour ET calendrier par tech).
Le PARENT l'enveloppe dans son propre <q-menu> (ancrage propre à chaque contexte) et câble les intentions émises
à ses propres fonctions (applyWindow / toggleGarde / absence / ). Aucun état de page ici : 100 % réutilisable. -->
<q-list dense style="width:262px;user-select:none;-webkit-user-select:none">
Présentation COHÉRENTE avec l'éditeur hebdo : chaque quart = une rangée heureheure éditable ; « + quart »
ajoute un quart NON chevauchant le même jour. Le parent enveloppe ce contenu dans son propre <q-menu> et
exécute l'intention `set-windows` (liste complète des fenêtres régulières voulues). Aucun état de page ici. -->
<q-list dense style="width:300px;user-select:none;-webkit-user-select:none">
<q-item-label header class="q-py-xs">{{ title }}</q-item-label>
<!-- 4 actions : Jour · Soir · Garde · Absent -->
<div class="row q-gutter-xs q-px-sm q-pb-xs">
<q-btn dense unelevated size="sm" color="primary" label="Jour 816" class="col" @click="$emit('window', { min: 8, max: 16 })" />
<q-btn dense unelevated size="sm" color="deep-purple-5" label="Soir 1620" class="col" @click="$emit('window', { min: 16, max: 20 })" />
<!-- QUARTS : rangées heureheure éditables ; plusieurs quarts non chevauchants par jour -->
<div class="q-px-sm q-pb-xs" @click.stop @mousedown.stop>
<div v-for="(r, i) in rows" :key="i" class="pcm-shift">
<div class="pcm-row">
<input type="text" inputmode="numeric" maxlength="5" placeholder="08:00" v-model="r.start" class="pcm-time" @change="onTimeEdit(r, 'start')" @blur="onTimeEdit(r, 'start')" />
<span class="pcm-sep"></span>
<input type="text" inputmode="numeric" maxlength="5" placeholder="16:00" v-model="r.end" class="pcm-time" @change="onTimeEdit(r, 'end')" @blur="onTimeEdit(r, 'end')" />
<span class="pcm-h">{{ rowHours(r) }}h</span>
<q-btn flat dense round size="xs" icon="close" color="grey-6" @click="removeRow(i)"><q-tooltip>Retirer ce quart</q-tooltip></q-btn>
</div>
<div v-if="functions.length" class="pcm-role">
<q-icon name="badge" size="13px" color="grey-6" />
<select v-model="r.function" class="pcm-fn" title="Rôle de ce quart : compétences effectives du tech pour ce quart" @change="onRoleEdit">
<option value=""> rôle (compétences de base) </option>
<option v-for="f in functions" :key="f.id" :value="f.id">{{ f.name }}</option>
<option v-if="r.function && !isKnownFn(r.function)" :value="r.function"> compétences ajustées</option>
</select>
</div>
<div v-if="functions.length" class="pcm-fnsk">{{ r.function ? fnSkills(r.function).join(' · ') : 'compétences de base du tech' }}</div>
</div>
<div v-if="overlap" class="pcm-warn"><q-icon name="warning" size="13px" /> Les quarts se chevauchent ajuste les heures.</div>
<div v-else-if="!rows.length" class="pcm-empty">Aucun quart ce jour.</div>
<div class="pcm-adders">
<span class="pcm-addslot" @click="addRow()"><q-icon name="add" size="13px" />quart</span>
<span class="pcm-preset" @click="addPreset(dayPreset.start, dayPreset.end)">Jour {{ fmtPreset(dayPreset) }}<q-icon name="edit" size="10px" class="pcm-preset-edit" @click.stop="startEditPreset('day')"><q-tooltip>Personnaliser le préréglage Jour de ce tech</q-tooltip></q-icon></span>
<span class="pcm-preset soir" @click="addPreset(evePreset.start, evePreset.end)">Soir {{ fmtPreset(evePreset) }}<q-icon name="edit" size="10px" class="pcm-preset-edit" @click.stop="startEditPreset('eve')"><q-tooltip>Personnaliser le préréglage Soir de ce tech</q-tooltip></q-icon></span>
</div>
<!-- Personnalisation du préréglage PAR TECH (ex. Philip = 7:3017:30) : édité une fois, réutilisé à chaque clic Jour/Soir. -->
<div v-if="editPreset" class="pcm-preset-editor">
<span class="pcm-preset-lbl">Préréglage {{ editPreset === 'day' ? 'Jour' : 'Soir' }} :</span>
<input type="text" inputmode="numeric" maxlength="5" v-model="presetBuf.start" class="pcm-time" placeholder="07:30" @keyup.enter="commitPreset" />
<span class="pcm-sep"></span>
<input type="text" inputmode="numeric" maxlength="5" v-model="presetBuf.end" class="pcm-time" placeholder="17:30" @keyup.enter="commitPreset" />
<q-btn flat dense round size="xs" icon="check" color="primary" @click="commitPreset"><q-tooltip>Enregistrer pour ce tech</q-tooltip></q-btn>
<q-btn flat dense round size="xs" icon="close" color="grey-6" @click="editPreset = null" />
</div>
<!-- Édition FINE des compétences par quart (éditeur en chips) : lien vers la section rôle (menu de cellule trop étroit pour les chips) -->
<a v-if="rows.length" class="pcm-editlink" @click="$emit('edit-role')"><q-icon name="tune" size="12px" />Éditer les compétences par quart (chips)</a>
</div>
<div class="row q-gutter-xs q-px-sm q-pb-xs">
<q-btn dense :unelevated="isGarde" :outline="!isGarde" size="sm" color="brown" icon="shield" :label="isGarde ? 'Garde ✓' : 'Garde'" class="col" @click="$emit('toggle-garde')"><q-tooltip>Mettre / retirer de garde (G) en parallèle d'un shift</q-tooltip></q-btn>
<q-separator />
<!-- Garde (en parallèle d'un quart) · Absent (congé) -->
<div class="row q-gutter-xs q-px-sm q-py-xs">
<q-btn dense :unelevated="isGarde" :outline="!isGarde" size="sm" color="brown" icon="shield" :label="isGarde ? 'Garde ✓' : 'Garde'" class="col" @click="$emit('toggle-garde')"><q-tooltip>Mettre / retirer de garde (G) en parallèle d'un quart</q-tooltip></q-btn>
<q-btn dense :unelevated="isAbsent" :outline="!isAbsent" size="sm" color="negative" icon="event_busy" :label="isAbsent ? 'Absent ✓' : 'Absent'" class="col" @click="$emit('toggle-absent')"><q-tooltip>Congé / absence à publier</q-tooltip></q-btn>
</div>
<!-- Saisie rapide d'heures : 8-17 · 830-16 · 85 (=817) -->
<div class="q-px-sm q-pb-xs" @click.stop @mousedown.stop>
<q-input dense outlined v-model="quickEntry" placeholder="Heures : 8-16 · 816 · 830-16" @keyup.enter="applyQuick">
<template #append><q-btn flat dense round size="sm" icon="keyboard_return" color="primary" @click="applyQuick"><q-tooltip>Appliquer</q-tooltip></q-btn></template>
</q-input>
</div>
<!-- Plage personnalisée (slider replié) -->
<q-expansion-item dense dense-toggle icon="tune" label="Personnaliser la plage" header-class="text-caption text-grey-7">
<div class="q-px-md q-pb-sm" @click.stop @mousedown.stop>
<q-range v-model="range" :min="0" :max="24" :step="0.5" snap color="primary" class="q-mt-sm" />
<div class="row items-center no-wrap q-gutter-sm">
<span class="text-caption text-weight-bold">{{ fmtH(range.min) }}h{{ fmtH(range.max) }}h</span>
<q-space />
<q-btn dense unelevated size="sm" color="primary" label="Appliquer" @click="applyRange" />
</div>
</div>
<q-separator />
<!-- AVANCÉ (replié) : options moins fréquentes regroupées -->
<q-expansion-item dense dense-toggle icon="tune" label="Avancé" header-class="text-caption text-grey-7">
<q-item clickable dense @click="$emit('filler')">
<q-item-section avatar style="min-width:30px"><q-icon name="block" color="deep-orange-6" /></q-item-section>
<q-item-section><q-item-label>Bloquer du temps</q-item-label><q-item-label caption>formation · réunion · projet hors dispatch</q-item-label></q-item-section>
</q-item>
<q-separator spaced />
<q-item clickable dense @click="$emit('edit-role')">
<q-item-section avatar style="min-width:30px"><q-icon name="badge" color="indigo-5" /></q-item-section>
<q-item-section><q-item-label>Rôle du quart</q-item-label><q-item-label caption>éditer les compétences de chaque quart (chips)</q-item-label></q-item-section>
</q-item>
</q-expansion-item>
<q-separator />
<!-- Job « bouche-trou » : occuper la journée du tech (formation, réunion, admin) le retire du dispatch -->
<q-item clickable dense @click="$emit('filler')">
<q-item-section avatar style="min-width:30px"><q-icon name="block" color="deep-orange-6" /></q-item-section>
<q-item-section><q-item-label>Bloquer du temps</q-item-label><q-item-label caption>Job/ticket qui occupe la journée hors dispatch (priorité = curseur dur/souple)</q-item-label></q-item-section>
</q-item>
<q-separator />
<!-- Shifts en place + actions compactes -->
<q-item v-for="a in shifts" :key="'c' + (a.shift || a.name)" dense>
<q-item-section>{{ a.shift_name || a.shift }} <span class="text-grey-6">{{ a.hours }}h</span></q-item-section>
<q-item-section side><q-btn flat dense round size="sm" icon="close" color="grey-7" @click="$emit('remove-shift', a)"><q-tooltip>Retirer</q-tooltip></q-btn></q-item-section>
</q-item>
<!-- Utilitaires -->
<div class="row items-center q-px-sm q-py-xs q-gutter-sm">
<q-btn v-if="showCopy" flat dense size="sm" icon="content_copy" color="grey-8" @click="$emit('copy')"><q-tooltip>Copier la case</q-tooltip></q-btn>
<q-btn v-if="showCopy" flat dense size="sm" icon="content_paste" color="grey-8" :disable="!clipboardCount" @click="$emit('paste')"><q-tooltip>Coller{{ clipboardCount ? ' (' + clipboardCount + ')' : '' }}</q-tooltip></q-btn>
<q-space />
<q-btn v-if="shifts.length" flat dense size="sm" icon="layers_clear" color="grey-8" label="Vider" @click="$emit('clear')" />
<q-btn v-if="rows.length" outline dense size="sm" icon="layers_clear" color="negative" label="Vider" class="pcm-clear text-weight-bold" @click="clearAll"><q-tooltip>Retirer tous les quarts de ce jour</q-tooltip></q-btn>
</div>
</q-list>
</template>
<script setup>
// Menu de cellule réutilisable voir en-tête du template. Émet des INTENTIONS ; le parent exécute (single source of logic).
import { ref, watch } from 'vue'
// Menu de cellule réutilisable voir en-tête du template. Émet l'INTENTION `set-windows` (liste { min, max } en
// heures décimales) ; le parent la réconcilie (crée/retire les quarts réguliers, garde préservée).
import { ref, reactive, watch } from 'vue'
const props = defineProps({
title: { type: String, default: '' },
shifts: { type: Array, default: () => [] }, // [{ shift, shift_name, hours, name? }]
shifts: { type: Array, default: () => [] }, // [{ shift, shift_name, hours, start?, end?, on_call? }]
isGarde: { type: Boolean, default: false },
isAbsent: { type: Boolean, default: false },
clipboardCount: { type: Number, default: 0 },
initialRange: { type: Object, default: () => ({ min: 8, max: 16 }) },
cellKey: { type: String, default: '' }, // change à chaque (ré)ouverture réinitialise saisie/slider
cellKey: { type: String, default: '' }, // change à chaque (ré)ouverture réinitialise les rangées depuis les quarts en place
showCopy: { type: Boolean, default: true },
functions: { type: Array, default: () => [] }, // catalogue de rôles [{id,name,skills}] sélecteur de rôle PAR quart
// P8 préréglages Jour/Soir PAR TECH (heures décimales). Le parent fournit ceux du tech ; défauts sobres si absents.
// Ex. Philip : dayPreset { start: 7.5, end: 17.5 }. Cliquer « Jour » ajoute ce quart ; personnalise (émet set-preset).
dayPreset: { type: Object, default: () => ({ start: 8, end: 16 }) },
evePreset: { type: Object, default: () => ({ start: 16, end: 20 }) },
})
const emit = defineEmits(['window', 'toggle-garde', 'toggle-absent', 'remove-shift', 'clear', 'copy', 'paste', 'filler'])
const quickEntry = ref('')
const range = ref({ min: props.initialRange.min, max: props.initialRange.max })
// Réinitialise à chaque ouverture sur une nouvelle cellule (le composant reste monté, seul le q-menu parent s'affiche/masque).
watch(() => props.cellKey, () => { quickEntry.value = ''; range.value = { min: props.initialRange.min, max: props.initialRange.max } })
function fmtH (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return mm ? (hh + ':' + String(mm).padStart(2, '0')) : ('' + hh) }
// Parse « 8-17 » · « 8:30-16 » · « 830-16 » · « 85 » (=817, dernier chiffre en pm si début).
function parseHM (tok) { tok = String(tok).trim().toLowerCase().replace(/h/g, ':').replace(/[^\d:]/g, ''); if (!tok) return null; if (tok.includes(':')) { const [h, m] = tok.split(':'); return Number(h) + (Number(m || 0)) / 60 } if (tok.length >= 3) return Number(tok.slice(0, -2)) + Number(tok.slice(-2)) / 60; return Number(tok) }
function parseQuickShift (str) {
const s = (str || '').trim().toLowerCase(); if (!s) return null
if (/[-–—]|to|→|\s/.test(s)) { const p = s.split(/[-–—]|to|→|\s+/).filter(Boolean); if (p.length < 2) return null; const a = parseHM(p[0]); const b = parseHM(p[1]); return (a == null || b == null || b <= a || b > 24) ? null : { min: a, max: b } }
// Compact SANS séparateur : « 816 »=816 · « 1016 »=1016 · « 0816 »=816 (2 derniers chiffres = fin, le reste = début).
if (/^\d{3,4}$/.test(s)) { const end = Number(s.slice(-2)); const start = Number(s.slice(0, -2)); if (end <= 24 && start >= 0 && start < end) return { min: start, max: end } }
if (/^\d{2}$/.test(s)) { const a = Number(s[0]); let b = Number(s[1]); if (b <= a) b += 12; return (b <= a || b > 24) ? null : { min: a, max: b } } // « 85 »=817
return null
const emit = defineEmits(['set-windows', 'toggle-garde', 'toggle-absent', 'clear', 'copy', 'paste', 'filler', 'edit-role', 'set-preset'])
const isKnownFn = (v) => (props.functions || []).some(f => f.id === v) // valeur = rôle du catalogue ? (sinon CSV « ajusté »)
const fnSkills = (v) => { if (!v) return []; const f = (props.functions || []).find(x => x.id === v); return f ? (f.skills || []) : String(v).split(/[,;]/).map(s => s.trim()).filter(Boolean) } // compétences résolues d'un rôle (pour l'afficher)
const rows = ref([]) // [{ start:'08:00', end:'16:00', function:'' }] quarts RÉGULIERS éditables (garde exclue)
const overlap = ref(false) // vrai si 2 rangées se chevauchent on n'émet pas (état invalide)
let emitT = null
const toNum = (t) => { const p = String(t || '').split(':'); const h = Number(p[0]); const m = Number(p[1] || 0); return Number.isFinite(h) ? h + (Number.isFinite(m) ? m / 60 : 0) : null }
// Normalise une saisie souple « HH:MM » 24 h (« 8 »08:00, « 830 »08:30, « 16h »16:00, « 16:0 »16:00). Toujours 24 h, jamais AM/PM.
function norm24 (s) {
s = String(s || '').trim().toLowerCase().replace(/h/g, ':').replace(/[^\d:]/g, ''); if (!s) return ''
let h, m
if (s.includes(':')) { const p = s.split(':'); h = parseInt(p[0] || '0', 10); m = parseInt(p[1] || '0', 10) }
else if (s.length <= 2) { h = parseInt(s, 10); m = 0 } else { h = parseInt(s.slice(0, s.length - 2), 10); m = parseInt(s.slice(-2), 10) }
if (!Number.isFinite(h)) return ''
h = Math.min(23, Math.max(0, h)); m = Math.min(59, Math.max(0, Number.isFinite(m) ? m : 0))
return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0')
}
function applyQuick () { const r = parseQuickShift(quickEntry.value); if (!r) return; quickEntry.value = ''; emit('window', r) }
function applyRange () { if (range.value.max > range.value.min) emit('window', { min: range.value.min, max: range.value.max }) }
function onTimeEdit (r, field) { r[field] = norm24(r[field]); onEdit() }
const numToTime = (n) => { const hh = Math.floor(n); const mm = Math.round((n - hh) * 60); return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') }
function rowHours (r) { const a = toNum(r.start); const b = toNum(r.end); if (a == null || b == null || b <= a) return 0; return Math.round((b - a) * 10) / 10 }
function addHrs (start, h) { const a = toNum(start); return a == null ? '16:00' : numToTime(Math.min(24, a + (Number(h) || 8))) }
// Repli quand le parent ne fournit pas start/end : parse un nom de fenêtre « 8h16h » / « 8:30h16h ».
function parseWinName (nm) {
const m = String(nm || '').match(/(\d{1,2})(?::(\d{2}))?\s*h?\s*[–—-]\s*(\d{1,2})(?::(\d{2}))?\s*h?/)
if (!m) return null
return { start: numToTime(Number(m[1]) + (m[2] ? Number(m[2]) / 60 : 0)), end: numToTime(Number(m[3]) + (m[4] ? Number(m[4]) / 60 : 0)) }
}
function initRows () {
rows.value = (props.shifts || []).filter(s => !s.on_call).map(s => {
let start = s.start; let end = s.end
if (!start || !end) { const w = parseWinName(s.shift_name || s.shift); if (w) { start = start || w.start; end = end || w.end } }
if (!start) start = '08:00'
if (!end) end = addHrs(start, s.hours)
return { start, end, function: s.function || '' }
}).sort((a, b) => a.start.localeCompare(b.start))
overlap.value = false
}
// Réinitialise à chaque ouverture sur une nouvelle cellule (le composant reste monté, seul le q-menu parent s'affiche).
watch(() => props.cellKey, initRows, { immediate: true })
function hasOverlap (list) { const s = list.slice().sort((a, b) => a.min - b.min); for (let i = 1; i < s.length; i++) if (s[i].min < s[i - 1].max - 1e-6) return true; return false }
function emitWindows (immediate) {
const list = rows.value.map(r => ({ min: toNum(r.start), max: toNum(r.end), function: r.function || '' })).filter(w => w.min != null && w.max != null && w.max > w.min)
overlap.value = hasOverlap(list)
if (overlap.value) return // état invalide ne pas écrire (le parent garde le dernier état valide)
clearTimeout(emitT)
if (immediate) emit('set-windows', list) // rôle / action discrète tout de suite (pas de perte si on ferme le menu vite)
else emitT = setTimeout(() => emit('set-windows', list), 400) // frappe d'heures debounce (coalesce)
}
function scheduleEmit () { emitWindows(false) }
function onEdit () { emitWindows(false) } // heures : debounce
function onRoleEdit () { emitWindows(true) } // rôle : émission immédiate
function addRow () { const last = rows.value[rows.value.length - 1]; const start = last ? last.end : '08:00'; rows.value.push({ start, end: addHrs(start, 4), function: '' }); scheduleEmit() }
function addPreset (a, b) { rows.value.push({ start: numToTime(a), end: numToTime(b), function: '' }); rows.value.sort((x, y) => x.start.localeCompare(y.start)); scheduleEmit() }
// P8 préréglages Jour/Soir PAR TECH : label lisible + édition inline (émet set-preset ; le parent persiste).
function fmtHLabel (h) { const n = Number(h); if (!Number.isFinite(n)) return ''; const hh = Math.floor(n); const mm = Math.round((n - hh) * 60); return mm ? (hh + ':' + String(mm).padStart(2, '0')) : ('' + hh) }
function fmtPreset (p) { return fmtHLabel(p && p.start) + '' + fmtHLabel(p && p.end) }
const editPreset = ref(null) // 'day' | 'eve' | null
const presetBuf = reactive({ start: '', end: '' })
function startEditPreset (kind) { editPreset.value = kind; const p = kind === 'day' ? props.dayPreset : props.evePreset; presetBuf.start = numToTime(p.start); presetBuf.end = numToTime(p.end) }
function commitPreset () {
const s = toNum(norm24(presetBuf.start)); const e = toNum(norm24(presetBuf.end))
if (s == null || e == null || e <= s) { editPreset.value = null; return } // saisie invalide on annule sans écrire
emit('set-preset', { kind: editPreset.value, start: s, end: e }); editPreset.value = null
}
function removeRow (i) { rows.value.splice(i, 1); scheduleEmit() }
function clearAll () { rows.value = []; overlap.value = false; clearTimeout(emitT); emit('clear') } // « Vider » = tout retirer (géré côté parent, garde incluse)
</script>
<style scoped>
.pcm-shift { padding: 3px 0; border-bottom: 0.5px solid #f1f3f5; margin-bottom: 2px; }
.pcm-shift:last-of-type { border-bottom: none; }
.pcm-role { display: flex; align-items: center; gap: 4px; margin: 1px 0 2px 2px; }
.pcm-fn { flex: 1; min-width: 0; border: 1px solid #cbd5e1; border-radius: 6px; padding: 2px 6px; font-size: 12px; font-family: inherit; color: #475569; background: #fff; }
.pcm-fnsk { margin: 1px 0 0 19px; font-size: 10.5px; color: #0F6E56; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* compétences résolues du rôle — pour VOIR ce que le rôle donne */
.pcm-editlink { display: inline-flex; align-items: center; gap: 3px; margin-top: 4px; font-size: 11px; font-weight: 600; color: #4338ca; cursor: pointer; user-select: none; } /* lien vers l'éditeur de compétences en chips (section rôle) */
.pcm-editlink:hover { text-decoration: underline; }
.pcm-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.pcm-time { border: 1px solid #cbd5e1; border-radius: 6px; padding: 3px 6px; font-size: 13px; font-family: inherit; }
.pcm-sep { color: #94a3b8; }
.pcm-h { color: #64748b; font-size: 12px; font-weight: 600; margin-left: auto; }
.pcm-empty { font-size: 11.5px; color: #94a3b8; font-style: italic; margin: 2px 0 4px; }
.pcm-warn { font-size: 11px; color: #b45309; margin: 2px 0 4px; display: flex; align-items: center; gap: 3px; }
.pcm-adders { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
.pcm-addslot { display: inline-flex; align-items: center; gap: 2px; font-size: 11px; font-weight: 600; color: #4338ca; cursor: pointer; user-select: none; padding: 2px 6px; border-radius: 6px; }
.pcm-addslot:hover { background: #eef2ff; }
.pcm-preset { display: inline-flex; align-items: center; padding: 2px 9px; border-radius: 12px; font-size: 11px; font-weight: 600; color: #1565c0; background: #e3f2fd; border: 1px solid #90caf9; cursor: pointer; user-select: none; }
.pcm-preset.soir { color: #5e35b1; background: #ede7f6; border-color: #b39ddb; }
.pcm-preset:hover { filter: brightness(0.97); }
.pcm-preset-edit { margin-left: 4px; opacity: 0.55; }
.pcm-preset-edit:hover { opacity: 1; }
.pcm-preset-editor { display: flex; align-items: center; gap: 5px; margin-top: 5px; padding: 4px 6px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; }
.pcm-preset-lbl { font-size: 11px; font-weight: 600; color: #475569; white-space: nowrap; }
/* « Vider » = action destructive du jour → visible (rouge outline) mais pas dominante, pour éviter les clics accidentels. */
.pcm-clear { letter-spacing: 0.2px; }
.pcm-clear:hover { background: #fef2f2; }
</style>

View File

@ -8,7 +8,7 @@
* « Équipe compétences, cadence & coût » même gabarit partout. Opère sur l'objet tech LIVE
* (skills[]/skill_levels{}/skill_eff{}/efficiency) ; la persistance reste au parent via les events.
*/
import { reactive } from 'vue'
import { reactive, ref, computed, watch } from 'vue'
import TagEditor from 'src/components/shared/TagEditor.vue'
import HelpHint from 'src/components/shared/HelpHint.vue'
@ -29,6 +29,19 @@ const levelOf = (sk) => (props.tech.skill_levels && props.tech.skill_levels[sk])
const cadenceOf = (sk) => { const e = props.tech.skill_eff && props.tech.skill_eff[sk]; return (e != null && e !== '') ? pctOf(e) : '' } // '' = hérite du global
const globalPct = () => pctOf(props.tech.efficiency) // défaut efficiency=1 100 %
// P3 la CADENCE (vitesse) est MASQUÉE par défaut : tous les techs sont à 100 % (neutre pour le dispatch). On ne la
// révèle que si CE tech a déjà une cadence personnalisée, ou si l'utilisateur clique « Ajuster la vitesse » (cas d'un
// tech nettement plus lent/rapide). Aucune donnée n'est modifiée 100 % = facteur 1 = le dispatch reste « au Score ».
const showCadence = ref(false)
const hasCustomCadence = computed(() => {
const eff = props.tech.skill_eff || {}
const anySkill = (props.tech.skills || []).some(sk => { const e = eff[sk]; return e != null && e !== '' && Number(e) !== 1 })
const g = Number(props.tech.efficiency)
return anySkill || (Number.isFinite(g) && g !== 0 && g !== 1)
})
const cadenceVisible = computed(() => showCadence.value || hasCustomCadence.value)
watch(() => props.tech && props.tech.id, () => { showCadence.value = false }) // réinitialise le révélateur quand on change de tech
// Tampons de saisie (n'écrit qu'au blur/enter, comme l'original) locaux au composant.
const buf = reactive({}) // { sk: pct }
const gBuf = reactive({ v: undefined })
@ -50,7 +63,7 @@ function commitGlobal () { if (gBuf.v === undefined) return; const v = gBuf.v; g
<div class="row items-center text-caption text-grey-6 q-pb-xs">
<div class="col">Compétence</div>
<div style="width:90px" class="text-center">Score</div>
<div style="width:88px" class="text-center">Cadence <HelpHint title="Cadence (vitesse)" text="Vitesse du technicien pour cette compétence. 100 % = cadence normale ; plus haut = plus rapide (fait plus de jobs, ex. 200 % = deux fois plus) ; sous 100 % = plus lent. Vide = hérite de la cadence globale du tech. Le dispatch auto en tient compte pour estimer les durées." /></div>
<div v-if="cadenceVisible" style="width:88px" class="text-center">Cadence <HelpHint title="Cadence (vitesse)" text="Vitesse du technicien pour cette compétence. 100 % = cadence normale ; plus haut = plus rapide (fait plus de jobs, ex. 200 % = deux fois plus) ; sous 100 % = plus lent. Vide = hérite de la cadence globale du tech. Le dispatch auto en tient compte pour estimer les durées." /></div>
</div>
<div v-for="(sk, si) in skills()" :key="sk" class="row items-center no-wrap q-py-xs" style="border-top:1px solid #eee">
<div class="col row items-center no-wrap">
@ -72,15 +85,20 @@ function commitGlobal () { if (gBuf.v === undefined) return; const v = gBuf.v; g
<div style="width:90px" class="text-center no-wrap">
<q-icon v-for="n in 5" :key="n" :name="levelOf(sk) >= n ? 'star' : 'star_outline'" :color="levelOf(sk) >= n ? 'indigo' : 'grey-4'" size="16px" class="cursor-pointer" @click="emit('set-level', { sk, level: levelOf(sk) === n ? 0 : n })" />
</div>
<div style="width:88px">
<div v-if="cadenceVisible" style="width:88px">
<q-input dense outlined type="number" step="25" min="20" max="1000" :model-value="(sk in buf) ? buf[sk] : cadenceOf(sk)" @update:model-value="v => { buf[sk] = v }" @blur="commitSkill(sk)" @keyup.enter="commitSkill(sk)" suffix="%" placeholder="glob." input-class="text-right" />
</div>
</div>
<div class="text-caption text-grey-6 q-mt-xs"><b>Score</b> = maîtrise · <b>Cadence</b> : <b>100&nbsp;%</b> = normale · <b>200&nbsp;%</b> = deux fois plus de jobs · &lt;&nbsp;100&nbsp;% = plus lent · vide = hérite du global · × sur le chip = retirer.</div>
<div class="text-caption text-grey-6 q-mt-xs"><b>Score</b> = maîtrise (priorité au dispatch) · × sur le chip = retirer.<template v-if="cadenceVisible"> · <b>Cadence</b> : <b>100&nbsp;%</b> = normale · &gt;&nbsp;100&nbsp;% = plus rapide · &lt;&nbsp;100&nbsp;% = plus lent · vide = 100&nbsp;%.</template></div>
<!-- Révélateur de cadence : masqué par défaut (tous à 100 %) ; on ne l'affiche que pour un tech nettement plus lent/rapide. -->
<div class="q-mt-xs">
<a v-if="!cadenceVisible" class="cad-toggle" @click="showCadence = true"><q-icon name="speed" size="13px" /> Ajuster la vitesse (cadence) seulement si ce tech est nettement plus lent/rapide</a>
<a v-else-if="!hasCustomCadence" class="cad-toggle" @click="showCadence = false"><q-icon name="speed" size="13px" /> Masquer la cadence (100 % par défaut)</a>
</div>
</div>
<!-- Cadence GLOBALE du tech (défaut 100 %) s'applique aux compétences sans cadence propre. -->
<div v-if="showGlobal" class="row items-center q-mt-sm q-pt-sm" style="border-top:1px dashed #e2e8f0">
<!-- Cadence GLOBALE du tech (défaut 100 %) s'applique aux compétences sans cadence propre. Masquée avec le reste. -->
<div v-if="showGlobal && cadenceVisible" class="row items-center q-mt-sm q-pt-sm" style="border-top:1px dashed #e2e8f0">
<div class="col text-caption text-weight-medium text-grey-7">Cadence globale <span class="text-grey-5 text-weight-regular">(défaut 100 %)</span></div>
<q-input dense outlined type="number" step="10" min="20" max="1000" style="width:96px" suffix="%"
:model-value="(gBuf.v !== undefined) ? gBuf.v : globalPct()" @update:model-value="v => { gBuf.v = v }" @blur="commitGlobal" @keyup.enter="commitGlobal">
@ -94,4 +112,6 @@ function commitGlobal () { if (gBuf.v === undefined) return; const v = gBuf.v; g
.skill-chip { font-size: 10px; line-height: 15px; height: 15px; padding: 0 5px; border-radius: 8px; color: #fff; font-weight: 600; white-space: nowrap; flex-shrink: 0; display: inline-flex; align-items: center; gap: 2px; }
.skill-rank { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; background: #e2e8f0; color: #64748b; font-size: 9px; font-weight: 800; margin-right: 5px; flex-shrink: 0; }
.skill-rank-1 { background: #fef3c7; color: #b45309; box-shadow: 0 0 0 1.5px rgba(250,204,21,0.7); }
.cad-toggle { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 600; color: #4338ca; cursor: pointer; user-select: none; }
.cad-toggle:hover { text-decoration: underline; }
</style>

View File

@ -81,9 +81,10 @@
<PlanifCellMenu
:cell-key="cellMenu.iso"
:title="(tech && tech.name) + ' — ' + fmtDayLabel(cellMenu.iso)"
:shifts="menuShifts" :is-garde="menuIsGarde" :is-absent="menuIsAbsent" :show-copy="false"
@window="onMenuWindow" @toggle-garde="onMenuGarde" @toggle-absent="onMenuAbsent"
@remove-shift="onMenuRemoveShift" @clear="onMenuClear" />
:shifts="menuShifts" :is-garde="menuIsGarde" :is-absent="menuIsAbsent" :show-copy="false" :functions="functions"
:day-preset="dayPreset" :eve-preset="evePreset"
@set-windows="onMenuSetWindows" @toggle-garde="onMenuGarde" @toggle-absent="onMenuAbsent"
@clear="onMenuClear" @set-preset="e => emit('set-preset', e)" />
</q-menu>
<div class="row items-center q-gutter-md q-mt-xs ts-legend">
<span v-for="b in BRUSHES.filter(x => x.type !== 'clear')" :key="'l' + b.type"><i class="ts-dot" :style="{ background: b.color }"></i>{{ b.label }}</span>
@ -120,8 +121,11 @@ const props = defineProps({
pendingPaused: { type: Boolean, default: false }, // pause indéfinie EN ATTENTE (staged par le parent, publish-required)
gardeMap: { type: Object, default: () => ({}) }, // garde EFFECTIVE du parent (gardeEffective) : clé tech|iso shift
refreshKey: { type: Number, default: 0 }, // par le parent après une écriture de quart recharge le mois
functions: { type: Array, default: () => [] }, // catalogue de rôles sélecteur de rôle par quart dans le menu de cellule
dayPreset: { type: Object, default: () => ({ start: 8, end: 16 }) }, // P8 préréglage Jour PAR TECH (heures décimales)
evePreset: { type: Object, default: () => ({ start: 16, end: 20 }) }, // P8 préréglage Soir PAR TECH
})
const emit = defineEmits(['update:modelValue', 'edit-schedule', 'changed', 'stage-abs', 'stage-pause', 'set-shift', 'remove-shift', 'clear-shifts', 'toggle-garde'])
const emit = defineEmits(['update:modelValue', 'edit-schedule', 'changed', 'stage-abs', 'stage-pause', 'set-shift', 'set-windows', 'remove-shift', 'clear-shifts', 'toggle-garde', 'set-preset'])
const $q = useQuasar()
const err = (e) => $q.notify({ type: 'negative', message: '' + (e.message || e) })
@ -245,7 +249,7 @@ async function loadCal () {
const abd = {}
for (const a of ((asgRes && asgRes.assignments) || [])) {
if (a.tech !== props.tech.id && a.tech !== props.tech.name) continue
;(abd[a.date] || (abd[a.date] = [])).push({ name: a.name, shift: a.shift, shift_name: a.shift_name || a.shift, hours: a.hours })
;(abd[a.date] || (abd[a.date] = [])).push({ name: a.name, shift: a.shift, shift_name: a.shift_name || a.shift, hours: a.hours, function: a.function || '' })
}
asgByDate.value = abd
} catch (e) { err(e) } finally { calLoading.value = false }
@ -271,10 +275,10 @@ function endDrag () {
}
// Menu de cellule (clic simple) émet des intentions au parent (single source of logic).
function openCellMenu (iso) { cellMenu.iso = iso; cellMenu.show = true }
function onMenuWindow (r) { if (props.tech && r) emit('set-shift', { techId: props.tech.id, iso: cellMenu.iso, min: r.min, max: r.max }); cellMenu.show = false }
// Rangées heureheure du menu partagé réconcilie plusieurs quarts pour ce jour (le menu reste ouvert pour enchaîner).
function onMenuSetWindows (list) { if (props.tech) emit('set-windows', { techId: props.tech.id, iso: cellMenu.iso, windows: list || [] }) }
function onMenuGarde () { if (props.tech) emit('toggle-garde', { techId: props.tech.id, iso: cellMenu.iso }); cellMenu.show = false }
function onMenuAbsent () { if (props.tech) emit('stage-abs', { techId: props.tech.id, changes: [{ iso: cellMenu.iso, type: menuIsAbsent.value ? '' : 'Congé' }] }); cellMenu.show = false }
function onMenuRemoveShift (a) { if (a && a.name) emit('remove-shift', { name: a.name }); cellMenu.show = false }
function onMenuClear () { if (props.tech) emit('clear-shifts', { techId: props.tech.id, iso: cellMenu.iso, names: (asgByDate.value[cellMenu.iso] || []).map(a => a.name) }); cellMenu.show = false }
// Archivage (réversible)

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,7 @@ const addrdb = require('./address-db') // pool PG local (camping_registry)
const muni = require('./municipality') // résolveur de municipalités QC (centre-ville en dernier recours)
const sse = require('./sse') // diffusion temps-réel vers Ops (topic 'dispatch')
const audit = require('./dispatch-audit') // journal d'audit d'assignation (append-only JSONL)
const skillResolver = require('./skill-resolver') // SOURCE UNIQUE raison→compétence (ne PAS dupliquer la logique ici)
const fs = require('fs')
// Décodeur d'entités HTML UNIQUE (osTicket stocke les sujets encodés : &#039; &eacute; &amp; …). Appliqué à l'ÉCRITURE
// du sujet des jobs → la donnée stockée est propre partout (Dispatch, Planification, app terrain, rapports), pas de
@ -1471,18 +1472,10 @@ async function legacyTechTickets (staffId) {
return { ok: true, staff_id: sid, total_open: all.length, hidden_non_field: all.length - jobs.length, jobs }
}
// Dépt/sujet legacy → COMPÉTENCE (réplique roster.js deptToSkill) → couleur du bloc identique aux jobs importés.
function deptSkill (txt) {
const d = String(txt || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
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 ''
}
// Dépt/sujet legacy → COMPÉTENCE. DÉLÈGUE au résolveur PARTAGÉ (skill-resolver.deptToSkill) = source unique :
// couvre modem/ONU/panne/signal → réparation, et NE force PLUS « fibre » en installation (bug corrigé au partagé).
// (Avant : copie locale périmée qui classait « changement d'ONU » comme rien et « fibre » comme installation.)
function deptSkill (txt) { return skillResolver.deptToSkill(txt) }
// Durée estimée d'un job legacy : « est. time XhYm » dans le sujet, sinon défaut par dépt.
function estLegacyHours (subject, dept) {
const s = String(subject || '')
@ -2156,9 +2149,13 @@ async function handle (req, res, method, path) {
// ─── Réconciliation F→OPS : aligner les Dispatch Job legacy ACTIFS sur la vérité de F (autoritaire) ───
// Pour chaque DJ actif (open/assigned/in_progress) portant un legacy_ticket_id, on lit l'état RÉEL dans F (action pont
// `ticket_status`) puis : F fermé/résolu → ANNULE le DJ ; F renvoyé au pool (Tech Targo) ou à personne → ANNULE ;
// F réassigné à un AUTRE tech terrain → CORRIGE `assigned_tech` (+ scheduled_date = due_date F) ; F donné à un non-tech
// (bureau) → ANNULE (pas de bloc terrain pour du bureau) ; F = OPS → rien. N'ÉCRIT QUE ERPNext (jamais F). Aperçu par défaut.
// `ticket_status`) puis : F **fermé** → DJ **Completed** si un tech est assigné (le travail a été fait, fermé côté F ;
// compte au leaderboard), **Cancelled** seulement si personne n'était assigné (fermé sans visite). « Annulé » n'est
// JAMAIS le défaut d'une fermeture F — il est réservé aux vraies annulations (client annule / reste chez son fournisseur).
// F **pending** (reporté/en attente, PAS fermé) → DJ **On Hold** — surtout pas Completed/Cancelled.
// F renvoyé au pool (Tech Targo) ou à personne → ANNULE ; F réassigné à un AUTRE tech terrain → CORRIGE `assigned_tech`
// (+ scheduled_date = due_date F) ; F donné à un non-tech (bureau) → ANNULE (pas de bloc terrain pour du bureau) ;
// F = OPS → rien. N'ÉCRIT QUE ERPNext (jamais F). Aperçu par défaut.
async function reconcileLegacyJobs (opts = {}) {
const apply = opts.confirm === 'RECONCILE'
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
@ -2167,7 +2164,7 @@ async function reconcileLegacyJobs (opts = {}) {
const techToStaff = {}; const staffToTech = {}
for (const t of (techs || [])) { const m = String(t.technician_id || '').match(/(\d+)$/); if (!m) continue; const s = Number(m[1]); techToStaff[t.technician_id] = s; if (s >= 2 && s !== TARGO_TECH_STAFF_ID) staffToTech[s] = t.technician_id }
const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['status', 'in', ['open', 'assigned', 'in_progress']]], fields: ['name', 'legacy_ticket_id', 'assigned_tech', 'scheduled_date', 'status', 'subject'], limit: 5000 })
const plan = { reassign: [], cancel_closed: [], cancel_pool: [], cancel_office: [], agree: [], not_in_f: [] }
const plan = { reassign: [], cancel_closed: [], hold_pending: [], cancel_pool: [], cancel_office: [], agree: [], not_in_f: [] }
const ids = [...new Set((djs || []).map(d => String(d.legacy_ticket_id)).filter(Boolean))]
const fState = {}
for (let i = 0; i < ids.length; i += 400) {
@ -2181,8 +2178,9 @@ async function reconcileLegacyJobs (opts = {}) {
const subj = decode(j.subject || '').slice(0, 50)
if (!f) { plan.not_in_f.push({ job: j.name, tid, subj }); continue }
const fStaff = Number(f.assign_to) || 0
const open = String(f.status || '').toLowerCase() === 'open'
if (!open) { plan.cancel_closed.push({ job: j.name, tid, subj, f_status: f.status }); continue }
const fs = String(f.status || '').toLowerCase()
if (fs === 'pending') { if (j.status !== 'On Hold') plan.hold_pending.push({ job: j.name, tid, subj, tech: j.assigned_tech || '' }); else plan.agree.push({ job: j.name, tid }); continue }
if (fs !== 'open') { plan.cancel_closed.push({ job: j.name, tid, subj, f_status: f.status, tech: j.assigned_tech || '' }); continue }
if (fStaff === TARGO_TECH_STAFF_ID || fStaff < 2) {
if (!j.assigned_tech) { plan.agree.push({ job: j.name, tid }); continue } // DJ NON assigné + F au pool = backlog COHÉRENT (les deux au pool) → on laisse
plan.cancel_pool.push({ job: j.name, tid, subj }); continue // DJ assigné à un tech mais F au pool → vraie divergence (stagé non publié OU périmé)
@ -2193,15 +2191,19 @@ async function reconcileLegacyJobs (opts = {}) {
const iso = f.due_date ? new Date(Number(f.due_date) * 1000).toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) : (j.scheduled_date || null)
plan.reassign.push({ job: j.name, tid, subj, from: j.assigned_tech || '(aucun)', to: fTech, sched: iso })
}
const counts = { active_total: (djs || []).length, reassign: plan.reassign.length, cancel_closed: plan.cancel_closed.length, cancel_pool: plan.cancel_pool.length, cancel_office: plan.cancel_office.length, agree: plan.agree.length, not_in_f: plan.not_in_f.length }
const samples = { reassign: plan.reassign.slice(0, 10), cancel_closed: plan.cancel_closed.slice(0, 10), cancel_pool: plan.cancel_pool.slice(0, 10), cancel_office: plan.cancel_office.slice(0, 10), not_in_f: plan.not_in_f.slice(0, 10) }
const counts = { active_total: (djs || []).length, reassign: plan.reassign.length, cancel_closed: plan.cancel_closed.length, hold_pending: plan.hold_pending.length, cancel_pool: plan.cancel_pool.length, cancel_office: plan.cancel_office.length, agree: plan.agree.length, not_in_f: plan.not_in_f.length }
const samples = { reassign: plan.reassign.slice(0, 10), cancel_closed: plan.cancel_closed.slice(0, 10), hold_pending: plan.hold_pending.slice(0, 10), cancel_pool: plan.cancel_pool.slice(0, 10), cancel_office: plan.cancel_office.slice(0, 10), not_in_f: plan.not_in_f.slice(0, 10) }
if (!apply) return { ok: true, apply: false, counts, samples, note: 'cancel_pool n\'est PAS appliqué par défaut (peut être une assignation OPS stagée non publiée) — opt-in purgePool=1 requis.' }
let done = 0; const errs = []
// SÛRS : F dit explicitement fermé / bureau → on annule le DJ. cancel_pool EXCLU par défaut (ambigu staging vs périmé).
const toCancel = [...plan.cancel_closed, ...plan.cancel_office]
if (opts.purgePool) toCancel.push(...plan.cancel_pool) // OPT-IN EXPLICITE : F=pool → annule (RISQUE : efface une assignation OPS non encore poussée dans F)
for (const c of toCancel) {
try { await erp.update('Dispatch Job', c.job, { status: 'Cancelled' }) } catch (e) { errs.push({ job: c.job, action: 'cancel', err: String(e.message || e) }) }
let done = 0; let completedN = 0; let cancelledN = 0; let heldN = 0; const errs = []
// SÛRS : F dit explicitement fermé / bureau. Fermé+tech assigné → Completed (travail fait, compte au leaderboard) ;
// fermé sans tech ou bureau/pool → Cancelled ; pending → On Hold (reporté, PAS terminé).
// cancel_pool EXCLU par défaut (ambigu staging vs périmé).
const toClose = plan.cancel_closed.map(c => ({ ...c, to: c.tech ? 'Completed' : 'Cancelled' }))
toClose.push(...plan.hold_pending.map(c => ({ ...c, to: 'On Hold' })))
toClose.push(...plan.cancel_office.map(c => ({ ...c, to: 'Cancelled' })))
if (opts.purgePool) toClose.push(...plan.cancel_pool.map(c => ({ ...c, to: 'Cancelled' }))) // OPT-IN EXPLICITE : F=pool → annule (RISQUE : efface une assignation OPS non encore poussée dans F)
for (const c of toClose) {
try { await erp.update('Dispatch Job', c.job, { status: c.to }); if (c.to === 'Completed') completedN++; else if (c.to === 'On Hold') heldN++; else cancelledN++ } catch (e) { errs.push({ job: c.job, action: c.to.toLowerCase(), err: String(e.message || e) }) }
if (++done % 25 === 0) await sleep(200)
}
for (const r of plan.reassign) {
@ -2209,7 +2211,7 @@ async function reconcileLegacyJobs (opts = {}) {
try { await erp.update('Dispatch Job', r.job, patch) } catch (e) { errs.push({ job: r.job, action: 'reassign', err: String(e.message || e) }) }
if (++done % 25 === 0) await sleep(200)
}
return { ok: true, apply: true, applied: done, cancelled: toCancel.length, reassigned: plan.reassign.length, pool_held: opts.purgePool ? 0 : plan.cancel_pool.length, error_count: errs.length, errors: errs.slice(0, 50), counts }
return { ok: true, apply: true, applied: done, completed: completedN, cancelled: cancelledN, held: heldN, reassigned: plan.reassign.length, pool_held: opts.purgePool ? 0 : plan.cancel_pool.length, error_count: errs.length, errors: errs.slice(0, 50), counts }
}
// ─── BACKFILL ONE-TIME : Service Location manquantes des Customers legacy ───────────────────────────

View File

@ -58,6 +58,25 @@ function setJobSkills (name, list) {
try { fs.mkdirSync(path.dirname(JOB_SKILLS_FILE), { recursive: true }) } catch (e) {}
fs.writeFileSync(JOB_SKILLS_FILE, JSON.stringify(m)); return m
}
// FILES DE GARDE (rotations on-call) — store hub PARTAGÉ (avant : localStorage par-navigateur, non partagé entre
// répartiteurs). Une file = { id, name, dept, shift, shiftWeekend, weekdays[], anchor(YYYY-MM-DD), steps[techId] }.
// steps = ordre de rotation (round-robin) ; rotationTech (client) avance chaque semaine + saute les absents.
const GARDE_QUEUES_FILE = path.join(__dirname, '..', 'data', 'garde-queues.json')
function listGardeQueues () { try { const a = JSON.parse(fs.readFileSync(GARDE_QUEUES_FILE, 'utf8')); return Array.isArray(a) ? a : [] } catch { return [] } }
function saveGardeQueues (arr) {
const clean = (Array.isArray(arr) ? arr : []).filter(q => q && q.id).map(q => ({
id: String(q.id), name: String(q.name || '').slice(0, 80), dept: String(q.dept || '').slice(0, 80),
shift: q.shift || '', shiftWeekend: q.shiftWeekend || '',
weekdays: Array.isArray(q.weekdays) ? q.weekdays.map(Number).filter(n => n >= 0 && n <= 6) : [],
anchor: /^\d{4}-\d{2}-\d{2}$/.test(q.anchor || '') ? q.anchor : '',
// steps = ordre de rotation : { tech, weeks } (weeks = semaines consécutives de ce tech avant de passer au suivant)
steps: Array.isArray(q.steps) ? q.steps.filter(s => s && s.tech).map(s => ({ tech: String(s.tech), weeks: Math.max(1, Number(s.weeks) || 1) })) : [],
}))
try { fs.mkdirSync(path.dirname(GARDE_QUEUES_FILE), { recursive: true }) } catch (e) {}
fs.writeFileSync(GARDE_QUEUES_FILE, JSON.stringify(clean, null, 2)); return clean
}
function upsertGardeQueue (q) { const a = listGardeQueues(); const i = a.findIndex(x => x.id === q.id); if (i >= 0) a[i] = q; else a.push(q); return saveGardeQueues(a) }
// Priorité de job = champ ERPNext standard (low/medium/high), édité via /roster/job/update (comme le pool). Plus de store parallèle.
// 🖥 Job « SANS DÉPLACEMENT » (configurable à distance, ex. boîtier tel via ACS) : exclu des tournées (aucun arrêt), à faire du bureau.
const JOB_REMOTE_FILE = path.join(__dirname, '..', 'data', 'job-remote.json')
@ -194,11 +213,17 @@ async function optimizePlan (body) {
])
const tplBy = Object.fromEntries(templates.map(t => [t.name, t]))
const winBy = {} // techId|iso → fenêtre de quart RÉELLE (min)
// Quarts du jour PAR tech (avec fonction éventuelle) → compétences dépendantes du jour via effectiveSkills.
// ⚠️ `a.function` reste indéfini (donc inerte = compétences de base) tant que le DocField `function` de
// Shift Assignment n'est pas migré ET lu par fetchAssignments. Les exceptions ponctuelles agissent déjà.
const shiftsBy = {} // techId|iso → [{ function, start, end }]
for (const a of assignments) {
const tp = tplBy[a.shift]; if (!tp || tp.on_call) continue
const tp = tplBy[a.shift]; if (!tp) continue
const k = a.tech + '|' + a.date
;(shiftsBy[k] = shiftsBy[k] || []).push({ function: a.function || '', start: tp.start_time, end: tp.end_time })
if (tp.on_call) continue
const s = hmToMin(tp.start_time); let e = hmToMin(tp.end_time); if (s == null || e == null) continue
if (e <= s) e = 24 * 60
const k = a.tech + '|' + a.date
const w = winBy[k]; winBy[k] = w ? { start: Math.min(w.start, s), end: Math.max(w.end, e) } : { start: s, end: e }
}
let policy = {}; try { policy = JSON.parse(fs.readFileSync(POLICY_FILE, 'utf8')) } catch (e) {}
@ -236,6 +261,10 @@ async function optimizePlan (body) {
for (const iso of dates) {
const dayJobs = [...(byDay[iso] || []), ...spill]; spill = []
if (!dayJobs.length) continue
// Compétences EFFECTIVES par tech CE JOUR (base ± fonction du quart ± exception) — le solveur filtre (techCovers)
// ET classe (skill/skill_eff) sur cet ensemble : une compétence retirée ce jour ne pèse plus sur l'offre.
const effByTech = new Map(techs.map(t => [t.id, effectiveSkills(t, iso, { shifts: shiftsBy[t.id + '|' + iso] || [] })]))
const effOf = (t) => (t && effByTech.get(t.id)) || t
// Occupation existante : minutes déjà prises par tech + index adresse→tech (coordination)
const usedMin = {}; const addrTech = {}
for (const t of techs) {
@ -255,7 +284,7 @@ async function optimizePlan (body) {
if (hit) {
const t = techs.find(x => x.id === hit.techId)
const dur = durOf(j)
plan.push({ ...entryOf(j, iso, dur), techId: hit.techId, techName: hit.techName, pinned: true, coordWith: hit.subject, capable: techCovers(t, j) })
plan.push({ ...entryOf(j, iso, dur), techId: hit.techId, techName: hit.techName, pinned: true, coordWith: hit.subject, capable: techCovers(effOf(t), j) })
usedMin[hit.techId] = (usedMin[hit.techId] || 0) + Math.round(dur * 60)
} else rest.push(j)
}
@ -267,7 +296,8 @@ async function optimizePlan (body) {
if (!w) { if (isWE(iso)) continue; w = { start: 480, end: 960 }; assumedShift[t.id + '|' + iso] = true }
const used = usedMin[t.id] || 0
const o = originOf(t.id)
vehicles.push({ id: t.id, name: t.name, skills: t.skills || [], skill_eff: t.skill_eff || {}, efficiency: Number(t.efficiency) || 1, home_lat: o ? o.lat : null, home_lon: o ? o.lon : null, shift_start_min: Math.min(w.end - 15, w.start + used), shift_end_min: w.end, overtime_min: Math.round(0.2 * (w.end - w.start)) })
const et = effOf(t) // compétences/cadence effectives du jour (fonction du quart ± exception)
vehicles.push({ id: t.id, name: t.name, skills: et.skills || [], skill_eff: et.skill_eff || {}, efficiency: Number(t.efficiency) || 1, home_lat: o ? o.lat : null, home_lon: o ? o.lon : null, shift_start_min: Math.min(w.end - 15, w.start + used), shift_end_min: w.end, overtime_min: Math.round(0.2 * (w.end - w.start)) })
}
if (!vehicles.length) { for (const j of rest) unassigned.push(entryOf(j, iso, durOf(j))); continue }
// MÊME ADRESSE = 1 ARRÊT : fusion des jobs du pool par clé d'adresse → 1 nœud (temps additionnés, skill = 1re non vide du groupe)
@ -297,7 +327,7 @@ async function optimizePlan (body) {
for (const st of (rt.stops || [])) {
const ui = Number(String(st.job_id).slice(1)); const g = units[ui]; if (!g) continue
placed.add(st.job_id)
for (const j of g) plan.push({ ...entryOf(j, iso, durOf(j)), techId: t.id, techName: t.name, capable: techCovers(t, j), sameAddrN: g.length })
for (const j of g) plan.push({ ...entryOf(j, iso, durOf(j)), techId: t.id, techName: t.name, capable: techCovers(effOf(t), j), sameAddrN: g.length })
}
}
for (const uid of (res.unassigned || [])) {
@ -343,7 +373,7 @@ async function _fetchTechniciansRaw () {
filters: [['resource_type', '=', 'human']],
fields: ['name', 'technician_id', 'full_name', 'status', 'color_hex', 'tech_group', 'efficiency', 'skills',
'cost_salary_h', 'cost_charges_pct', 'cost_other_h', 'traccar_device_id',
'absence_from', 'absence_until', 'employee', 'phone', '_user_tags', 'skill_levels', 'skill_eff', 'weekly_schedule'],
'absence_from', 'absence_until', 'employee', 'phone', 'email', '_user_tags', 'skill_levels', 'skill_eff', 'weekly_schedule'],
limit: 500,
})
const arch = loadArchivedSet() // ids archivés (store hub) → masqués partout
@ -359,6 +389,7 @@ async function _fetchTechniciansRaw () {
cost_h: Math.round(((Number(t.cost_salary_h) || 0) * (1 + (Number(t.cost_charges_pct) || 0) / 100) + (Number(t.cost_other_h) || 0)) * 100) / 100,
color: t.color_hex || '#1976d2',
phone: t.phone,
email: t.email || '',
traccar_device_id: t.traccar_device_id || '', // appareil Traccar associé (pour GPS live + tracé) — éditable inline
employee: t.employee,
skills: splitCsv(t.skills || t._user_tags), // champ skills (ou tags Frappe)
@ -431,13 +462,15 @@ async function fetchAssignments (start, days) {
const dates = rangeDates(start, days)
const rows = await erp.list('Shift Assignment', {
filters: [['assignment_date', 'in', dates]],
fields: ['name', 'technician', 'technician_name', 'assignment_date', 'shift_template', 'zone', 'hours', 'status', 'source'],
// ⚠️ `function` : à activer UNIQUEMENT après la migration (bench migrate ajoute le DocField). Avant, le demander
// ferait échouer erp.list (« Field not permitted ») + empoisonnerait le cache erp → à déployer AVEC la migration.
fields: ['name', 'technician', 'technician_name', 'assignment_date', 'shift_template', 'zone', 'hours', 'status', 'source', 'function'],
limit: 2000,
})
// Normaliser vers la forme canonique {tech, date, shift} (= sortie du solveur + UI)
// Normaliser vers la forme canonique {tech, date, shift} (= sortie du solveur + UI). `function` = fonction du quart (skillset).
return rows.map(r => ({
name: r.name, tech: r.technician, tech_name: r.technician_name, date: r.assignment_date,
shift: r.shift_template, zone: r.zone, hours: r.hours, status: r.status, source: r.source,
shift: r.shift_template, zone: r.zone, hours: r.hours, status: r.status, source: r.source, function: r.function || '',
}))
}
@ -459,7 +492,12 @@ async function ensureShiftTemplate (start, end, cache) { // start/end 'HH:MM'
else log('[materialize] ensureShiftTemplate FAIL ' + key + ' → ' + JSON.stringify(r).slice(0, 220))
return name
}
async function materializeShifts ({ dryRun = true, weeks = 4, techId = null } = {}) {
// rolesToManual : sur un jour qui a des quarts MANUELS (donc non re-généré), applique quand même le RÔLE du patron
// aux quarts dont la fenêtre correspond (rôle seul — ne crée/supprime/re-horaire jamais). Réservé à l'application
// EXPLICITE (« Définir l'horaire »), PAS au cron (qui ne doit pas écraser silencieusement un rôle posé à la main).
// replaceManual : sur un jour à quarts MANUELS, SUPPRIME les quarts réguliers manuels (garde préservée) et laisse le patron
// reprendre la main (crée ses quarts + rôles). Réservé à l'application EXPLICITE APRÈS confirmation utilisateur (« Remplacer »).
async function materializeShifts ({ dryRun = true, weeks = 4, techId = null, rolesToManual = false, replaceManual = false } = {}) {
const weeksN = Math.max(1, Math.min(12, Number(weeks) || 4))
const start = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
const days = weeksN * 7
@ -468,30 +506,69 @@ async function materializeShifts ({ dryRun = true, weeks = 4, techId = null } =
const techs = techsAll.filter(t => t.status !== PAUSE_STATUS && t.weekly_schedule && (!techId || t.id === techId))
const rowsByKey = {}; for (const a of existing) { (rowsByKey[a.tech + '|' + a.date] = rowsByKey[a.tech + '|' + a.date] || []).push(a) }
const tplCache = {}
// Templates de GARDE (on_call) → à préserver quand on « remplace » les quarts réguliers manuels par le patron.
const onCallTpls = replaceManual ? new Set((await fetchTemplates()).filter(t => t.on_call).map(t => t.name)) : new Set()
let created = 0, updated = 0, deleted = 0, keptManual = 0, skipHoliday = 0, skipVacation = 0, tplNull = 0, createFail = 0
log('[materialize] start=' + start + ' days=' + days + ' techs=' + techs.length + '/' + techsAll.length + ' avec_patron dryRun=' + dryRun)
for (const t of techs) {
const sched = t.weekly_schedule || {}
for (const iso of dates) {
const day = sched[DOW_KEYS[new Date(iso + 'T12:00:00Z').getUTCDay()]] // {start,end} ou null/absent
const dayVal = sched[DOW_KEYS[new Date(iso + 'T12:00:00Z').getUTCDay()]] // MULTI-SLOT : tableau [{start,end,function}] ; RÉTRO-COMPAT : objet simple {start,end,function} ; ou null/absent
const key = t.id + '|' + iso; const rows = rowsByKey[key] || []
if (rows.some(r => r.source !== 'pattern')) { keptManual++; continue } // override manuel présent → intouchable
const pat = rows.filter(r => r.source === 'pattern')
const holiday = holidaysQc.isHoliday(iso); const vacation = !!absences[key]
const wants = !!(day && day.start && day.end) && !holiday && !vacation
if (!wants) { // férié / vacances / jour OFF du patron → retirer le quart pattern
// Créneaux voulus ce jour (0..n). Objet simple → 1 slot (rétro-compat). Férié/vacances → aucun.
let slots = []
if (!holiday && !vacation) {
if (Array.isArray(dayVal)) slots = dayVal.filter(s => s && s.start && s.end)
else if (dayVal && dayVal.start && dayVal.end) slots = [{ start: dayVal.start, end: dayVal.end, function: dayVal.function || '' }]
}
if (rows.some(r => r.source !== 'pattern')) { // override MANUEL présent sur ce jour
if (replaceManual) { // REMPLACER : supprimer les quarts réguliers MANUELS (garde on_call préservée), le patron reprend la main ci-dessous
for (const r of rows) { if (r.source !== 'pattern' && !onCallTpls.has(r.shift)) { if (dryRun) deleted++; else { const rr = await erp.remove('Shift Assignment', r.name); if (rr && rr.ok) deleted++ } } }
// pas de `continue` → on tombe dans le reconcile pattern (crée les quarts du patron avec leur rôle)
} else { // CONSERVER : on ne re-génère pas ce jour…
keptManual++
if (rolesToManual && slots.length) { // …mais (application explicite) on applique le RÔLE du patron aux quarts dont la fenêtre correspond
for (const s of slots) {
const fn = s.function || ''; if (!fn) continue
const tpl = await ensureShiftTemplate(s.start, s.end, tplCache); if (!tpl) continue
const m = rows.find(r => r.shift === tpl && fn !== (r.function || ''))
if (m && !dryRun) { const u = await retryWrite(() => erp.update('Shift Assignment', m.name, { function: fn })); if (u && u.ok) updated++ }
else if (m && dryRun) updated++
}
}
continue
}
}
const pat = rows.filter(r => r.source === 'pattern')
if (!slots.length) { // férié / vacances / jour OFF du patron → retirer TOUS les quarts pattern
if (holiday) skipHoliday++; else if (vacation) skipVacation++
for (const r of pat) { if (dryRun) deleted++; else { const rr = await erp.remove('Shift Assignment', r.name); if (rr && rr.ok) deleted++ } }
continue
}
const tplName = await ensureShiftTemplate(day.start, day.end, tplCache); if (!tplName) { tplNull++; continue }
if (pat.some(r => r.shift === tplName)) continue // déjà bon (idempotent)
const hours = Math.round((_timeH(day.end) - _timeH(day.start)) * 100) / 100
if (pat.length) { // patron changé → mettre à jour le quart pattern (+ nettoyer doublons)
if (dryRun) updated++; else { const rr = await erp.update('Shift Assignment', pat[0].name, { shift_template: tplName, hours }); if (rr && rr.ok) updated++ }
for (const extra of pat.slice(1)) { if (!dryRun) await erp.remove('Shift Assignment', extra.name) }
} else if (dryRun) created++
else { const rr = await retryWrite(() => erp.create('Shift Assignment', { technician: t.id, technician_name: t.name, assignment_date: iso, shift_template: tplName, hours, status: 'Publié', source: 'pattern' })); if (rr && rr.ok) created++; else { createFail++; if (createFail <= 3) log('[materialize] SA create FAIL ' + t.name + ' ' + iso + ' → ' + JSON.stringify(rr).slice(0, 220)) } }
// 1 quart par slot (template = heures). Réconcilie : garde les existants (curation `function` préservée),
// supprime les slots retirés + doublons, crée les manquants (rôle du patron sur les NOUVEAUX seulement).
const wantTpls = []
for (const s of slots) { const tpl = await ensureShiftTemplate(s.start, s.end, tplCache); if (!tpl) { tplNull++; continue } wantTpls.push({ tpl, fn: s.function || '', hours: Math.round((_timeH(s.end) - _timeH(s.start)) * 100) / 100 }) }
const wantSet = new Set(wantTpls.map(w => w.tpl))
const fnByTpl = {}; for (const w of wantTpls) if (!(w.tpl in fnByTpl)) fnByTpl[w.tpl] = w.fn || '' // rôle voulu par le patron, par template
const seen = new Set()
for (const r of pat) {
if (wantSet.has(r.shift) && !seen.has(r.shift)) { // voulu + 1re occurrence → conserver, MAIS synchroniser le rôle du patron
seen.add(r.shift)
const wantFn = fnByTpl[r.shift] || ''
if (!dryRun && wantFn !== (r.function || '')) { const u = await retryWrite(() => erp.update('Shift Assignment', r.name, { function: wantFn })); if (u && u.ok) updated++ } // le rôle du patron s'applique AUSSI aux quarts existants
continue
}
if (dryRun) deleted++; else { const rr = await erp.remove('Shift Assignment', r.name); if (rr && rr.ok) deleted++ } // slot retiré OU doublon
}
for (const w of wantTpls) {
if (seen.has(w.tpl)) continue
seen.add(w.tpl)
if (dryRun) { created++; continue }
const rr = await retryWrite(() => erp.create('Shift Assignment', { technician: t.id, technician_name: t.name, assignment_date: iso, shift_template: w.tpl, hours: w.hours, status: 'Publié', source: 'pattern', function: w.fn }))
if (rr && rr.ok) created++; else { createFail++; if (createFail <= 3) log('[materialize] SA create FAIL ' + t.name + ' ' + iso + ' → ' + JSON.stringify(rr).slice(0, 220)) }
}
}
}
const result = { ok: true, dryRun, weeks: weeksN, techs: techs.length, created, updated, deleted, kept_manual: keptManual, skipped_holiday: skipHoliday, skipped_vacation: skipVacation, tpl_null: tplNull, create_fail: createFail }
@ -683,7 +760,7 @@ function skillForJob (job) {
// Sert à colorer les tickets par la couleur de leur compétence (éditable via le gestionnaire de tags).
// SOURCE UNIQUE : la logique vit dans lib/skill-resolver.js (partagée avec le résolveur raison→compétence
// et le prédicat de disponibilité) ; ré-exportée ici pour ne rien casser des appelants existants.
const { deptToSkill, techHasSkills } = require('./skill-resolver')
const { deptToSkill, techHasSkills, effectiveSkills, listFunctions, setFunction, deleteFunction, getSkillExceptions, getSkillExceptionsMap, setSkillExceptions } = require('./skill-resolver')
// Enrichit des jobs avec une adresse LISIBLE (le champ service_location est un code « LOC-… »).
// Batch : 1 seule requête sur Service Location pour tous les codes distincts.
async function attachLocations (jobs) {
@ -791,7 +868,11 @@ const BOOKING_TRAVEL_BUF_H = 0.25 // 15 min
function techGaps (a, d, skill, zone, bufferH = 0) {
const t = d.techById[a.tech]; if (!t || t.status === PAUSE_STATUS) return null
if (d.unavail && d.unavail[a.tech] && d.unavail[a.tech].has(a.date)) return null // absence/congé approuvé ce jour-là → pas de créneaux
if (skill && !(t.skills || []).includes(skill)) return null
if (skill) {
const _tp = d.tplByName[a.shift]
const _eff = effectiveSkills(t, a.date, { shifts: [{ function: a.function || '', start: _tp && _tp.start_time, end: _tp && _tp.end_time }] }).skills
if (!_eff.includes(skill)) return null // compétence EFFECTIVE de ce quart (fonction ± exception) ; `function` inerte avant migration
}
if (zone && a.zone && a.zone !== zone) return null
const tpl = d.tplByName[a.shift]; if (!tpl) return null
if (tpl.on_call) return null // garde (sur appel) = capacité d'urgence, JAMAIS offerte au booking client
@ -1407,7 +1488,7 @@ async function occupancyByTechDay (start, days) {
const s = j.start_time ? timeToH(j.start_time) : null
if (s != null && !cancelled) o.blocks.push({ s, e: s + dur, skill, job: j.name, done }) // 1 bloc = 1 job, coloré par sa compétence
const actualMin = (j.actual_start && j.actual_end) ? Math.max(0, Math.round((Date.parse(j.actual_end.replace(' ', 'T')) - Date.parse(j.actual_start.replace(' ', 'T'))) / 60000)) : null
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', customer_id: j.customer || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: estMin, priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', status: j.status || '', done, cancelled, legacy_id: j.legacy_ticket_id || '', legacy_dept: j.legacy_dept || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: j.actual_start || '', actual_end: j.actual_end || '', actual_min: actualMin })
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', customer_id: j.customer || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: estMin, priority: j.priority || 'low', job_type: j.job_type || '', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', status: j.status || '', done, cancelled, legacy_id: j.legacy_ticket_id || '', legacy_dept: j.legacy_dept || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: j.actual_start || '', actual_end: j.actual_end || '', actual_min: actualMin })
}
// ── ÉQUIPE : reporte la charge des assistants ÉPINGLÉS sur LEUR propre lane (mirror) ──
// 1 requête enfant UNIQUE (pas de N+1). Le lead reste compté via assigned_tech ci-dessus ;
@ -1455,6 +1536,27 @@ async function absencesByTechDay (start, days) {
return m
}
// ── COUVERTURE terrain : par jour, nb de techs avec un quart RÉGULIER (garde exclue) et PRÉSENTS (ni pause ni congé).
// Signale les jours SANS aucun tech terrain (week-ends exclus — souvent volontairement vides). Le helpdesk est géré à part.
async function coverageCheck (start, days) {
const dates = rangeDates(start, days)
const [asgs, templates, techsActive, absences] = await Promise.all([
fetchAssignments(start, days), fetchTemplates(), fetchTechnicians(), absencesByTechDay(start, days),
])
const tpl = {}; for (const t of templates) tpl[t.name] = t
const valid = new Set(techsActive.map(t => t.id)) // exclut les archivés
const byDay = {}; for (const d of dates) byDay[d] = new Set()
for (const a of asgs) {
if (!byDay[a.date] || !valid.has(a.tech)) continue
if (absences[a.tech + '|' + a.date]) continue // pause / congé approuvé
const t = tpl[a.shift]; if (t && t.on_call) continue // garde (sur appel) ≠ couverture terrain régulière
byDay[a.date].add(a.tech)
}
const daysOut = dates.map(d => { const dow = new Date(d + 'T12:00:00Z').getUTCDay(); return { date: d, dow, weekend: dow === 0 || dow === 6, techs: byDay[d].size, covered: byDay[d].size > 0 } })
const gaps = daysOut.filter(o => !o.covered && !o.weekend) // jours de semaine sans aucun tech terrain
return { ok: gaps.length === 0, days: daysOut, gaps }
}
// ── Routeur ──────────────────────────────────────────────────────────────────
// technician_id n'est pas le docname → résoudre le docname Dispatch Technician.
async function resolveTechName (techId) {
@ -1482,12 +1584,21 @@ async function capacityByDay (start, days, skills = null) {
// Capacité RÉELLE : un quart matérialisé subsiste même quand le tech est en congé/pause/archivé → on retranche ces
// (tech, jour) sinon le dénominateur gonfle (ex. « 24/150 » alors que plusieurs techs sont absents). Aligné sur le
// moteur de créneaux + le solveur (buildUnavailability = En pause + absence_from/until + Tech Availability approuvé).
let techsActive = await fetchTechnicians() // exclut les techs archivés
// FILTRE COMPÉTENCE (optionnel) : le dénominateur ne compte QUE les techs qui ont la/les compétence(s) demandée(s).
// Ex. « installation » → seuls les installateurs comptent (fini « 150 h dispo » alors que la moitié ne peut pas installer).
const techsActive = await fetchTechnicians() // exclut les techs archivés
const techByIdCap = {}; for (const t of techsActive) techByIdCap[t.id] = t
// FILTRE COMPÉTENCE (optionnel) : le dénominateur ne compte QUE les techs qui ont la/les compétence(s) demandée(s)
// CE JOUR-LÀ (base ± fonction du quart ± exception) — évalué PAR JOUR (pas en pré-filtre), car une fonction peut
// retirer/ajouter une compétence pour une journée. Ex. « installation » → seuls les installateurs de ce jour comptent.
const wantSkills = (Array.isArray(skills) ? skills : (skills ? [skills] : [])).map(s => String(s || '').trim()).filter(Boolean)
if (wantSkills.length) techsActive = techsActive.filter(t => techHasSkills(t.skills, wantSkills))
const validTech = new Set(techsActive.map(t => t.id))
// Quarts du jour par tech (fonction éventuelle → compétences dépendantes du jour). `function` inerte tant que non migré+lu.
const capShiftsBy = {}
for (const a of asgs) { (capShiftsBy[a.tech + '|' + a.date] = capShiftsBy[a.tech + '|' + a.date] || []).push({ function: a.function || '' }) }
const dayCovers = (techId, date) => {
if (!wantSkills.length) return true
const t = techByIdCap[techId]; if (!t) return false
return techHasSkills(effectiveSkills(t, date, { shifts: capShiftsBy[techId + '|' + date] || [] }).skills, wantSkills)
}
const unavail = await buildUnavailability(techsActive, dates)
const isUnavailable = (tech, date) => !validTech.has(tech) || (unavail[tech] && unavail[tech].has(date))
const jobs = await erp.list('Dispatch Job', {
@ -1500,6 +1611,7 @@ async function capacityByDay (start, days, skills = null) {
for (const a of asgs) {
const o = out[a.date]; if (!o) continue
if (isUnavailable(a.tech, a.date)) continue // congé / pause / archivé → ne compte pas dans la capacité du jour
if (wantSkills.length && !dayCovers(a.tech, a.date)) continue // ce jour-là, la fonction/exception retire la compétence demandée
const w = tpl[a.shift]; if (!w || !(w.e > w.s)) continue
DAY_SEGMENTS.forEach((seg, i) => { o.segments[i].cap += segOverlap(w.s, w.e, seg.from, seg.to) })
const tk = a.date + '|' + a.tech; if (!techSeen[tk]) { techSeen[tk] = 1; o.techs++ }
@ -1509,7 +1621,7 @@ async function capacityByDay (start, days, skills = null) {
// Sous filtre compétence : ne compter que ce qui occupe VRAIMENT un tech qualifié (assigné à un tech du dénominateur).
// On ignore les jobs assignés à un tech non qualifié + les jobs non placés (compétence inconnue ici) → used/due
// restent cohérents avec la capacité réduite (sinon due_h > cap_h ⇒ « surbooké » faux systématique).
if (wantSkills.length && (!j.assigned_tech || !validTech.has(j.assigned_tech))) continue
if (wantSkills.length && (!j.assigned_tech || !dayCovers(j.assigned_tech, j.scheduled_date))) continue
const dur = Number(j.duration_h) || 0
o.due_h += dur; o.jobs_due++
const sh = j.start_time ? timeToH(j.start_time) : null
@ -1726,14 +1838,29 @@ async function handle (req, res, method, path, url) {
if (!start) return json(res, 400, { error: 'start requis' })
return json(res, 200, { absences: await absencesByTechDay(start, days) })
}
if (path === '/roster/field-coverage' && method === 'GET') { // couverture terrain par jour (jours de semaine sans aucun tech de quart) — distinct de /roster/coverage (rapport solveur par compétence)
if (!start) return json(res, 400, { error: 'start requis' })
return json(res, 200, await coverageCheck(start, days))
}
// Absence d'UN JOUR depuis la grille (approuvée → hachurée tout de suite) ou retrait (jour unique seulement).
if (path === '/roster/absence/set' && method === 'POST') {
const b = await parseBody(req)
if (!b.tech || !b.date) return json(res, 400, { error: 'tech + date requis' })
if (b.remove) {
const rows = await erp.list('Tech Availability', { filters: [['technician', '=', b.tech], ['from_date', '=', b.date], ['to_date', '=', b.date]], fields: ['name'], limit: 20 })
let removed = 0; for (const r of rows) { const x = await retryWrite(() => erp.remove('Tech Availability', r.name)); if (x.ok) removed++ }
return json(res, 200, { ok: true, removed })
// Retire le jour `date` de TOUTE dispo qui le COUVRE (pas seulement les enregistrements 1 jour) : supprime si mono-jour,
// rogne si le jour est au bord, SCINDE si le jour est à l'intérieur d'une plage multi-jours. Corrige « le congé revient
// à la publication » (avant : filtre from=to=date → aucun match sur une plage → rien supprimé).
const d = b.date
const prev = iso(addDays(parseISO(d), -1)); const next = iso(addDays(parseISO(d), 1))
const rows = await erp.list('Tech Availability', { filters: [['technician', '=', b.tech], ['from_date', '<=', d], ['to_date', '>=', d]], fields: ['name', 'from_date', 'to_date', 'availability_type', 'status', 'reason'], limit: 50 })
let removed = 0; let trimmed = 0
for (const r of rows) {
if (r.from_date === d && r.to_date === d) { const x = await retryWrite(() => erp.remove('Tech Availability', r.name)); if (x && x.ok) removed++ }
else if (r.from_date === d) { const x = await retryWrite(() => erp.update('Tech Availability', r.name, { from_date: next })); if (x && x.ok) trimmed++ }
else if (r.to_date === d) { const x = await retryWrite(() => erp.update('Tech Availability', r.name, { to_date: prev })); if (x && x.ok) trimmed++ }
else { const origTo = r.to_date; const u = await retryWrite(() => erp.update('Tech Availability', r.name, { to_date: prev })); await retryWrite(() => erp.create('Tech Availability', { technician: b.tech, from_date: next, to_date: origTo, availability_type: r.availability_type || 'Congé', status: r.status || 'Approuvé', reason: r.reason || 'Grille' })); if (u && u.ok) trimmed++ }
}
return json(res, 200, { ok: true, removed, trimmed })
}
const r = await retryWrite(() => erp.create('Tech Availability', { technician: b.tech, from_date: b.date, to_date: b.date, availability_type: b.type || 'Congé', status: 'Approuvé', reason: 'Grille' }))
return json(res, r.ok ? 200 : 500, r)
@ -1924,6 +2051,61 @@ async function handle (req, res, method, path, url) {
const m = setJobSkills(b.name, b.skills); invalidatePool() // le pool porte required_skills/required_skill → rafraîchir
return json(res, 200, { ok: true, name: b.name, skills: m[String(b.name)] || [] })
}
// ── FONCTIONS (skillsets nommés réutilisables) — catalogue hub ──────────────────────────────────
// Une fonction portée par un quart définit ce que le dispatch TERRAIN peut envoyer ce quart-là :
// mode 'replace' (remplace les compétences de base ce jour) ou 'add' (s'y ajoute). Le helpdesk = à part.
if (path === '/roster/functions' && method === 'GET') {
return json(res, 200, { functions: listFunctions() })
}
if (path === '/roster/functions' && method === 'POST') {
const b = await parseBody(req)
if (!b || !(b.id || b.name)) return json(res, 400, { error: 'id ou name requis' })
setFunction(b)
return json(res, 200, { ok: true, functions: listFunctions() })
}
if (path === '/roster/functions/delete' && method === 'POST') {
const b = await parseBody(req); if (!b || !b.id) return json(res, 400, { error: 'id requis' })
deleteFunction(b.id)
return json(res, 200, { ok: true, functions: listFunctions() })
}
// ── FILES DE GARDE (rotations on-call) — store hub PARTAGÉ (remplace le localStorage par-navigateur) ──
if (path === '/roster/garde/queues' && method === 'GET') {
return json(res, 200, { queues: listGardeQueues() })
}
if (path === '/roster/garde/queues' && method === 'POST') { // upsert UNE file (id requis)
const b = await parseBody(req); if (!b || !b.id) return json(res, 400, { error: 'id requis' })
return json(res, 200, { ok: true, queues: upsertGardeQueue(b) })
}
if (path === '/roster/garde/queues/replace' && method === 'POST') { // remplace TOUT le tableau (migration + réordonnancement)
const b = await parseBody(req)
return json(res, 200, { ok: true, queues: saveGardeQueues(b && b.queues) })
}
if (path === '/roster/garde/queues/delete' && method === 'POST') {
const b = await parseBody(req); if (!b || !b.id) return json(res, 400, { error: 'id requis' })
return json(res, 200, { ok: true, queues: saveGardeQueues(listGardeQueues().filter(q => q.id !== String(b.id))) })
}
// Horizon matérialisé de la garde : date la PLUS LOINTAINE avec une garde (on_call) publiée → le client sait
// s'il doit prolonger (auto top-up de la queue). Cheap : 1 requête max-date. Aucune écriture.
if (path === '/roster/garde/status' && method === 'GET') {
const gardeTpls = (await fetchTemplates()).filter(t => t.on_call).map(t => t.name)
let furthest = null
if (gardeTpls.length) {
try { const rows = await erp.list('Shift Assignment', { filters: [['shift_template', 'in', gardeTpls]], fields: ['assignment_date'], orderBy: 'assignment_date desc', limit: 1 }); if (rows && rows[0]) furthest = rows[0].assignment_date } catch (e) {}
}
return json(res, 200, { furthest })
}
// ── EXCEPTIONS de compétence PONCTUELLES (par tech, plage de dates) ──────────────────────────────
// Ex. « mal de dos → pas d'installation cette semaine » = {mode:'remove', skills:['installation']}.
if (path === '/roster/skill-exceptions' && method === 'GET') {
const tech = qs.get('tech') || ''
if (!tech) return json(res, 200, { all: getSkillExceptionsMap() }) // sans ?tech → tous les techs (pour afficher les ajustements sur la grille)
return json(res, 200, { tech, exceptions: getSkillExceptions(tech) })
}
if (path === '/roster/skill-exceptions' && method === 'POST') {
const b = await parseBody(req); if (!b || !b.tech) return json(res, 400, { error: 'tech requis' })
setSkillExceptions(b.tech, b.exceptions || [])
return json(res, 200, { ok: true, tech: b.tech, exceptions: getSkillExceptions(b.tech) })
}
// MÉDIAS TERRAIN d'un job : photos prises par le tech (app terrain, /field/photo) + journal completion_notes
// (arrivées géofence, scans d'appareil, notes). Sert le module « Photos & activité terrain » du détail ticket/job.
// Les <img> passent par /field/photo-file (URL signée fieldSign — même modèle de jeton que l'app terrain).
@ -2281,18 +2463,27 @@ async function handle (req, res, method, path, url) {
let deleted = 0; let created = 0; let errors = 0; let unchanged = 0; let promoted = 0
for (const a of existing) { // supprimer ceux qui ne sont plus voulus
if (desiredKeys.has(keyOf(a))) continue
// BUG « publié puis à republier » : l'AUTO-SAVE (draft) envoie l'état COURANT (fenêtre partielle / possiblement
// désynchro du cron de matérialisation ou d'un autre répartiteur). Il ne doit JAMAIS supprimer un quart PUBLIÉ —
// sinon le quart disparaît et doit être recréé (→ redevient « Proposé »). Seule une action EXPLICITE
// (publish/submit/approve) peut retirer un quart publié. Le draft ne nettoie que les brouillons réellement retirés.
if (mode === 'draft' && a.status === 'Publié') continue
const r = await retryWrite(() => erp.remove('Shift Assignment', a.name)); if (r.ok) deleted++
}
for (const a of desired) {
const ex = existByKey[keyOf(a)]
if (ex) { // draft = laisser tel quel ; submit/approve/publish = faire MONTER le statut (jamais rétrograder un Publié)
if (mode !== 'draft' && ex.status !== 'Publié' && ex.status !== targetStatus) { const r = await retryWrite(() => erp.update('Shift Assignment', ex.name, { status: targetStatus })); if (r.ok) promoted++; else errors++ } else unchanged++
if (ex) { // quart existant : monter le statut (jamais rétrograder un Publié) ET persister un rôle changé (même en brouillon)
const patch = {}
if (mode !== 'draft' && ex.status !== 'Publié' && ex.status !== targetStatus) patch.status = targetStatus
if ((a.function || '') !== (ex.function || '')) patch.function = a.function || '' // rôle du quart modifié → écrire (sinon perdu)
if (Object.keys(patch).length) { const r = await retryWrite(() => erp.update('Shift Assignment', ex.name, patch)); if (r.ok) { if (patch.status) promoted++; else unchanged++ } else errors++ } else unchanged++
continue
}
const r = await retryWrite(() => erp.create('Shift Assignment', {
technician: a.tech, technician_name: a.tech_name || '', assignment_date: a.date,
shift_template: a.shift, zone: a.zone || '', hours: Number(a.hours) || 0,
status: (mode === 'draft' ? 'Proposé' : targetStatus), source: a.source || 'manuel',
function: a.function || '', // rôle du quart posé dans la grille → conservé au brouillon ET à la publication
}))
if (r.ok) created++; else errors++
}
@ -2518,6 +2709,32 @@ async function handle (req, res, method, path, url) {
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { traccar_device_id: deviceId })); if (r && r.ok) invalidateTechCache()
return json(res, r.ok ? 200 : 500, { ...r, technician: techId, traccar_device_id: deviceId })
}
// Détection des CONFLITS de quarts sur l'horizon : chevauchements réguliers (souvent doublons manuel+patron) ET quarts
// posés sur un CONGÉ approuvé (le congé est prioritaire). → l'outil « Corriger les chevauchements » les résout 1 par 1.
if (path === '/roster/overlaps' && method === 'GET') {
const u = new URL(req.url, 'http://localhost')
const weeks = Math.max(1, Math.min(12, Number(u.searchParams.get('weeks')) || 6))
const start = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
const days = weeks * 7
const [asgs, tpls, absences] = await Promise.all([fetchAssignments(start, days), fetchTemplates(), absencesByTechDay(start, days)])
const win = {}; for (const t of tpls) win[t.name] = { start: t.start_time || '', end: t.end_time || '', on_call: !!t.on_call, label: String(t.start_time || '').slice(0, 5) + '' + String(t.end_time || '').slice(0, 5) }
const byKey = {}; for (const a of asgs) { (byKey[a.tech + '|' + a.date] = byKey[a.tech + '|' + a.date] || []).push(a) }
const mk = (a) => ({ name: a.name, shift: a.shift, label: (win[a.shift] && win[a.shift].label) || a.shift_name || a.shift, role: a.function || '', source: a.source || '', status: a.status || '' })
const conflicts = []
for (const key of Object.keys(byKey)) {
const [tech, date] = key.split('|'); const rows = byKey[key]
const reg = rows.filter(a => win[a.shift] && !win[a.shift].on_call)
if (!reg.length) continue
const nm = reg[0].tech_name || tech
if (absences[key]) { conflicts.push({ tech, tech_name: nm, date, type: 'vacation', shifts: reg.map(mk) }); continue } // quart sur congé → congé prioritaire
const sorted = reg.slice().sort((x, y) => String(win[x.shift].start).localeCompare(String(win[y.shift].start)))
let overlap = false
for (let i = 1; i < sorted.length; i++) { if (String(win[sorted[i].shift].start) < String(win[sorted[i - 1].shift].end)) { overlap = true; break } } // aStart < prevEnd (compare HH:MM:SS)
if (overlap) conflicts.push({ tech, tech_name: nm, date, type: 'overlap', shifts: reg.map(mk) })
}
conflicts.sort((a, b) => String(a.date).localeCompare(String(b.date)) || String(a.tech_name).localeCompare(String(b.tech_name)))
return json(res, 200, { ok: true, weeks, count: conflicts.length, conflicts })
}
// Patron d'horaire RÉCURRENT (weekly_schedule) — source des quarts matérialisés (hybride). body.schedule = {mon:{start,end}|null,…}
const mSched = path.match(/^\/roster\/technician\/(.+)\/weekly-schedule$/)
if (mSched && method === 'POST') {
@ -2527,10 +2744,18 @@ async function handle (req, res, method, path, url) {
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { weekly_schedule: sched ? JSON.stringify(sched) : '' })); if (r && r.ok) invalidateTechCache()
return json(res, r.ok ? 200 : 500, { ...r, technician: techId })
}
// Fonction (skillset) posée sur UN quart précis (override ponctuel d'un Shift Assignment). '' = retirer.
// ⚠️ écrit le DocField `function` → nécessite la migration ; à déployer AVEC elle.
const mAsgFn = path.match(/^\/roster\/assignment\/(.+)\/function$/)
if (mAsgFn && method === 'POST') {
const name = decodeURIComponent(mAsgFn[1]); const b = await parseBody(req)
const r = await retryWrite(() => erp.update('Shift Assignment', name, { function: String(b.function || '') }))
return json(res, r && r.ok ? 200 : 500, r && r.ok ? { ok: true, name, function: String(b.function || '') } : { ok: false, error: (r && r.error) || 'échec' })
}
// HYBRIDE : matérialise les quarts depuis les patrons (fériés/vacances sautés, manuels préservés). GET=aperçu / POST=applique. ?weeks= ?tech=
if (path === '/roster/materialize-shifts' && (method === 'GET' || method === 'POST')) {
const u = new URL(req.url, 'http://localhost'); const b = method === 'POST' ? await parseBody(req) : {}
return json(res, 200, await materializeShifts({ dryRun: method === 'GET', weeks: Number(b.weeks || u.searchParams.get('weeks')) || 4, techId: b.tech || u.searchParams.get('tech') || null }))
return json(res, 200, await materializeShifts({ dryRun: method === 'GET' || !!b.dry_run, weeks: Number(b.weeks || u.searchParams.get('weeks')) || 4, techId: b.tech || u.searchParams.get('tech') || null, rolesToManual: !!(b.roles_to_manual || u.searchParams.get('roles_to_manual')), replaceManual: !!(b.replace_manual || u.searchParams.get('replace_manual')) }))
}
// Supprimer une assignation publiée
const mDelA = path.match(/^\/roster\/assignment\/(.+)$/)

View File

@ -10,6 +10,8 @@
//
// 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')
@ -19,6 +21,10 @@ const SKILL_VOCAB = ['installation', 'réparation', 'tv', 'telephone', 'épissur
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
@ -52,7 +58,9 @@ function deptToSkill (txt) {
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.)
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/.test(d)) return 'réparation'
// É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 ''
}
@ -129,9 +137,134 @@ async function resolveSkills ({ text = '', department = '', jobType = '', useAI
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,
@ -139,4 +272,13 @@ module.exports = {
DEPARTMENT_SKILLS,
aiSkills,
resolveSkills,
// Fonctions (catalogue) + exceptions + résolveur jour-conscient :
listFunctions,
getFunction,
setFunction,
deleteFunction,
getSkillExceptions,
getSkillExceptionsMap,
setSkillExceptions,
effectiveSkills,
}