feat(planif): hybrid recurring shift model — weekly_schedule pattern + materialization

The weekly_schedule pattern per tech is now the SOURCE of shifts. New
materializeShifts() (hub roster.js) generates source='pattern' Shift
Assignment rows over an N-week horizon, auto-skipping QC holidays
(holidays-qc) and vacations (Tech Availability), and PRESERVING manual
overrides (source≠'pattern') — e.g. a one-off night week. Idempotent
(re-runnable / cron-ready).

- hub: fetchTechnicians returns parsed weekly_schedule; ensureShiftTemplate
  (find/create Shift Template by exact times); endpoints
  POST /roster/technician/:id/weekly-schedule and /roster/materialize-shifts
  (GET=dry-run, POST=apply, weeks/tech params).
- api/roster.js: setWeeklySchedule + materializeShifts.
- WeeklyScheduleEditor: mode toggle 🔁 Récurrent (base, materialized) ↔
  📌 Exception (concrete override weeks); adaptive labels/horizon.
- PlanificationPage onScheduleApply branches: recurring → setWeeklySchedule
  per tech + materializeShifts; exception → direct-write source='manuel'.

No ERPNext schema change (weekly_schedule + source already exist).
Dry-run against prod: 6 techs patterned → 48 shifts/2wk, 0 collisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-03 11:35:57 -04:00
parent c267f9a3dd
commit 69f00840c8
4 changed files with 102 additions and 8 deletions

View File

@ -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 })

View File

@ -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)
}
</script>
@ -49,6 +51,12 @@ function submit () {
</q-card-section>
<q-separator />
<q-card-section class="q-gutter-sm">
<!-- Mode : patron RÉCURRENT (base, matérialisé avec fériés/vacances sautés) vs EXCEPTION ponctuelle (semaines précises) -->
<q-btn-toggle v-model="mode" spread no-caps dense unelevated toggle-color="primary" color="grey-3" text-color="grey-8"
:options="[{ value: 'recurring', slot: 'rec' }, { value: 'exception', slot: 'exc' }]">
<template #rec><div class="column items-center" style="line-height:1.1;padding:2px 0"><span>🔁 Horaire récurrent</span><span style="font-size:10px;opacity:.7">base · fériés/vacances sautés</span></div></template>
<template #exc><div class="column items-center" style="line-height:1.1;padding:2px 0"><span>📌 Exception</span><span style="font-size:10px;opacity:.7">semaine(s) précise(s)</span></div></template>
</q-btn-toggle>
<!-- Mode LOT : choisir les techniciens à qui appliquer le modèle (tous cochés par défaut) -->
<div v-if="bulk">
<div class="text-caption text-grey-7 q-mb-xs">Appliquer à ({{ selected.size }}/{{ techs.length }}) clic pour ()cocher :</div>
@ -75,17 +83,18 @@ function submit () {
<span v-else class="wse-repos">Repos</span>
</div>
</div>
<!-- Automatisation : répéter sur N semaines -->
<!-- Horizon : récurrent = fenêtre à matérialiser ; exception = nb de semaines dès la semaine affichée -->
<div class="row items-center q-gutter-sm q-pt-xs">
<q-input dense outlined type="number" v-model.number="weeks" label="Générer pour N semaine(s)" :min="1" :max="12" style="width:220px">
<q-input dense outlined type="number" v-model.number="weeks" :label="mode === 'recurring' ? 'Matérialiser N semaines' : 'N semaine(s) dès la semaine affichée'" :min="1" :max="12" style="width:250px">
<template #prepend><q-icon name="repeat" size="18px" /></template>
</q-input>
<div class="text-caption text-grey-6">à partir de la semaine affichée · {{ totalDays() }} j / {{ Math.round(totalHours() * 10) / 10 }}h par semaine</div>
<div class="text-caption text-grey-6">{{ totalDays() }} j / {{ Math.round(totalHours() * 10) / 10 }}h par semaine</div>
</div>
<div class="text-caption text-grey-6">{{ 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.' }}</div>
</q-card-section>
<q-card-actions align="right" class="q-px-md q-pb-md">
<q-btn flat no-caps label="Annuler" color="grey-7" @click="emit('update:modelValue', false)" />
<q-btn unelevated no-caps color="primary" icon="event_available" :disable="bulk && !selected.size" :label="'Générer' + (bulk ? ' ×' + selected.size + ' tech' : '') + (weeks > 1 ? ' · ' + weeks + ' sem.' : '')" @click="submit" />
<q-btn unelevated no-caps color="primary" icon="event_available" :disable="bulk && !selected.size" :label="(mode === 'recurring' ? 'Définir l\'horaire' : 'Créer l\'exception') + (bulk ? ' ×' + selected.size + ' tech' : '')" @click="submit" />
</q-card-actions>
</q-card>
</q-dialog>

View File

@ -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) {

View File

@ -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') {