roster(planif): assignation drag-drop + timeline ressource + occupation + nettoyage

Panneau « jobs à assigner » v2 : multi-sélection (cases), groupes parent-enfant
surlignés, heuristique terrain/à distance (activation/netadmin pré-décochés),
pré-total d'heures, aperçu d'occupation PROJETÉE au survol (barre fantôme + badge).

Fix barre d'occupation figée après drop : /roster/assign-job pose désormais un
start_time (premier-trou-libre dans le shift) + garantit duration_h, sinon le job
compte dans les heures mais n'affiche aucun bloc. Nouvel endpoint
/roster/backfill-start-times (idempotent) pour rattraper l'historique.

Infobulle de cellule : nb de jobs + liste triée par priorité (occupancyByTechDay
renvoie jobs[]). Timeline contextuelle par ressource (dialogue, 0 appel réseau).

Lisibilité du drag : fantôme compact semi-transparent décalé sous le curseur
(ne masque plus l'aperçu) + source estompée.

Scoring de priorité : hook proximité (neutre — secteur géré manuellement),
réservé à 20% du score quand la géoloc arrivera.

Refactor hub : helper partagé firstFitStart (assign-job + backfill).
Nettoyage : retrait code mort (onDeleteRosterTag, projUsedH), carte des sections
en tête de PlanificationPage. Doc : docs/features/roster.md + index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-05 15:50:17 -04:00
parent 70c89b2cea
commit f1204ed459
9 changed files with 1230 additions and 208 deletions

View File

@ -64,10 +64,12 @@ export const approveAvailability = (name, body) => jpost('/roster/availability/'
export const pauseTechnician = (id, paused, reason) => jpost(`/roster/technician/${encodeURIComponent(id)}/pause`, { paused, reason })
export const setTechEfficiency = (id, efficiency) => jpost(`/roster/technician/${encodeURIComponent(id)}/efficiency`, { efficiency })
export const setTechCost = (id, body) => jpost(`/roster/technician/${encodeURIComponent(id)}/cost`, body)
export const setTechSkills = (id, skills) => jpost(`/roster/technician/${encodeURIComponent(id)}/skills`, { skills })
export const setTechSkills = (id, skills, skillLevels, skillEff) => { const body = { skills }; if (skillLevels !== undefined) body.skill_levels = skillLevels; if (skillEff !== undefined) body.skill_eff = skillEff; return jpost(`/roster/technician/${encodeURIComponent(id)}/skills`, body) }
// ── Prise de RDV ──
export const bookJobs = () => jget('/roster/book/jobs')
// Méta de l'éditeur type→compétence : { service_types, skills, skill_by_type }
export const bookMeta = () => jget('/roster/book/meta')
export const bookSlots = (p) => jget('/roster/book/slots?' + new URLSearchParams(p).toString())
export const bookFit = (body) => jpost('/roster/book/fit', body)
export const bookConfirm = (body) => jpost('/roster/book/confirm', body)
@ -77,5 +79,19 @@ export const bookHold = (body) => jpost('/roster/book/hold', body)
export const bookLink = (job) => jpost('/roster/book/link', { job })
// File « À recontacter » (jobs À reporter)
export const jobsToReschedule = () => jget('/roster/jobs-to-reschedule')
// Impact d'un retrait de compétence : jobs assignés exigeant cette compétence
export const skillImpact = (tech, skill) => jget('/roster/skill-impact?tech=' + encodeURIComponent(tech) + '&skill=' + encodeURIComponent(skill))
// Impact d'une absence : jobs assignés au tech sur ces dates (CSV)
export const absenceImpact = (tech, dates) => jget('/roster/absence-impact?tech=' + encodeURIComponent(tech) + '&dates=' + encodeURIComponent((dates || []).join(',')))
// Redistribuer ces jobs : mode 'auto' (re-match) ou 'requeue' (À recontacter)
export const redistributeSkillJobs = (jobs, skill, mode) => jpost('/roster/skill-impact/redistribute', { jobs, skill, mode })
// Candidats classés pour reprendre un job (techs qualifiés + libres au créneau)
export const jobCandidates = (job, exclude) => jget('/roster/job-candidates?job=' + encodeURIComponent(job) + '&exclude=' + encodeURIComponent(exclude || ''))
// Plan explicite : [{ job, tech } | { job, requeue:true }]
export const redistributePlan = (plan) => jpost('/roster/skill-impact/redistribute', { plan })
// Jobs non assignés (+ groupe/dépendances) pour le panneau glisser-déposer
export const unassignedJobs = () => jget('/roster/unassigned-jobs')
// Assigner un job à un tech (date = case déposée)
export const assignJob = (job, tech, date) => jpost('/roster/assign-job', { job, tech, date })
// Aviser le client d'un report : désassigne + SMS lien /book — { job, phone?, message? }
export const notifyReschedule = (body) => jpost('/roster/job/notify-reschedule', body)

View File

@ -10,7 +10,7 @@
* - Create new tags on-the-fly with color picker
* - Reusable everywhere: jobs, techs, sidebar filter
*/
import { ref, computed, nextTick, watch } from 'vue'
import { ref, computed, nextTick, watch, onMounted } from 'vue'
import { useQuasar } from 'quasar'
const $q = useQuasar()
@ -28,6 +28,7 @@ const props = defineProps({
levelHint: { type: String, default: '1 = base · 5 = expert' },
showRequired:{ type: Boolean, default: false }, // show required pin per tag (jobs)
compact: { type: Boolean, default: false }, // smaller chips
autofocus: { type: Boolean, default: false }, // focus l'input au montage (ouvre la liste direct)
})
const emit = defineEmits(['update:modelValue', 'create', 'update-tag', 'rename-tag', 'delete-tag'])
@ -179,6 +180,9 @@ function onBlur () {
function onEditBlur () {
setTimeout(() => closeEdit(), 250)
}
// Autofocus optionnel : ouvre la liste d'autocomplétion dès l'apparition (ergonomie popover près du clic).
onMounted(() => { if (props.autofocus) nextTick(() => { inputEl.value?.focus(); focused.value = true }) })
</script>
<template>

View File

@ -43,6 +43,23 @@
:text-color="(booking.days_offered || []).includes(d.v) ? 'white' : 'grey-8'"
@click="toggleDay(d.v)">{{ d.l }}</q-chip>
</div>
<q-separator class="q-my-md" />
<div class="text-caption text-weight-medium q-mb-xs">Compétence requise par type de job</div>
<div class="text-caption text-grey-7 q-mb-sm">
Le client ne voit que les créneaux des techs ayant le <b>tag</b> correspondant. Laisser vide = aucun tag requis
(ex. ajout TV / borne WiFi n'importe qui ; installation ou réparation « installateur »).
</div>
<div v-if="!meta.service_types.length" class="text-caption text-grey-6 q-mb-sm">
Aucun type de job détecté (champ <code>service_type</code> vide sur les jobs). Renseignele sur les Dispatch Jobs.
</div>
<div v-for="st in meta.service_types" :key="st" class="row items-center q-col-gutter-sm q-mb-xs">
<div class="col-12 col-sm-5 text-body2">{{ st }}</div>
<q-select class="col" dense outlined clearable use-input new-value-mode="add-unique" input-debounce="0"
:options="meta.skills" v-model="skillMap[st]" label="Compétence requise (tag)" placeholder="(aucune)" />
</div>
<div v-if="!meta.skills.length" class="text-caption text-orange-9 q-mt-xs">
Aucun technicien n'a de compétence saisie renseigne les tags dans Planification Outils Cadence équipe, sinon le filtre exclura tout le monde.
</div>
<div class="row items-center q-mt-sm">
<q-space />
<q-btn unelevated color="primary" label="Enregistrer" :loading="savingBooking" @click="doSaveBooking" />
@ -80,7 +97,7 @@
<script setup>
import { ref, reactive, onMounted, nextTick } from 'vue'
import { askAssistant, getPolicy, savePolicy } from 'src/api/roster'
import { askAssistant, getPolicy, savePolicy, bookMeta } from 'src/api/roster'
const messages = ref([])
const input = ref('')
@ -99,6 +116,9 @@ const bookingFields = ref([])
const weekdays = ref([])
const savingBooking = ref(false)
const bookingSaved = ref(false)
// Table type de job compétence requise (le client ne voit que les techs qualifiés)
const meta = reactive({ service_types: [], skills: [] })
const skillMap = reactive({})
onMounted(async () => {
loadingPolicy.value = true
@ -111,6 +131,7 @@ onMounted(async () => {
bookingFields.value = d.options?.booking_fields || []
weekdays.value = d.options?.weekdays || []
} catch (e) { /* defaults */ }
try { const m = await bookMeta(); meta.service_types = m.service_types || []; meta.skills = m.skills || []; Object.assign(skillMap, m.skill_by_type || {}) } catch (e) { /* non bloquant */ }
loadingPolicy.value = false
})
@ -128,7 +149,8 @@ function toggleDay (v) {
}
async function doSaveBooking () {
savingBooking.value = true; bookingSaved.value = false
try { await savePolicy({ booking: { ...booking, days_offered: [...(booking.days_offered || [])] } }); bookingSaved.value = true; setTimeout(() => { bookingSaved.value = false }, 2500) }
const sbt = {}; for (const k in skillMap) { if (skillMap[k]) sbt[k] = String(skillMap[k]).trim() } // n'enregistre que les types tagués
try { await savePolicy({ booking: { ...booking, days_offered: [...(booking.days_offered || [])], skill_by_type: sbt } }); bookingSaved.value = true; setTimeout(() => { bookingSaved.value = false }, 2500) }
catch (e) { /* ignore */ }
savingBooking.value = false
}

File diff suppressed because it is too large Load Diff

View File

@ -20,8 +20,9 @@
</q-item-label>
<q-item v-for="j in filteredJobs" :key="j.name" clickable :active="sel && sel.name === j.name" active-class="bg-blue-1" @click="selectJob(j)">
<q-item-section>
<q-item-label class="text-weight-medium">{{ j.customer_name || j.name }}</q-item-label>
<q-item-label caption>{{ j.service_location || '—' }} · {{ j.duration_h || 1 }}h</q-item-label>
<q-item-label class="text-weight-medium">{{ jobTitle(j) }}</q-item-label>
<q-item-label caption lines="1"><MapPin :size="12" class="q-mr-xs" style="vertical-align:middle;color:#546e7a" />{{ jobLoc(j) }} · {{ fmtDur(j.duration_h) }}</q-item-label>
<q-item-label caption class="text-grey-5" style="font-size:10px">{{ j.name }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-badge v-if="view === 'reschedule' || j.booking_status === 'À reporter'" color="red" label="à reporter" />
@ -38,8 +39,9 @@
<q-card v-else flat bordered>
<q-card-section class="q-pb-none row items-start">
<div class="col">
<div class="text-subtitle1 text-weight-bold">{{ sel.customer_name || sel.name }}</div>
<div class="text-caption text-grey-7">{{ sel.service_location || '—' }} · durée {{ params.duration }}h · tech actuel : {{ sel.assigned_tech || '—' }}</div>
<div class="text-subtitle1 text-weight-bold">{{ jobTitle(sel) }}</div>
<div class="text-caption text-grey-7"><MapPin :size="13" style="vertical-align:middle;color:#546e7a" /> {{ jobLoc(sel) }} · durée {{ fmtDur(params.duration) }} · tech actuel : {{ sel.assigned_tech || '—' }}</div>
<div class="text-caption text-grey-5" style="font-size:10px">{{ sel.name }}</div>
</div>
<q-btn flat dense no-caps icon="link" label="Lien client" color="primary" @click="genLink(sel)" />
</q-card-section>
@ -53,7 +55,7 @@
</q-card-section>
<q-card-section class="row q-col-gutter-sm items-end">
<q-input dense outlined v-model="params.skill" label="Compétence requise" style="width:150px" />
<q-input dense outlined v-model="params.skill" label="Compétence requise" style="width:160px" :hint="sel.service_type ? ('auto : ' + sel.service_type) : 'tag (vide = tous)'"><template v-if="params.skill" #append><q-icon name="sell" size="16px" color="teal" /></template></q-input>
<q-input dense outlined v-model="params.zone" label="Zone" style="width:140px" />
<q-input dense outlined type="number" step="0.5" v-model.number="params.duration" label="Durée (h)" style="width:100px" />
<q-input dense outlined type="date" v-model="params.start" label="À partir du" style="width:160px" />
@ -121,6 +123,7 @@
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { MapPin } from 'lucide-vue-next' // pin monochrome outline (style menu Dispatch)
import { useQuasar } from 'quasar'
import * as roster from 'src/api/roster'
@ -132,7 +135,11 @@ const loadingJobs = ref(false)
const sel = ref(null)
const mode = ref('propose')
const params = reactive({ skill: '', zone: '', duration: 1, start: todayISO(), days: 14 })
const prefs = ref([{ date: '', start: '' }, { date: '', start: '' }, { date: '', start: '' }])
// Les 3 dispos sont PRÉ-REMPLIES avec des valeurs réelles (et non des défauts navigateur vides)
// « Vérifier » fonctionne d'emblée ; l'agent ajuste selon ce que dit le client.
function plusDaysISO (iso, n) { const a = iso.split('-').map(Number); const d = new Date(Date.UTC(a[0], a[1] - 1, a[2])); d.setUTCDate(d.getUTCDate() + n); return d.toISOString().slice(0, 10) }
function defaultPrefs () { const d1 = plusDaysISO(todayISO(), 1); const d2x = plusDaysISO(todayISO(), 2); return [{ date: d1, start: '09:00' }, { date: d1, start: '13:00' }, { date: d2x, start: '09:00' }] }
const prefs = ref(defaultPrefs())
const fit = ref(null); const fitting = ref(false)
const slots = ref([]); const chosen = ref(null); const finding = ref(false); const searched = ref(false)
const confirming = ref(false); const smsing = ref(false)
@ -145,6 +152,10 @@ const MO = ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août',
function d2 (iso) { const a = iso.split('-').map(Number); return new Date(Date.UTC(a[0], a[1] - 1, a[2])) }
function frDate (iso) { if (!iso) return ''; return FR_DOW[d2(iso).getUTCDay()] + ' ' + iso.slice(8) + '/' + iso.slice(5, 7) }
function dayLabel (iso) { const dt = d2(iso); return FR_DOW_FULL[dt.getUTCDay()] + ' ' + dt.getUTCDate() + ' ' + MO[dt.getUTCMonth()] }
// Titre LISIBLE d'un job : type de service + client (l'ID DJ- est secondaire). Lieu = adresse lisible si dispo.
function jobTitle (j) { if (!j) return ''; const t = (j.service_type || '').trim(); const c = (j.customer_name || '').trim(); if (t) return c ? (t + ' — ' + c) : t; return c || j.location_label || j.name }
function jobLoc (j) { return (j && (j.location_label || j.service_location)) || '—' }
function fmtDur (h) { h = Number(h) || 0; const H = Math.floor(h); const M = Math.round((h - H) * 60); return M ? (H + 'h' + String(M).padStart(2, '0')) : (H + 'h') }
function wkMon (iso) { const dt = d2(iso); const off = (dt.getUTCDay() + 6) % 7; dt.setUTCDate(dt.getUTCDate() - off); return dt.toISOString().slice(0, 10) }
function wkLabel (m) { const a = d2(m); const b = new Date(a); b.setUTCDate(b.getUTCDate() + 6); return 'Semaine du ' + a.getUTCDate() + ' ' + MO[a.getUTCMonth()] + ' ' + b.getUTCDate() + ' ' + MO[b.getUTCMonth()] }
@ -175,8 +186,8 @@ async function loadJobs () {
} catch (e) { err(e) } finally { loadingJobs.value = false }
}
function onViewChange () { sel.value = null; resetPanel() }
function resetPanel () { fit.value = null; slots.value = []; chosen.value = null; searched.value = false }
async function selectJob (j) { await releaseChosen(); sel.value = j; params.duration = Number(j.duration_h) || 1; resetPanel() }
function resetPanel () { fit.value = null; slots.value = []; chosen.value = null; searched.value = false; prefs.value = defaultPrefs() }
async function selectJob (j) { await releaseChosen(); sel.value = j; params.duration = Number(j.duration_h) || 1; params.skill = j.required_skill || ''; resetPanel() } // compétence requise dérivée du type de job
async function doFit () {
const valid = prefs.value.filter(p => p.date && p.start)

View File

@ -7,6 +7,7 @@ modes. Open the one that matches the feature you're changing.
| Doc | Owns |
|---|---|
| [dispatch.md](dispatch.md) | Ops dispatch board: drag-and-drop scheduling, tech assignment with skill tags, travel-time optimization, magic-link SMS issuance, live SSE updates |
| [roster.md](roster.md) | Planification (Roster AI): grille hebdo ressources × jours, garde live, solveur OR-Tools, scoring priorité (maîtrise⊕vitesse⊕coût), panneau « jobs à assigner » (drag-drop + aperçu occupation), timeline ressource, dialogues d'impact, booking roster-aware |
| [tech-mobile.md](tech-mobile.md) | Field tech app (three surfaces: SSR `/t/{jwt}`, transitional `apps/field/`, unified `/ops/#/j/*`). Native camera → Gemini scanner, equipment install/remove, JWT auth, offline queue |
| [customer-portal.md](customer-portal.md) | Passwordless customer self-service at `portal.gigafibre.ca`: magic-link email (24h JWT), invoice + ticket view, Stripe-linked payment flows |
| [billing-payments.md](billing-payments.md) | Stripe integration (Checkout, Billing Portal, webhook), subscription lifecycle, invoice generation, payment reconciliation, PPA (Plan de paiement automatique), Klarna BNPL |

86
docs/features/roster.md Normal file
View File

@ -0,0 +1,86 @@
# Planification (Roster AI) & assignation dispatch — Handoff dev
Grille hebdomadaire **ressources × jours** pour planifier les quarts, la garde, et
**prendre en charge les jobs dispatch** (glisser-déposer, timeline, occupation).
Aucune paie : on planifie, on approuve, on assigne.
## Surfaces
| Couche | Fichier | Rôle |
|---|---|---|
| UI Ops | `apps/ops/src/pages/PlanificationPage.vue` | Grille, garde, panneau « jobs à assigner », timeline ressource, dialogues d'impact |
| Client API | `apps/ops/src/api/roster.js` | Wrappers `fetch` vers `targo-hub /roster/*` |
| Backend | `services/targo-hub/lib/roster.js` | Endpoints, accès ERPNext, moteur de créneaux, orchestration solveur |
| Copilote | `services/targo-hub/lib/roster-assistant.js` | Actions d'écriture en langage naturel (réassign / SMS / escalade) |
| Solveur | `services/roster-solver/` (conteneur OR-Tools, réseau `erpnext_erpnext:8090`) | Propose un horaire (ne publie jamais) |
| RDV client | `apps/ops/src/pages/RendezVousPage.vue`, portail Lovable | Prise de rendez-vous (booking) roster-aware |
## Doctypes ERPNext (site facturation `erp.gigafibre.ca`)
- **Dispatch Technician** — ressources. Custom fields clés (cf. `dispatch-app/frappe-setup/setup_dispatch_custom_fields.py`) :
`efficiency` (cadence globale), `skills` (CSV), `skill_levels` (JSON `{compétence:1-5}` = maîtrise),
`skill_eff` (JSON `{compétence:facteur}` = vitesse par compétence), `cost_salary_h`/`cost_charges_pct`/`cost_other_h`.
- **Shift Template** — quarts. `on_call` (garde : capacité de réserve, **jamais** offerte au booking).
- **Shift Requirement** — besoins de couverture (« dispo vs requis »).
- **Shift Assignment** — assignations (statut Proposé/Publié). Le solveur propose, `/publish` écrit.
- **Tech Availability** — congés/absences (Approuvé). `long_term` = à remplacer (≠ vacances ponctuelles).
- **Dispatch Job** — les tickets. Champs utilisés ici : `assigned_tech`, `scheduled_date`, `start_time`,
`duration_h`, `priority`, `parent_job`/`depends_on`/`step_order` (groupes), `booking_*`, `required_skill`.
## Concepts UI (PlanificationPage.vue — voir la carte des sections en tête de `<script setup>`)
- **Garde LIVE** : jamais matérialisée. `gardeOverlay` (règles de rotation) ⊕ `manualGarde` (touche **G**, localStorage)
`gardeEffective`. Recalculée à chaque rendu ⇒ pas de désync. La garde est **exclue** des heures travaillées et du coût.
- **Filtre compétences (ET)** : `skillFilter` — un tech n'apparaît que s'il a **toutes** les compétences cochées.
- **Score de priorité** : `priorityScores` = maîtrise (`techCompetence` 1-5) ⊕ vitesse (`techSpeed`, efficacité par
compétence) ⊕ coût. Hook **proximité** (`techProximity`) câblé mais **neutre** (renvoie `null`) — secteur géré
manuellement via les zones ; réservé à 20 % du score quand la géoloc arrivera.
- **Occupation** : `occCells` combine la fenêtre de shift (`bookableH`) et les jobs (`occByTechDay` du hub) →
`usedH`, `pct`, `blocks` (barres colorées), `jobs` (liste triée priorité→heure). ⚠️ **un bloc n'apparaît que si le job
a un `start_time`** (sinon il compte dans `usedH` mais reste invisible — d'où le premier-trou-libre, voir hub).
- **Timeline ressource** : `openTimeline(t)` → dialogue listant les jobs de la semaine visible (barre + liste priorisée).
0 appel réseau (réutilise les helpers de cellule). Lien « Dispatch » vers le tableau (`/dispatch`).
- **Panneau « jobs à assigner »** (Outils) : flottant, déplaçable. Multi-sélection (cases), groupes parent-enfant
surlignés, heuristique **terrain vs à distance** (`jobIsOnsite` : activation/config/netadmin = à distance → pré-décoché),
pré-total d'heures, **glisser la sélection** sur une case → assignation **séquentielle**, **aperçu d'occupation projetée**
(barre fantôme + badge `+Xh → Y%`) au survol.
- **Dialogues d'impact** : retirer une compétence ou poser une absence sur un tech avec jobs assignés → liste des jobs
affectés + **candidats classés** (qualifiés + libres au créneau) ou « À recontacter ».
## Endpoints hub `/roster/*` (extrait)
| Méthode | Chemin | Usage |
|---|---|---|
| GET | `/technicians` `/templates` `/requirements` `/assignments` `/coverage` `/stats` `/occupancy` `/absences` | Lecture grille |
| POST | `/generate` | Solveur OR-Tools (propose) |
| POST | `/publish-week` | Écrit les Shift Assignment (diff : crée/retire seulement le delta) |
| POST | `/technician/:id/skills` `/pause` `/efficiency` `/cost` | Édition ressource |
| GET | `/unassigned-jobs` | Jobs ouverts/On Hold sans tech (+ groupe/dépendances) pour le panneau |
| POST | `/assign-job` | Assigne un job (pose `start_time` **premier-trou-libre** + garantit `duration_h`) |
| POST | `/backfill-start-times` | Pose un `start_time` sur les jobs **déjà** assignés sans heure (idempotent) |
| GET | `/skill-impact` `/absence-impact` `/job-candidates` | Données des dialogues d'impact |
| POST | `/skill-impact/redistribute` | `plan` (explicite) / `auto` / `requeue` |
| GET/POST | `/book/slots` `/book/fit` `/book/confirm` `/book/hold` `/book/meta` `/policy` | Prise de RDV (booking) |
Moteur de créneaux partagé : `loadBookingData``techGaps` (trous libres) → `firstFitStart` (premier trou pour une durée,
réutilisé par `assign-job` **et** `backfill-start-times`) → `bookingSlots` / `fitBooking`.
## Pièges (à NE PAS réintroduire)
- **frappe_pg ne supporte PAS la concurrence** : `erp.list`/écritures en **séquentiel**, jamais `Promise.all`
(sous charge concurrente la connexion PG se réinitialise → « load fail »). Les sauvegardes d'édition sont **debouncées**.
- **Occupation invisible sans `start_time`** : un job assigné sans heure ne dessine aucun bloc. `assign-job` pose donc
toujours une heure (premier-trou-libre). Le backfill rattrape l'historique. Si le tech n'a **aucun quart** ce jour-là,
on ne peut pas placer de bloc (écart de planification, pas un bug).
- **Scheduler ERPNext en pause** : la facturation legacy reste autoritaire jusqu'au cutover.
- **Déploiement** : front via `apps/ops/deploy.sh` (`DEPLOY_BASE=/ops/ quasar build` + tar/scp → `/opt/ops-app/`) ;
hub via `scp lib/roster.js → /opt/targo-hub/lib/` + `docker restart targo-hub`. Custom fields via le script frappe-setup.
## TODO / dette
- Bloquer réellement (≠ 🔒 visuel) le dépôt d'un job « On Hold » avant son prérequis.
- Brancher la **proximité** (lat/lng Service Location ↔ base/secteur du tech) dans `techProximity`.
- Deep-link Dispatch filtré tech/date (DispatchPage ne lit pas encore `route.query`).
- Apprentissage IA compétence ↔ type de cas (historique des jobs).
- **Refactor** : `PlanificationPage.vue` (~1,5k lignes) gagnerait à extraire des composables
(`useGarde`, `useOccupancy`, `useAssignPanel`, `useSkillEditor`) — non fait (risque sur fichier prod).

View File

@ -32,7 +32,9 @@ const DEFAULT_POLICY = {
sms_max: 1,
escalation: 'queue_sms', // file Ops + SMS superviseur
// #56 — créneaux offerts au booking (page /book + vue agent). Lu par lib/roster.js getBookingPolicy().
booking: { lead_hours: 24, day_start: 8, day_end: 18, days_offered: [1, 2, 3, 4, 5], horizon_days: 21, max_per_day: 0, hold_minutes: 10 },
// skill_by_type : table type de job (service_type) → compétence requise. Le client ne voit que les
// créneaux des techs qui ont ce tag. '' = aucun tag requis (ex. ajout TV/borne WiFi → n'importe qui).
booking: { lead_hours: 24, day_start: 8, day_end: 18, days_offered: [1, 2, 3, 4, 5], horizon_days: 21, max_per_day: 0, hold_minutes: 10, skill_by_type: {} },
}
const POLICY_OPTIONS = {
reschedule: [

View File

@ -96,7 +96,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',
'absence_from', 'absence_until', 'employee', 'phone', '_user_tags'],
'absence_from', 'absence_until', 'employee', 'phone', '_user_tags', 'skill_levels', 'skill_eff'],
limit: 500,
})
return rows.map(t => ({
@ -113,6 +113,8 @@ async function fetchTechnicians () {
phone: t.phone,
employee: t.employee,
skills: splitCsv(t.skills || t._user_tags), // champ skills (ou tags Frappe)
skill_levels: (() => { try { return JSON.parse(t.skill_levels || '{}') } catch { return {} } })(), // {compétence: niveau 15}
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,
}))
@ -262,10 +264,30 @@ function hToTime (h) { const hh = Math.floor(h); const mm = Math.round((h - hh)
// Persistée dans le même fichier que la politique de reprise (sous-objet `booking`),
// éditée via /roster/policy (lib/roster-assistant.js). Appliquée ici à TOUTE source
// de créneaux (page /book, vue agent, fit) → comportement cohérent partout.
const BOOKING_DEFAULTS = { lead_hours: 24, day_start: 8, day_end: 18, days_offered: [1, 2, 3, 4, 5], horizon_days: 21, max_per_day: 0, hold_minutes: 10 }
const BOOKING_DEFAULTS = { lead_hours: 24, day_start: 8, day_end: 18, days_offered: [1, 2, 3, 4, 5], horizon_days: 21, max_per_day: 0, hold_minutes: 10, skill_by_type: {} }
function getBookingPolicy () {
try { const p = JSON.parse(fs.readFileSync(POLICY_FILE, 'utf8')); return { ...BOOKING_DEFAULTS, ...(p.booking || {}) } } catch { return { ...BOOKING_DEFAULTS } }
}
// Compétence REQUISE pour un job → seuls les techs qui l'ont produisent des créneaux (techGaps filtre).
// Un ajout TV/borne WiFi peut mapper à '' (n'importe qui) ; une installation/réparation à 'installateur'.
// Source : champ explicite Dispatch Job.required_skill, sinon table service_type → tag (politique booking).
function skillForJob (job) {
if (!job) return ''
if (job.required_skill) return String(job.required_skill).trim()
const map = getBookingPolicy().skill_by_type || {}
return String(map[job.service_type] || '').trim()
}
// Enrichit des jobs avec une adresse LISIBLE (le champ service_location est un code « LOC-… »).
// Batch : 1 seule requête sur Service Location pour tous les codes distincts.
async function attachLocations (jobs) {
const codes = [...new Set((jobs || []).map(j => j.service_location).filter(Boolean))]
if (!codes.length) return jobs
let locs = []
try { locs = await erp.list('Service Location', { filters: [['name', 'in', codes]], fields: ['name', 'address_line', 'city', 'location_name'], limit: codes.length + 10 }) } catch (e) { return jobs }
const m = {}; for (const l of locs) m[l.name] = l
for (const j of jobs) { const l = m[j.service_location]; if (l) j.location_label = [l.address_line, l.city].filter(Boolean).join(', ') || l.location_name || '' }
return jobs
}
function nowMs () { return new Date().getTime() }
// Holds en mémoire : quand un client/agent sélectionne une fenêtre, on la réserve
// quelques minutes pour éviter qu'un autre la prenne pendant la confirmation.
@ -320,6 +342,18 @@ function techGaps (a, d, skill, zone) {
return { tech: t, gaps }
}
// Premier créneau libre (en heures, ex 8.5) d'un tech un jour donné pour une durée `dur`.
// Aucun trou assez large → empile après le dernier bloc (surbook visible) ; pas de shift régulier → null.
// Partagé par /roster/assign-job (drag-drop) et /roster/backfill-start-times.
function firstFitStart (d, techId, date, dur) {
const mine = d.asgs.filter(a => a.tech === techId && a.date === date && !(d.tplByName[a.shift] && d.tplByName[a.shift].on_call))
if (!mine.length) return null
let best = null
for (const a of mine) { const g = techGaps(a, d, null, null); if (!g) continue; for (const [gs, ge] of g.gaps) if (ge - gs >= dur - 1e-6) { if (best == null || gs < best) best = gs; break } }
if (best == null) { const tpl = d.tplByName[mine[0].shift]; const sh = timeToH(tpl && tpl.start_time) || 8; const day = (d.booked[techId + '|' + date] || []).slice().sort((x, y) => x.e - y.e); best = day.length ? day[day.length - 1].e : sh }
return best
}
async function bookingSlots ({ skill, zone, duration = 1, start, days = 7, limit = 24, aggregate = false, ignorePolicy = false } = {}) {
const dur = Number(duration) || 1
const pol = getBookingPolicy()
@ -381,13 +415,14 @@ async function fitBooking ({ skill, zone, duration = 1, prefs = [] } = {}) {
function todayET () { return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) }
async function jobByToken (token) {
if (!token) return null
const rows = await erp.list('Dispatch Job', { filters: [['booking_token', '=', token]], fields: ['name', 'service_location', 'duration_h', 'scheduled_date', 'start_time', 'booking_status'], limit: 1 })
const rows = await erp.list('Dispatch Job', { filters: [['booking_token', '=', token]], fields: ['name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'start_time', 'booking_status'], limit: 1 })
return rows[0] || null
}
async function confirmWindow (jobName, date, start, duration) {
async function confirmWindow (jobName, date, start, duration, skill) {
// À la confirmation on veut juste vérifier que le tech est ENCORE physiquement libre
// (pas re-filtrer par la politique d'offre) → ignorePolicy.
const day = await bookingSlots({ duration, start: date, days: 1, limit: 300, ignorePolicy: true })
// (pas re-filtrer par la politique d'offre) → ignorePolicy. MAIS on garde le filtre COMPÉTENCE
// (skill) sinon on pourrait assigner un tech non qualifié au créneau choisi.
const day = await bookingSlots({ skill, duration, start: date, days: 1, limit: 300, ignorePolicy: true })
const slot = day.find(s => s.start === start)
if (!slot) return { ok: false, message: 'Ce créneau vient d\'être pris — choisissez-en un autre.' }
const st = start.length === 5 ? start + ':00' : start
@ -431,16 +466,16 @@ async function handlePublicBooking (req, res, method, path, url) {
const token = url.searchParams.get('token') || ''
if (path === '/book/api/options' && method === 'GET') {
const job = await jobByToken(token); if (!job) return json(res, 404, { ok: false, error: 'lien invalide' })
const dur = Number(job.duration_h) || 1
const windows = await bookingSlots({ duration: dur, start: todayET(), days: getBookingPolicy().horizon_days || 21, aggregate: true, limit: 60 })
return json(res, 200, { ok: true, job: { location: job.service_location || '', duration: dur, scheduled: job.scheduled_date || '' }, windows })
const dur = Number(job.duration_h) || 1; const skill = skillForJob(job) // seuls les techs qualifiés
const windows = await bookingSlots({ skill, duration: dur, start: todayET(), days: getBookingPolicy().horizon_days || 21, aggregate: true, limit: 60 })
return json(res, 200, { ok: true, job: { location: job.service_location || '', service_type: job.service_type || '', required_skill: skill, duration: dur, scheduled: job.scheduled_date || '' }, windows })
}
if (path === '/book/api/submit' && method === 'POST') {
const job = await jobByToken(token); if (!job) return json(res, 404, { ok: false, error: 'lien invalide' })
const b = await parseBody(req); const dur = Number(job.duration_h) || 1
const b = await parseBody(req); const dur = Number(job.duration_h) || 1; const skill = skillForJob(job)
if (b.mode === 'rank' && Array.isArray(b.prefs) && b.prefs.length) {
const fit = await fitBooking({ duration: dur, prefs: b.prefs })
if (fit.chosen) { const r = await confirmWindow(job.name, fit.chosen.date, fit.chosen.start, dur); if (r.ok) return json(res, 200, { ...r, rank: fit.chosen.rank }) }
const fit = await fitBooking({ skill, duration: dur, prefs: b.prefs })
if (fit.chosen) { const r = await confirmWindow(job.name, fit.chosen.date, fit.chosen.start, dur, skill); if (r.ok) return json(res, 200, { ...r, rank: fit.chosen.rank }) }
await retryWrite(() => erp.update('Dispatch Job', job.name, { booking_prefs: JSON.stringify(b.prefs), booking_status: 'Proposé' }))
return json(res, 200, { ok: true, confirmed: false, message: 'Vos disponibilités sont enregistrées — nous vous confirmerons sous peu.' })
}
@ -476,17 +511,21 @@ async function occupancyByTechDay (start, days) {
const dates = rangeDates(start, days)
const jobs = await erp.list('Dispatch Job', {
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress']]],
fields: ['assigned_tech', 'scheduled_date', 'start_time', 'duration_h'], limit: 5000,
fields: ['name', 'subject', 'customer_name', 'service_type', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority'], limit: 5000,
})
const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 } // ordre d'affichage
const m = {}
for (const j of jobs) {
if (!j.assigned_tech || !j.scheduled_date) continue
const k = j.assigned_tech + '|' + j.scheduled_date
const o = m[k] || (m[k] = { h: 0, blocks: [] })
const o = m[k] || (m[k] = { h: 0, blocks: [], jobs: [] })
const dur = Number(j.duration_h) || 0
o.h += dur
if (j.start_time) { const s = timeToH(j.start_time); o.blocks.push({ s, e: s + dur }) }
const s = j.start_time ? timeToH(j.start_time) : null
if (s != null) o.blocks.push({ s, e: s + dur })
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, priority: j.priority || 'low' })
}
for (const k in m) m[k].jobs.sort((a, b) => (PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3) || ((a.start_h ?? 99) - (b.start_h ?? 99))) // priorité puis heure
return m
}
@ -590,8 +629,18 @@ async function handle (req, res, method, path, url) {
fields: ['name', 'customer_name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'start_time', 'assigned_tech', 'booking_status', 'status'],
orderBy: 'modified desc', limit: 100,
})
for (const j of rows) j.required_skill = skillForJob(j) // tag requis (table service_type → compétence)
await attachLocations(rows) // adresse lisible (service_location = code)
return json(res, 200, { jobs: rows })
}
// Méta pour l'éditeur de la table « type de job → compétence requise » (#56 booking)
if (path === '/roster/book/meta' && method === 'GET') {
const tk = await fetchTechnicians()
const skills = [...new Set(tk.flatMap(t => t.skills || []))].sort()
let types = []
try { const jb = await erp.list('Dispatch Job', { fields: ['service_type'], limit: 3000 }); types = [...new Set(jb.map(j => j.service_type).filter(Boolean))].sort() } catch (e) {}
return json(res, 200, { service_types: types, skills, skill_by_type: getBookingPolicy().skill_by_type || {} })
}
// Générer le lien client (token) pour un job → URL publique /book?token=
if (path === '/roster/book/link' && method === 'POST') {
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
@ -621,9 +670,118 @@ async function handle (req, res, method, path, url) {
}
// File « À reporter » (jobs à recontacter) — pour le superviseur
if (path === '/roster/jobs-to-reschedule' && method === 'GET') {
const rows = await erp.list('Dispatch Job', { filters: [['booking_status', '=', 'À reporter']], fields: ['name', 'customer_name', 'service_location', 'service_type', 'scheduled_date', 'assigned_tech', 'booking_token'], limit: 100 })
const rows = await erp.list('Dispatch Job', { filters: [['booking_status', '=', 'À reporter']], fields: ['name', 'customer_name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'assigned_tech', 'booking_token'], limit: 100 })
await attachLocations(rows || []) // adresse lisible
return json(res, 200, { jobs: rows || [] })
}
// Impact d'un retrait de compétence : jobs assignés à un tech qui EXIGENT cette compétence (devenus invalides).
if (path === '/roster/skill-impact' && method === 'GET') {
const tech = qs.get('tech'); const skill = qs.get('skill')
if (!tech || !skill) return json(res, 400, { error: 'tech + skill requis' })
const rows = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', tech], ['status', 'in', ['open', 'assigned']]], fields: ['name', 'customer_name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'start_time'], limit: 500 })
const impacted = (rows || []).filter(j => skillForJob(j) === skill)
await attachLocations(impacted)
return json(res, 200, { jobs: impacted })
}
// Impact d'une ABSENCE : jobs assignés à un tech sur des dates données (devenus à redistribuer).
if (path === '/roster/absence-impact' && method === 'GET') {
const tech = qs.get('tech'); const dates = (qs.get('dates') || '').split(',').filter(Boolean)
if (!tech || !dates.length) return json(res, 400, { error: 'tech + dates requis' })
const rows = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', tech], ['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned']]], fields: ['name', 'customer_name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'start_time'], limit: 500 })
await attachLocations(rows || [])
return json(res, 200, { jobs: rows || [] })
}
// Candidats CLASSÉS pour reprendre un job : techs qualifiés + LIBRES au même créneau (≠ l'impacté).
if (path === '/roster/job-candidates' && method === 'GET') {
const jobName = qs.get('job'); const exclude = qs.get('exclude') || ''
if (!jobName) return json(res, 400, { error: 'job requis' })
let job = null
try { job = await erp.get('Dispatch Job', jobName, { fields: ['name', 'scheduled_date', 'start_time', 'duration_h', 'service_type'] }) } catch (e) {}
if (!job || !job.scheduled_date || !job.start_time) return json(res, 200, { candidates: [], date: (job && job.scheduled_date) || '', start: '' })
const skill = skillForJob(job); const start = String(job.start_time).slice(0, 5); const dur = Number(job.duration_h) || 1
const slots = await bookingSlots({ skill, duration: dur, start: job.scheduled_date, days: 1, limit: 500, ignorePolicy: true })
const seen = new Set(); const cands = []
for (const s of slots) { if (s.start !== start || s.tech === exclude || seen.has(s.tech)) continue; seen.add(s.tech); cands.push({ tech: s.tech, tech_name: s.tech_name }) }
return json(res, 200, { candidates: cands.slice(0, 6), date: job.scheduled_date, start, skill })
}
// Redistribuer les jobs impactés. 3 voies : b.plan (réassign explicite par tech / requeue) · b.mode 'auto' (re-match
// auto au même créneau, compétence b.skill ou par job) · 'requeue' (À recontacter).
if (path === '/roster/skill-impact/redistribute' && method === 'POST') {
const b = await parseBody(req); const mode = b.mode || 'requeue'
if (Array.isArray(b.plan)) { // plan explicite : { job, tech } (réassigner) OU { job, requeue:true }
let reassigned = 0; let requeued = 0; let errors = 0
for (const p of b.plan) {
if (!p || !p.job) { errors++; continue }
if (p.tech && !p.requeue) { const r = await retryWrite(() => erp.update('Dispatch Job', p.job, { assigned_tech: p.tech, status: 'assigned', booking_status: 'Confirmé' })); if (r.ok) reassigned++; else errors++ }
else { const r = await retryWrite(() => erp.update('Dispatch Job', p.job, { booking_status: 'À reporter', scheduled_date: null, start_time: null, assigned_tech: null, status: 'open' })); if (r.ok) requeued++; else errors++ }
}
return json(res, 200, { ok: errors === 0, reassigned, requeued, errors })
}
let reassigned = 0; let requeued = 0; let errors = 0; const details = []
for (const jobName of (b.jobs || [])) {
let job = null
try { job = await erp.get('Dispatch Job', jobName, { fields: ['name', 'scheduled_date', 'start_time', 'duration_h', 'customer_name', 'service_type'] }) } catch (e) {}
if (!job) { errors++; continue }
if (mode === 'auto' && job.scheduled_date && job.start_time) { // re-match au même créneau, tech qualifié ≠ l'impacté
const sk = b.skill || skillForJob(job) // compétence requise (globale si fournie, sinon par job → cas absence)
const r = await confirmWindow(jobName, job.scheduled_date, String(job.start_time).slice(0, 5), Number(job.duration_h) || 1, sk)
if (r.ok) { reassigned++; details.push({ job: jobName, customer: job.customer_name, tech: r.tech, action: 'réassigné' }); continue }
}
const r = await retryWrite(() => erp.update('Dispatch Job', jobName, { booking_status: 'À reporter', scheduled_date: null, start_time: null, assigned_tech: null, status: 'open' }))
if (r.ok) { requeued++; details.push({ job: jobName, customer: job.customer_name, action: 'à recontacter' }) } else errors++
}
return json(res, 200, { ok: errors === 0, reassigned, requeued, errors, details })
}
// Jobs À ASSIGNER (non assignés) avec leur groupe/dépendances (parent_job, depends_on, step_order, chaîne On Hold).
if (path === '/roster/unassigned-jobs' && method === 'GET') {
const rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'subject', 'customer_name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech'], orderBy: 'modified desc', limit: 400 })
const jobs = (rows || []).filter(j => !j.assigned_tech) // non assignés
for (const j of jobs) j.required_skill = skillForJob(j)
await attachLocations(jobs)
return json(res, 200, { jobs })
}
// Assigner un job à un tech (depuis le panneau flottant glisser-déposer) — date = case déposée.
// On pose AUSSI un start_time (premier trou libre du shift) : sans heure, le job compte dans
// les heures occupées mais n'affiche AUCUN bloc sur la timeline → la barre d'occupation semble figée.
if (path === '/roster/assign-job' && method === 'POST') {
const b = await parseBody(req); if (!b.job || !b.tech) return json(res, 400, { error: 'job + tech requis' })
let dur = 1
try { const jb = await erp.get('Dispatch Job', b.job, { fields: ['duration_h'] }); dur = Number(jb && jb.duration_h) || 1 } catch (e) {}
const patch = { assigned_tech: b.tech, status: 'assigned', duration_h: dur } // duration_h garanti → occupation comptée
if (b.date) patch.scheduled_date = b.date
let placed = null
if (b.start) patch.start_time = (String(b.start).length === 5 ? b.start + ':00' : b.start)
else if (b.date) { // premier trou libre dans le shift du tech ce jour-là
try { const d = await loadBookingData(b.date, 1); const h = firstFitStart(d, b.tech, b.date, dur); if (h != null) { placed = hToTime(h); patch.start_time = placed } } catch (e) {}
}
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, tech: b.tech, start_time: placed, duration_h: dur })
}
// Backfill : pose un start_time (premier trou libre) sur les jobs DÉJÀ assignés mais SANS heure
// → leurs blocs d'occupation apparaissent enfin sur la grille. Idempotent (ne touche que start_time vide).
if (path === '/roster/backfill-start-times' && method === 'POST') {
const b = await parseBody(req)
const start = b.start || todayET(); const days = Number(b.days) || 14
const dates = rangeDates(start, days)
const rows = await erp.list('Dispatch Job', {
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress']]],
fields: ['name', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h'], limit: 5000,
})
const todo = (rows || []).filter(j => j.assigned_tech && j.scheduled_date && !j.start_time)
const d = await loadBookingData(start, days) // chargé UNE fois ; on mute d.booked au fil de l'eau pour empiler
let placed = 0; const details = []
for (const j of todo) { // SÉQUENTIEL (frappe_pg ne supporte pas la concurrence)
const dur = Number(j.duration_h) || 1
const k = j.assigned_tech + '|' + j.scheduled_date
if (!d.booked[k]) d.booked[k] = []
const h = firstFitStart(d, j.assigned_tech, j.scheduled_date, dur)
if (h == null) { details.push({ job: j.name, skipped: 'pas de shift régulier ce jour-là' }); continue }
const r = await retryWrite(() => erp.update('Dispatch Job', j.name, { start_time: hToTime(h), duration_h: dur }))
if (r.ok) { d.booked[k].push({ s: h, e: h + dur }); placed++; details.push({ job: j.name, tech: j.assigned_tech, date: j.scheduled_date, start: hToTime(h) }) }
else details.push({ job: j.name, error: r.error || 'update failed' })
}
return json(res, 200, { ok: true, candidates: todo.length, placed, details })
}
// Hold : réserver temporairement une fenêtre (agent au tél. ou client qui sélectionne)
// → la fenêtre est retirée des dispos des autres pendant `minutes` (défaut politique).
if (path === '/roster/book/hold' && method === 'POST') {
@ -727,14 +885,17 @@ async function handle (req, res, method, path, url) {
return json(res, 200, { ok: errors === 0, created, deleted, errors, notified, unchanged })
}
// Garde : matérialiser la rotation sur un HORIZON (plusieurs semaines) — comme un évènement récurrent.
// Wipe ciblé sur les shifts de garde dans l'horizon + recréation (idempotent, reflète l'édition de la séquence).
// Wipe ROBUSTE : on supprime TOUTE garde (tout template on_call) dans la plage, pas seulement les shifts
// courants — sinon une ancienne garde sous un autre nom de template survit et fausse la rotation
// (« la suite est bousillée »). Puis recréation depuis la rotation déterministe (= le calque live).
if (path === '/roster/garde/apply' && method === 'POST') {
const b = await parseBody(req)
const dates = rangeDates(b.start, (b.weeks || 1) * 7)
const shifts = b.shifts || []
const tpls = await fetchTemplates()
const gardeTpls = tpls.filter(t => t.on_call).map(t => t.name)
let deleted = 0
if (shifts.length) {
const existing = await erp.list('Shift Assignment', { filters: [['shift_template', 'in', shifts], ['assignment_date', 'in', dates]], fields: ['name'], limit: 3000 })
if (gardeTpls.length && dates.length) {
const existing = await erp.list('Shift Assignment', { filters: [['shift_template', 'in', gardeTpls], ['assignment_date', 'in', dates]], fields: ['name'], limit: 5000 })
for (const a of existing) { const r = await retryWrite(() => erp.remove('Shift Assignment', a.name)); if (r.ok) deleted++ }
}
let created = 0; let errors = 0
@ -792,7 +953,10 @@ async function handle (req, res, method, path, url) {
const techId = decodeURIComponent(mSkills[1]); const b = await parseBody(req)
const techName = await resolveTechName(techId)
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { skills: (b.skills || '').trim() }))
const patch = { skills: (b.skills || '').trim() }
if (b.skill_levels !== undefined) patch.skill_levels = typeof b.skill_levels === 'string' ? b.skill_levels : JSON.stringify(b.skill_levels || {}) // niveaux 15 par compétence (JSON)
if (b.skill_eff !== undefined) patch.skill_eff = typeof b.skill_eff === 'string' ? b.skill_eff : JSON.stringify(b.skill_eff || {}) // efficacité (facteur vitesse) PAR compétence (JSON)
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch))
return json(res, r.ok ? 200 : 500, { ...r, technician: techId })
}
const mCost = path.match(/^\/roster\/technician\/(.+)\/cost$/)