fix: capacité réelle (hors congé/pause/archivé), archive via store hub, calendrier horaire tech

- capacité/occupation par jour : retranche les (tech,jour) en congé approuvé /
  pause / archivé du dénominateur (dashboard cap_h + bande Planif visStat) —
  fini le « 24/150 » gonflé par des techs indisponibles
- archive technicien : store hub durable (data/archived_techs.json) au lieu
  d'écrire status=Archivé (ERPNext v16 rejette la valeur hors liste → 500) ;
  exclusion partout (roster/solveur/créneaux/occupation) + restauration
- TechScheduleDialog calendrier : jours SANS quart grisés (comme week-end),
  jours AVEC quart en blanc + mince barre d'occupation → vraie dispo du tech
- techOccupancy : horizon jusqu'à 42 j (calendrier mois)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-08 14:46:21 -04:00
parent d7412fcced
commit 844eff9a49
4 changed files with 57 additions and 18 deletions

View File

@ -65,6 +65,11 @@
<template v-if="c.inMonth">
<span class="ts-daynum">{{ c.day }}</span>
<span v-if="c.absType" class="ts-abs" :style="{ background: absColor(c.absType) }"><q-tooltip>{{ c.absType }}</q-tooltip></span>
<!-- Jour AVEC quart (et sans absence) : mince barre d'occupation (vertambrerouge). Jour sans quart = fond gris. -->
<template v-else-if="c.hasShift">
<span class="ts-occ"><span class="ts-occ-fill" :style="{ width: Math.round((c.occupancy || 0) * 100) + '%', background: occColor(c.occupancy) }"></span></span>
<q-tooltip>Quart · {{ c.occupancy != null ? Math.round(c.occupancy * 100) + '% occupé' : 'libre' }}</q-tooltip>
</template>
<span v-if="c.holiday" class="ts-holi" title="Férié"></span>
</template>
</div>
@ -94,6 +99,7 @@
import { ref, reactive, computed, watch } from 'vue'
import { useQuasar } from 'quasar'
import * as roster from 'src/api/roster'
import { techOccupancy } from 'src/api/dispatch' // occupation/quart par jour du tech grise les jours sans quart, barre d'occupation les jours avec quart
const props = defineProps({
modelValue: { type: Boolean, default: false },
@ -138,6 +144,7 @@ const brush = ref('Congé')
const month = ref('') // 'YYYY-MM'
const calLoading = ref(false)
const absMap = ref({}) // 'YYYY-MM-DD' type d'absence
const shiftMap = ref({}) // 'YYYY-MM-DD' { shift:bool, occupancy:0..1 } (quart réel du tech ce jour) gris = pas de quart
const holiSet = ref(new Set())
const drag = reactive({ on: false, sel: new Set() })
const selCount = computed(() => drag.sel.size)
@ -159,7 +166,8 @@ const monthCells = computed(() => {
for (let day = 1; day <= daysInMonth; day++) {
const iso = y + '-' + pad(m) + '-' + pad(day)
const dow = new Date(iso + 'T12:00:00').getUTCDay()
cells.push({ inMonth: true, day, iso, dow, weekend: dow === 0 || dow === 6, isToday: iso === t, absType: absMap.value[iso] || null, holiday: holiSet.value.has(iso), sel: drag.sel.has(iso) })
const sh = shiftMap.value[iso]
cells.push({ inMonth: true, day, iso, dow, weekend: dow === 0 || dow === 6, isToday: iso === t, absType: absMap.value[iso] || null, holiday: holiSet.value.has(iso), sel: drag.sel.has(iso), hasShift: !!(sh && sh.shift), occupancy: sh ? sh.occupancy : null })
}
while (cells.length % 7) cells.push({ inMonth: false })
return cells
@ -167,9 +175,12 @@ const monthCells = computed(() => {
function cellClass (c) {
if (!c.inMonth) return 'ts-cell-empty'
return { weekend: c.weekend, today: c.isToday, sel: drag.sel.has(c.iso), off: !!c.absType }
// Gris = pas de quart ce jour (comme un week-end) montre la vraie dispo. Blanc = quart (barre d'occupation).
// Une absence (congé/maladie) garde son fond ambré et sa barre colorée par-dessus.
return { today: c.isToday, sel: drag.sel.has(c.iso), off: !!c.absType, noshift: !c.absType && !c.hasShift }
}
function absColor (type) { const b = BRUSHES.find(x => x.type === type); return b ? b.color : '#94a3b8' }
function occColor (o) { if (o == null) return '#94a3b8'; if (o < 0.5) return '#22c55e'; if (o < 0.85) return '#f59e0b'; return '#ef4444' } // occupation : vert < ambre < rouge
function shiftMonth (n) {
const [y, m] = month.value.split('-').map(Number)
@ -184,9 +195,10 @@ async function loadCal () {
const [y, m] = month.value.split('-').map(Number)
const start = y + '-' + pad(m) + '-01'
const days = new Date(Date.UTC(y, m, 0)).getUTCDate()
const [absRes, holiRes] = await Promise.all([
const [absRes, holiRes, occRes] = await Promise.all([
roster.getAbsences(start, days),
roster.holidays(start, y + '-' + pad(m) + '-' + pad(days)),
techOccupancy({ after_date: start, days, skill: '' }).catch(() => null), // quart + occupation par jour (tous techs) on garde le nôtre
])
const out = {}
const abs = (absRes && absRes.absences) || {}
@ -196,6 +208,11 @@ async function loadCal () {
}
absMap.value = out
holiSet.value = new Set((holiRes && (holiRes.holidays || [])).map(h => h.date || h))
// Quart réel par jour pour CE tech (jour « off » = pas de quart grisé ; sinon barre d'occupation).
const sh = {}
const mine = occRes && (occRes.techs || []).find(x => x.tech_id === props.tech.id || x.tech_name === props.tech.name)
if (mine) for (const d of (mine.days || [])) sh[d.date] = { shift: !d.off, occupancy: d.occupancy }
shiftMap.value = sh
} catch (e) { err(e) } finally { calLoading.value = false }
}
@ -245,7 +262,7 @@ watch(() => props.modelValue, (o) => {
pauseNote.value = ''
const t = todayISO()
month.value = t.slice(0, 7)
absMap.value = {}; holiSet.value = new Set(); drag.on = false; drag.sel = new Set()
absMap.value = {}; shiftMap.value = {}; holiSet.value = new Set(); drag.on = false; drag.sel = new Set()
loadCal()
})
</script>
@ -257,13 +274,16 @@ watch(() => props.modelValue, (o) => {
.ts-dowh { text-align: center; font-size: 10.5px; color: #64748b; font-weight: 700; padding-bottom: 2px; }
.ts-cell { position: relative; aspect-ratio: 1 / 1; border: 1px solid #e2e8f0; border-radius: 6px; cursor: pointer; display: flex; align-items: flex-start; justify-content: flex-start; padding: 3px 5px; background: #fff; transition: background .08s, border-color .08s; }
.ts-cell:hover { border-color: #2563eb; }
.ts-cell.weekend { background: #f8fafc; }
.ts-cell.noshift { background: #eef1f4; border-color: #e2e8f0; } /* pas de quart ce jour → grisé (montre la vraie dispo) */
.ts-cell.noshift:hover { border-color: #cbd5e1; }
.ts-cell.today { border-color: #2563eb; box-shadow: inset 0 0 0 1px #2563eb; }
.ts-cell.sel { background: #dbeafe; border-color: #2563eb; }
.ts-cell.off { background: #fff7ed; }
.ts-cell-empty { border: none; background: transparent; cursor: default; }
.ts-daynum { font-size: 11.5px; color: #334155; font-weight: 600; }
.ts-abs { position: absolute; left: 4px; right: 4px; bottom: 4px; height: 5px; border-radius: 3px; }
.ts-occ { position: absolute; left: 4px; right: 4px; bottom: 4px; height: 4px; border-radius: 3px; background: #e5e7eb; overflow: hidden; }
.ts-occ-fill { display: block; height: 100%; border-radius: 3px; }
.ts-holi { position: absolute; top: 2px; right: 3px; color: #b45309; font-size: 10px; }
.ts-legend { font-size: 10.5px; color: #475569; }
.ts-dot { display: inline-block; width: 9px; height: 9px; border-radius: 2px; vertical-align: middle; margin-right: 3px; }

View File

@ -2014,6 +2014,7 @@ const visStatByDay = computed(() => {
const vis = new Set(visibleTechs.value.map(t => t.id)); const tpl = tplByName.value; const agg = {}
for (const a of assignments.value) {
if (!vis.has(a.tech)) continue
if (absByTechDay.value[a.tech + '|' + a.date]) continue // congé / pause ce jour quart matérialisé mais tech absent : hors capacité
const t = tpl[a.shift]; if (t && t.on_call) continue // garde = mise en dispo, pas travaillée
const o = agg[a.date] || (agg[a.date] = { hours: 0, staff: new Set() })
o.hours += Number(a.hours) || 0; o.staff.add(a.tech)

View File

@ -1,6 +1,8 @@
'use strict'
const cfg = require('./config')
const { log, json, parseBody, erpFetch } = require('./helpers')
const { log, json, parseBody, erpFetch, readJsonFile } = require('./helpers')
// Techs archivés (store hub partagé avec roster.js) → exclus des créneaux + occupation. Lu à chaud (change rarement).
const archivedTechSet = () => new Set((readJsonFile('/app/data/archived_techs.json', []) || []).map(String))
const SCORE_WEIGHTS = {
proximityMultiplier: 4, // distance (km, capped at 100) * this = proximity penalty
@ -162,7 +164,7 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
if (techRes.status !== 200) throw new Error('Failed to fetch technicians')
// FILTRE COMPÉTENCE : ne garder que les techs qui ONT le skill demandé (champ skills ou tags Frappe).
const hasSkill = (t) => { if (!wantSkill) return true; return String(t.skills || t._user_tags || '').toLowerCase().split(/[,;]/).some(s => { s = s.trim(); return s && (s === wantSkill || (s.length >= 3 && (s.includes(wantSkill) || wantSkill.includes(s)))) }) }
const techs = (techRes.data.data || []).filter(t => t.status !== 'unavailable' && t.status !== 'Archivé' && t.status !== 'En pause' && hasSkill(t))
const techs = (() => { const _arch = archivedTechSet(); return (techRes.data.data || []).filter(t => t.status !== 'unavailable' && t.status !== 'En pause' && !_arch.has(t.technician_id) && !_arch.has(t.name) && hasSkill(t)) })()
// Jours de congé approuvé par tech (clé = technician, qui correspond au technician_id ou au docname). Aligné sur roster.buildUnavailability.
const vacBy = {}
for (const a of ((availRes.data && availRes.data.data) || [])) {
@ -300,7 +302,7 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
// visualisation « bandes verticales » de « Trouver un créneau » : on voit d'un coup qui a de la place pour le skill choisi.
async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = '' } = {}) {
const baseDate = after_date || todayET()
const span = Math.max(1, Math.min(21, parseInt(days, 10) || SLOT_HORIZON_DAYS))
const span = Math.max(1, Math.min(42, parseInt(days, 10) || SLOT_HORIZON_DAYS)) // jusqu'à 6 semaines (calendrier mois de l'horaire tech)
const dates = Array.from({ length: span }, (_, i) => dateAddDays(baseDate, i))
const wantSkill = String(skill || '').trim().toLowerCase()
@ -321,7 +323,7 @@ async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = ''
])
if (techRes.status !== 200) throw new Error('Failed to fetch technicians')
const hasSkill = (t) => { if (!wantSkill) return true; return String(t.skills || t._user_tags || '').toLowerCase().split(/[,;]/).some(s => { s = s.trim(); return s && (s === wantSkill || (s.length >= 3 && (s.includes(wantSkill) || wantSkill.includes(s)))) }) }
const techs = (techRes.data.data || []).filter(t => t.status !== 'unavailable' && t.status !== 'Archivé' && t.status !== 'En pause' && hasSkill(t))
const techs = (() => { const _arch = archivedTechSet(); return (techRes.data.data || []).filter(t => t.status !== 'unavailable' && t.status !== 'En pause' && !_arch.has(t.technician_id) && !_arch.has(t.name) && hasSkill(t)) })()
const allJobs = jobRes.status === 200 ? (jobRes.data.data || []) : []
// Congés approuvés (Tech Availability) → jours « off » dans l'occupation (aligné sur suggestSlots + roster).
const vacBy = {}

View File

@ -72,7 +72,12 @@ function setJobRemote (name, remote) {
const SOLVER_URL = cfg.ROSTER_SOLVER_URL || 'http://roster-solver:8090'
const PAUSE_STATUS = 'En pause'
const AVAIL_STATUS = 'Disponible'
const ARCHIVE_STATUS = 'Archivé' // tech archivé (réversible) : masqué du dispatch/solveur/créneaux, historique conservé
const ARCHIVE_STATUS = 'Archivé' // (legacy) — l'archivage vit désormais dans un store hub (ERPNext v16 refuse une valeur de statut hors liste sur ce doctype custom → 500)
// Archivage RÉVERSIBLE via store hub durable (pas d'écriture ERP — même raison que job-levels : v16 rejette les valeurs
// de statut hors liste Select sur Dispatch Technician). { [technician_id]: true }. Masque le tech partout, historique conservé.
const ARCHIVED_FILE = path.join(__dirname, '..', 'data', 'archived_techs.json')
function loadArchivedSet () { return new Set((readJsonFile(ARCHIVED_FILE, []) || []).map(String)) }
function saveArchivedSet (set) { writeJsonFile(ARCHIVED_FILE, [...set]) }
// ── Date helpers (local, sans dépendance) ──────────────────────────────────
function iso (d) { return d.toISOString().slice(0, 10) }
@ -337,6 +342,7 @@ async function _fetchTechniciansRaw () {
'absence_from', 'absence_until', 'employee', 'phone', '_user_tags', 'skill_levels', 'skill_eff', 'weekly_schedule'],
limit: 500,
})
const arch = loadArchivedSet() // ids archivés (store hub) → masqués partout
return rows.map(t => ({
id: t.technician_id || t.name,
name: t.full_name || t.technician_id,
@ -357,15 +363,17 @@ async function _fetchTechniciansRaw () {
absence_from: t.absence_from,
absence_until: t.absence_until,
weekly_schedule: (() => { try { return JSON.parse(t.weekly_schedule || 'null') } catch { return null } })(), // patron récurrent {mon:{start,end}|null,…} — source des quarts matérialisés (hybride)
})).filter(t => t.status !== ARCHIVE_STATUS) // techs archivés = masqués partout (planif, solveur, stats) ; restaurables via /roster/technicians?archived=1
})).filter(t => !arch.has(t.id)) // techs archivés (store hub) = masqués partout (planif, solveur, stats) ; restaurables via /roster/technicians?archived=1
}
// Techs ARCHIVÉS uniquement (pour l'UI de restauration) — requête directe (hors cache).
// Techs ARCHIVÉS uniquement (pour l'UI de restauration) — tous les humains dont l'id est dans le store d'archivage.
async function fetchArchivedTechnicians () {
const arch = loadArchivedSet(); if (!arch.size) return []
const rows = await erp.list('Dispatch Technician', {
filters: [['resource_type', '=', 'human'], ['status', '=', ARCHIVE_STATUS]],
filters: [['resource_type', '=', 'human']],
fields: ['name', 'technician_id', 'full_name', 'status', 'absence_reason'], limit: 500,
})
return rows.map(t => ({ id: t.technician_id || t.name, name: t.full_name || t.technician_id, status: t.status, reason: t.absence_reason || '' }))
.filter(t => arch.has(t.id))
}
// Construit, pour chaque tech, la liste des dates indisponibles dans l'horizon.
@ -1236,6 +1244,13 @@ async function capacityByDay (start, days) {
const templates = await fetchTemplates()
const tpl = {}; for (const t of templates) tpl[t.name] = { s: timeToH(t.start_time), e: timeToH(t.end_time) }
const asgs = await fetchAssignments(start, days) // Proposé + Publié = présence planifiée du tech
// 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é).
const techsActive = await fetchTechnicians() // exclut les techs archivés
const validTech = new Set(techsActive.map(t => t.id))
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', {
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress', 'On Hold']]],
fields: ['name', 'scheduled_date', 'start_time', 'duration_h', 'assigned_tech'], limit: 5000,
@ -1245,6 +1260,7 @@ async function capacityByDay (start, days) {
const techSeen = {}
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
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++ }
@ -2043,11 +2059,11 @@ async function handle (req, res, method, path, url) {
if (mArchive && method === 'POST') {
const techId = decodeURIComponent(mArchive[1])
const b = await parseBody(req)
const techName = await resolveTechName(techId)
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const patch = { status: b.archived ? ARCHIVE_STATUS : AVAIL_STATUS }
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch)); if (r && r.ok) invalidateTechCache()
return json(res, r.ok ? 200 : 500, { ...r, technician: techId, status: patch.status, archived: !!b.archived })
// Store hub (pas d'écriture ERP : v16 rejette une valeur de statut hors liste sur ce doctype → 500).
const arch = loadArchivedSet()
if (b.archived) arch.add(String(techId)); else arch.delete(String(techId))
saveArchivedSet(arch); invalidateTechCache()
return json(res, 200, { ok: true, technician: techId, archived: !!b.archived })
}
// Positions GPS LIVE de TOUS les techs ayant un appareil Traccar → marqueurs « live » sur la carte des tournées
// (rafraîchi périodiquement côté client). Léger : une requête position par appareil (getPositions parallèle).