diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js index 6f47279..4ba4392 100644 --- a/apps/ops/src/api/roster.js +++ b/apps/ops/src/api/roster.js @@ -145,6 +145,9 @@ export const setJobLocation = (job, lat, lon, address) => jpost('/roster/job/set export const updateJob = (job, patch) => jpost('/roster/job/update', { job, patch }) // Persister UN quart immédiatement (sinon addShift reste local/Proposé et disparaît au rechargement). Idempotent côté hub. export const createShift = (s) => jpost('/roster/shift', s) +// Hybride : patron récurrent (source) + matérialisation (fériés/vacances sautés, manuels préservés) +export const setWeeklySchedule = (techId, schedule) => jpost('/roster/technician/' + encodeURIComponent(techId) + '/weekly-schedule', { schedule }) +export const materializeShifts = (payload) => jpost('/roster/materialize-shifts', payload || {}) // ÉQUIPE d'un job (assistants en renfort) — le lead reste inchangé ; l'assistant épinglé voit un bloc hachuré dans son horaire. export const getJobTeam = (job) => jget('/roster/job/team?job=' + encodeURIComponent(job)) export const addAssistant = (job, a) => jpost('/roster/job/team', { job, add: a }) diff --git a/apps/ops/src/components/shared/WeeklyScheduleEditor.vue b/apps/ops/src/components/shared/WeeklyScheduleEditor.vue index 6dca3a4..aa84405 100644 --- a/apps/ops/src/components/shared/WeeklyScheduleEditor.vue +++ b/apps/ops/src/components/shared/WeeklyScheduleEditor.vue @@ -19,13 +19,15 @@ const emit = defineEmits(['update:modelValue', 'apply']) const DAYS = [['mon', 'Lun'], ['tue', 'Mar'], ['wed', 'Mer'], ['thu', 'Jeu'], ['fri', 'Ven'], ['sat', 'Sam'], ['sun', 'Dim']] const sched = reactive({}) const weeks = ref(1) +const mode = ref('recurring') // 'recurring' = patron de base (matérialisé, exceptions auto) · 'exception' = override ponctuel (semaines précises) const selected = ref(new Set()) // techIds cochés (mode lot) const bulk = computed(() => (props.techs || []).length > 1) const title = computed(() => bulk.value ? ((props.techs || []).length + ' techniciens') : (props.techName || (props.techs[0] && props.techs[0].name) || '')) function fill (preset) { for (const [k] of DAYS) { const s = preset[k]; sched[k] = s ? { on: true, start: s.start, end: s.end } : { on: false, start: '08:00', end: '16:00' } } } fill(SCHEDULE_PRESETS[0].schedule) -watch(() => props.modelValue, (o) => { if (o) { fill(SCHEDULE_PRESETS[0].schedule); weeks.value = 1; selected.value = new Set((props.techs || []).map(t => t.id)) } }) +watch(() => props.modelValue, (o) => { if (o) { fill(SCHEDULE_PRESETS[0].schedule); weeks.value = mode.value === 'recurring' ? 6 : 1; selected.value = new Set((props.techs || []).map(t => t.id)) } }) +watch(mode, (m) => { weeks.value = m === 'recurring' ? 6 : 1 }) function toggleTech (id) { const s = new Set(selected.value); s.has(id) ? s.delete(id) : s.add(id); selected.value = s } function dayHours (d) { if (!d || !d.on) return 0; const [h1, m1] = d.start.split(':').map(Number); const [h2, m2] = d.end.split(':').map(Number); let mn = (h2 * 60 + m2) - (h1 * 60 + m1); if (mn < 0) mn += 1440; return Math.round(mn / 60 * 10) / 10 } @@ -34,7 +36,7 @@ const totalHours = () => DAYS.reduce((s, [k]) => s + dayHours(sched[k]), 0) function submit () { const techIds = bulk.value ? [...selected.value] : (props.techs[0] ? [props.techs[0].id] : []) if (!techIds.length) return - emit('apply', { schedule: DAYS.map(([k]) => ({ dow: k, ...sched[k] })), weeks: Math.max(1, Math.min(12, Number(weeks.value) || 1)), techIds }) + emit('apply', { schedule: DAYS.map(([k]) => ({ dow: k, ...sched[k] })), weeks: Math.max(1, Math.min(12, Number(weeks.value) || 1)), techIds, mode: mode.value }) emit('update:modelValue', false) } @@ -49,6 +51,12 @@ function submit () { + + + + +
Appliquer à ({{ selected.size }}/{{ techs.length }}) — clic pour (dé)cocher :
@@ -75,17 +83,18 @@ function submit () { Repos
- +
- + -
à partir de la semaine affichée · {{ totalDays() }} j / {{ Math.round(totalHours() * 10) / 10 }}h par semaine
+
{{ totalDays() }} j / {{ Math.round(totalHours() * 10) / 10 }}h par semaine
+
{{ mode === 'recurring' ? 'Devient l\'horaire par défaut : quarts générés automatiquement, fériés & vacances exclus, tes ajustements manuels préservés.' : 'Écrit des quarts pour ces semaines uniquement (override) — ex. semaine de nuit. Ne change pas l\'horaire de base.' }}
- + diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 8b08e70..fb91ac9 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -2351,8 +2351,20 @@ const schedGenOpen = ref(false); const schedGenTechs = ref([]) function openSchedGen (t) { schedGenTechs.value = [{ id: t.id, name: t.name }]; skillMenuShown.value = false; schedGenOpen.value = true } // 1 tech function openSchedGenBulk () { schedGenTechs.value = (visibleTechs.value || []).map(t => ({ id: t.id, name: t.name })); schedGenOpen.value = true } // LOT : tous les techs visibles const _hmNum = (s) => { const [h, m] = String(s || '').split(':').map(Number); return (h || 0) + (m || 0) / 60 } -async function onScheduleApply ({ schedule, weeks, techIds }) { +async function onScheduleApply ({ schedule, weeks, techIds, mode }) { const targets = (schedGenTechs.value || []).filter(t => (techIds || []).includes(t.id)); if (!targets.length) return + // ── RÉCURRENT : le patron devient la SOURCE (weekly_schedule) → matérialisation auto (fériés/vacances sautés, manuels préservés) ── + if (mode === 'recurring') { + const patt = {}; for (const d of schedule) patt[d.dow] = d.on ? { start: d.start, end: d.end } : null + let okT = 0 + for (const t of targets) { try { const r = await roster.setWeeklySchedule(t.id, patt); if (r && r.ok) okT++ } catch (e) {} } + let mat = null; try { mat = await roster.materializeShifts({ weeks }) } catch (e) { err(e) } + try { await loadWeek() } catch (e) {} + $q.notify({ type: okT ? 'positive' : 'warning', icon: 'event_repeat', message: 'Horaire récurrent défini · ' + okT + ' tech' + (mat ? ' → ' + mat.created + ' quart(s) sur ' + weeks + ' sem.' + (mat.skipped_holiday ? ' (' + mat.skipped_holiday + ' férié(s) sauté(s))' : '') : ''), timeout: 4800 }) + schedGenTechs.value = [] + return + } + // ── EXCEPTION : override ponctuel (source='manuel', préservé par la matérialisation) sur N semaines dès la semaine affichée ── let created = 0, failed = 0; const tplByKey = {} const base = start.value // lundi de la semaine affichée for (const t of targets) { diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index 4dbb627..3b3ccf6 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -35,6 +35,7 @@ const erp = require('./erp') const cfg = require('./config') const fs = require('fs') const path = require('path') +const holidaysQc = require('./holidays-qc') // fériés QC déterministes — exception AUTO de la matérialisation d'horaire const POLICY_FILE = path.join(__dirname, '..', 'data', 'dispatch-policy.json') // Niveau requis PAR JOB, persistant. (L'API Custom Field d'ERPNext v16 échoue sur ce doctype custom → store hub durable.) const JOB_LEVELS_FILE = path.join(__dirname, '..', 'data', 'job-levels.json') @@ -303,7 +304,7 @@ async function fetchTechnicians () { 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'], + 'absence_from', 'absence_until', 'employee', 'phone', '_user_tags', 'skill_levels', 'skill_eff', 'weekly_schedule'], limit: 500, }) return rows.map(t => ({ @@ -325,6 +326,7 @@ async function fetchTechnicians () { skill_eff: (() => { try { return JSON.parse(t.skill_eff || '{}') } catch { return {} } })(), // {compétence: facteur d'efficacité (vitesse)} 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) })) } @@ -389,6 +391,60 @@ async function fetchAssignments (start, days) { })) } +// ── HYBRIDE : matérialisation des quarts à partir du PATRON récurrent (weekly_schedule) de chaque tech ── +// Le patron est la SOURCE ; on génère des Shift Assignment (source='pattern') sur l'horizon. Exceptions AUTO : +// fériés (holidays-qc) + vacances (Tech Availability) → pas de quart (et retrait d'un quart pattern existant). +// PRÉSERVE les quarts manuels (source≠'pattern') = overrides (ex. semaine de nuit). Idempotent (re-lançable / cron). +const DOW_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] +const _timeH = (s) => { const [h, m] = String(s || '').split(':').map(Number); return (h || 0) + (m || 0) / 60 } +const _timeStr = (s) => (String(s || '').length === 5 ? s + ':00' : String(s || '')) +async function ensureShiftTemplate (start, end, cache) { // start/end 'HH:MM' → nom de doc Shift Template (créé si absent) + const key = start + '-' + end; if (cache[key]) return cache[key] + const st = _timeStr(start), et = _timeStr(end) + try { const rows = await erp.list('Shift Template', { filters: [['start_time', '=', st], ['end_time', '=', et], ['on_call', '=', 0]], fields: ['name'], limit: 1 }); if (rows && rows[0]) { cache[key] = rows[0].name; return rows[0].name } } catch (e) {} + const hours = Math.round((_timeH(end) - _timeH(start)) * 100) / 100 + const r = await retryWrite(() => erp.create('Shift Template', { template_name: start + 'h–' + end + 'h', start_time: st, end_time: et, hours, on_call: 0, default_required: 1, color: '#1976d2' })) + const name = (r && (r.name || (r.data && r.data.name))) || null + if (name) cache[key] = name + return name +} +async function materializeShifts ({ dryRun = true, weeks = 4, techId = null } = {}) { + 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 + const dates = rangeDates(start, days) + const [techsAll, existing, absences] = await Promise.all([fetchTechnicians(), fetchAssignments(start, days), absencesByTechDay(start, days)]) + 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 = {} + let created = 0, updated = 0, deleted = 0, keptManual = 0, skipHoliday = 0, skipVacation = 0 + 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 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 + 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) 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++ } + } + } + return { ok: true, dryRun, weeks: weeksN, techs: techs.length, created, updated, deleted, kept_manual: keptManual, skipped_holiday: skipHoliday, skipped_vacation: skipVacation } +} + // ── Construit le payload du solveur + l'appelle ───────────────────────────── async function generate (start, days, weights) { const dateList = rangeDates(start, days) @@ -1764,6 +1820,20 @@ async function handle (req, res, method, path, url) { const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { traccar_device_id: deviceId })) return json(res, r.ok ? 200 : 500, { ...r, technician: techId, traccar_device_id: deviceId }) } + // 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') { + const techId = decodeURIComponent(mSched[1]); const b = await parseBody(req) + const techName = await resolveTechName(techId); if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId }) + const sched = (b.schedule && typeof b.schedule === 'object') ? b.schedule : null + const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { weekly_schedule: sched ? JSON.stringify(sched) : '' })) + return json(res, r.ok ? 200 : 500, { ...r, technician: techId }) + } + // 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 })) + } // Supprimer une assignation publiée const mDelA = path.match(/^\/roster\/assignment\/(.+)$/) if (mDelA && method === 'DELETE') {