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:
parent
c267f9a3dd
commit
69f00840c8
|
|
@ -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 })
|
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.
|
// 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)
|
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.
|
// É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 getJobTeam = (job) => jget('/roster/job/team?job=' + encodeURIComponent(job))
|
||||||
export const addAssistant = (job, a) => jpost('/roster/job/team', { job, add: a })
|
export const addAssistant = (job, a) => jpost('/roster/job/team', { job, add: a })
|
||||||
|
|
|
||||||
|
|
@ -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 DAYS = [['mon', 'Lun'], ['tue', 'Mar'], ['wed', 'Mer'], ['thu', 'Jeu'], ['fri', 'Ven'], ['sat', 'Sam'], ['sun', 'Dim']]
|
||||||
const sched = reactive({})
|
const sched = reactive({})
|
||||||
const weeks = ref(1)
|
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 selected = ref(new Set()) // techIds cochés (mode lot)
|
||||||
const bulk = computed(() => (props.techs || []).length > 1)
|
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) || ''))
|
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' } } }
|
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)
|
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 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 }
|
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 () {
|
function submit () {
|
||||||
const techIds = bulk.value ? [...selected.value] : (props.techs[0] ? [props.techs[0].id] : [])
|
const techIds = bulk.value ? [...selected.value] : (props.techs[0] ? [props.techs[0].id] : [])
|
||||||
if (!techIds.length) return
|
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)
|
emit('update:modelValue', false)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -49,6 +51,12 @@ function submit () {
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-separator />
|
<q-separator />
|
||||||
<q-card-section class="q-gutter-sm">
|
<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) -->
|
<!-- Mode LOT : choisir les techniciens à qui appliquer le modèle (tous cochés par défaut) -->
|
||||||
<div v-if="bulk">
|
<div v-if="bulk">
|
||||||
<div class="text-caption text-grey-7 q-mb-xs">Appliquer à ({{ selected.size }}/{{ techs.length }}) — clic pour (dé)cocher :</div>
|
<div class="text-caption text-grey-7 q-mb-xs">Appliquer à ({{ selected.size }}/{{ techs.length }}) — clic pour (dé)cocher :</div>
|
||||||
|
|
@ -75,17 +83,18 @@ function submit () {
|
||||||
<span v-else class="wse-repos">Repos</span>
|
<span v-else class="wse-repos">Repos</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<template #prepend><q-icon name="repeat" size="18px" /></template>
|
||||||
</q-input>
|
</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>
|
||||||
|
<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-section>
|
||||||
<q-card-actions align="right" class="q-px-md q-pb-md">
|
<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 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-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
|
||||||
|
|
@ -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 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
|
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 }
|
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
|
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 = {}
|
let created = 0, failed = 0; const tplByKey = {}
|
||||||
const base = start.value // lundi de la semaine affichée
|
const base = start.value // lundi de la semaine affichée
|
||||||
for (const t of targets) {
|
for (const t of targets) {
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ const erp = require('./erp')
|
||||||
const cfg = require('./config')
|
const cfg = require('./config')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
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')
|
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.)
|
// 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')
|
const JOB_LEVELS_FILE = path.join(__dirname, '..', 'data', 'job-levels.json')
|
||||||
|
|
@ -303,7 +304,7 @@ async function fetchTechnicians () {
|
||||||
filters: [['resource_type', '=', 'human']],
|
filters: [['resource_type', '=', 'human']],
|
||||||
fields: ['name', 'technician_id', 'full_name', 'status', 'color_hex', 'tech_group', 'efficiency', 'skills',
|
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',
|
'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,
|
limit: 500,
|
||||||
})
|
})
|
||||||
return rows.map(t => ({
|
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)}
|
skill_eff: (() => { try { return JSON.parse(t.skill_eff || '{}') } catch { return {} } })(), // {compétence: facteur d'efficacité (vitesse)}
|
||||||
absence_from: t.absence_from,
|
absence_from: t.absence_from,
|
||||||
absence_until: t.absence_until,
|
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 ─────────────────────────────
|
// ── Construit le payload du solveur + l'appelle ─────────────────────────────
|
||||||
async function generate (start, days, weights) {
|
async function generate (start, days, weights) {
|
||||||
const dateList = rangeDates(start, days)
|
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 }))
|
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 })
|
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
|
// Supprimer une assignation publiée
|
||||||
const mDelA = path.match(/^\/roster\/assignment\/(.+)$/)
|
const mDelA = path.match(/^\/roster\/assignment\/(.+)$/)
|
||||||
if (mDelA && method === 'DELETE') {
|
if (mDelA && method === 'DELETE') {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user