From 844eff9a49c2fdcfbe50aa3c3823700ce58a223c Mon Sep 17 00:00:00 2001 From: louispaulb Date: Wed, 8 Jul 2026 14:46:21 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20capacit=C3=A9=20r=C3=A9elle=20(hors=20co?= =?UTF-8?q?ng=C3=A9/pause/archiv=C3=A9),=20archive=20via=20store=20hub,=20?= =?UTF-8?q?calendrier=20horaire=20tech?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../components/planif/TechScheduleDialog.vue | 30 +++++++++++++--- apps/ops/src/pages/PlanificationPage.vue | 1 + services/targo-hub/lib/dispatch.js | 10 +++--- services/targo-hub/lib/roster.js | 34 ++++++++++++++----- 4 files changed, 57 insertions(+), 18 deletions(-) diff --git a/apps/ops/src/components/planif/TechScheduleDialog.vue b/apps/ops/src/components/planif/TechScheduleDialog.vue index ebb2a43..8f55adf 100644 --- a/apps/ops/src/components/planif/TechScheduleDialog.vue +++ b/apps/ops/src/components/planif/TechScheduleDialog.vue @@ -65,6 +65,11 @@ @@ -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() }) @@ -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; } diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index f3a2ab6..eace838 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -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) diff --git a/services/targo-hub/lib/dispatch.js b/services/targo-hub/lib/dispatch.js index 6523f2f..b85f37f 100644 --- a/services/targo-hub/lib/dispatch.js +++ b/services/targo-hub/lib/dispatch.js @@ -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 = {} diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index 16fb3e2..58cf464 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -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).