Roster AI (planification) + prise de rendez-vous client
Solveur OR-Tools (services/roster-solver) : couverture, compétences, équité, coût chargé, cadence/efficacité, capacité-par-job ; contraintes dures/souples façon Timefold. Hub (lib/roster.js) : génération via solveur, publication par réécriture de semaine (anti-doublons), demande (effectif ou nb de jobs), cadence/coût/ compétences par tech, pause, congés (Tech Availability + approbation), booking (slots roster-aware / fit 3-dispos / confirm) + portail public /book. Réessai sur serialization failures frappe_pg ; appels ERP séquentiels. Ops : page Planification (grille compacte « J8 », multi-shift, drag-select + undo/redo, modèles de semaine, éditeur cadence&coût, congés, SMS opt-in), page Rendez-vous (répartiteur), jobColor tech en pause → tickets rouges. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d8366ab0be
commit
f4138cdd75
64
apps/ops/src/api/roster.js
Normal file
64
apps/ops/src/api/roster.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* Roster (Planification) API — appelle targo-hub /roster/*.
|
||||
* Le backend lit les techs/modèles/besoins dans ERPNext facturation et appelle
|
||||
* le solveur OR-Tools. Voir services/targo-hub/lib/roster.js.
|
||||
*/
|
||||
import { HUB_URL as HUB } from 'src/config/hub'
|
||||
|
||||
async function jget (path) {
|
||||
const r = await fetch(HUB + path)
|
||||
if (!r.ok) throw new Error('Roster API ' + r.status)
|
||||
return r.json()
|
||||
}
|
||||
async function jpost (path, body) {
|
||||
const r = await fetch(HUB + path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body || {}),
|
||||
})
|
||||
if (!r.ok) throw new Error('Roster API ' + r.status)
|
||||
return r.json()
|
||||
}
|
||||
async function jput (path, body) {
|
||||
const r = await fetch(HUB + path, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })
|
||||
if (!r.ok) throw new Error('Roster API ' + r.status)
|
||||
return r.json()
|
||||
}
|
||||
|
||||
export const listTechnicians = () => jget('/roster/technicians')
|
||||
export const listTemplates = () => jget('/roster/templates')
|
||||
export const createTemplate = (t) => jpost('/roster/templates', t)
|
||||
export const listRequirements = (start, days = 7) => jget(`/roster/requirements?start=${start}&days=${days}`)
|
||||
export const createRequirement = (r) => jpost('/roster/requirements', r)
|
||||
export const listAssignments = (start, days = 7) => jget(`/roster/assignments?start=${start}&days=${days}`)
|
||||
export const getCoverage = (start, days = 7) => jget(`/roster/coverage?start=${start}&days=${days}`)
|
||||
export const getStats = (start, days = 7) => jget(`/roster/stats?start=${start}&days=${days}`)
|
||||
export const generate = (start, days = 7, weights) => jpost('/roster/generate', { start, days, weights })
|
||||
export const publish = (assignments) => jpost('/roster/publish', { assignments })
|
||||
export const publishWeek = (start, days, assignments, notify) => jpost('/roster/publish-week', { start, days, assignments, notify })
|
||||
export const updateTemplate = (name, patch) => jput('/roster/template/' + encodeURIComponent(name), patch)
|
||||
export async function deleteShiftTemplate (name) {
|
||||
const r = await fetch(HUB + '/roster/template/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||
if (!r.ok) throw new Error('Suppression modèle: ' + r.status)
|
||||
return r.json()
|
||||
}
|
||||
export const bulkRequirements = (requirements) => jpost('/roster/requirements/bulk', { requirements })
|
||||
export const clearRequirements = (start, days = 7) => jpost('/roster/requirements/clear', { start, days })
|
||||
export async function deleteAssignment (name) {
|
||||
const r = await fetch(HUB + '/roster/assignment/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||
if (!r.ok) throw new Error('Suppression échouée: ' + r.status)
|
||||
return r.json()
|
||||
}
|
||||
export const listAvailability = (status) => jget('/roster/availability' + (status ? '?status=' + encodeURIComponent(status) : ''))
|
||||
export const requestAvailability = (a) => jpost('/roster/availability', a)
|
||||
export const approveAvailability = (name, body) => jpost('/roster/availability/' + encodeURIComponent(name) + '/approve', body || {})
|
||||
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 })
|
||||
|
||||
// ── Prise de RDV ──
|
||||
export const bookJobs = () => jget('/roster/book/jobs')
|
||||
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)
|
||||
|
|
@ -82,6 +82,11 @@ export function jobSvcCode (job) {
|
|||
}
|
||||
|
||||
export function jobColor (job, techColors, store) {
|
||||
// Tech en pause/absent (statut interne 'off') → ses jobs en ROUGE (à réassigner)
|
||||
if (job.assignedTech && store) {
|
||||
const at = store.technicians.find(x => x.id === job.assignedTech)
|
||||
if (at && at.status === 'off') return '#e53935'
|
||||
}
|
||||
if (SVC_COLORS[job.service_type]) return SVC_COLORS[job.service_type]
|
||||
const s = (job.subject||'').toLowerCase()
|
||||
if (s.includes('internet')) return '#3b82f6'
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ export const navItems = [
|
|||
{ path: '/', icon: 'LayoutDashboard', label: 'Tableau de bord', requires: 'view_dashboard_kpi' },
|
||||
{ path: '/clients', icon: 'Users', label: 'Clients', requires: 'view_clients' },
|
||||
{ path: '/dispatch', icon: 'Truck', label: 'Dispatch', requires: 'view_all_jobs' },
|
||||
{ path: '/planification', icon: 'CalendarRange', label: 'Planification', requires: 'view_all_jobs' },
|
||||
{ path: '/rdv', icon: 'CalendarClock', label: 'Rendez-vous', requires: 'view_all_jobs' },
|
||||
{ path: '/tickets', icon: 'Ticket', label: 'Tickets', requires: 'view_all_tickets' },
|
||||
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users' },
|
||||
{ path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' },
|
||||
|
|
|
|||
550
apps/ops/src/pages/PlanificationPage.vue
Normal file
550
apps/ops/src/pages/PlanificationPage.vue
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
<template>
|
||||
<q-page padding>
|
||||
<div class="row items-center q-mb-sm q-gutter-xs">
|
||||
<div class="text-h6 text-weight-bold">Planification</div>
|
||||
<q-chip v-if="dirty" dense size="sm" color="orange" text-color="white" icon="circle">{{ dirtyCount }} non publié(s)</q-chip>
|
||||
<q-space />
|
||||
<q-btn-group flat>
|
||||
<q-btn dense flat icon="chevron_left" @click="navWeek(-1)"><q-tooltip>Semaine précédente</q-tooltip></q-btn>
|
||||
<q-btn dense flat label="Auj." @click="navToday" />
|
||||
<q-btn dense flat icon="chevron_right" @click="navWeek(1)"><q-tooltip>Semaine suivante</q-tooltip></q-btn>
|
||||
</q-btn-group>
|
||||
<q-input dense outlined type="date" v-model="start" style="width:160px" @update:model-value="onWeekChange" />
|
||||
<q-select dense outlined v-model="days" :options="[7, 14]" style="width:80px" emit-value map-options @update:model-value="onDaysChange" />
|
||||
<q-btn dense flat round icon="undo" :disable="!history.length" @click="undo"><q-tooltip>Annuler (Ctrl+Z)</q-tooltip></q-btn>
|
||||
<q-btn dense flat round icon="redo" :disable="!future.length" @click="redo"><q-tooltip>Rétablir (Ctrl+Shift+Z)</q-tooltip></q-btn>
|
||||
<q-btn dense :outline="!showDemand" :unelevated="showDemand" color="indigo" icon="tune" label="Demande" @click="showDemand = !showDemand" />
|
||||
<q-btn dense outline color="brown" icon="bookmark" label="Modèles">
|
||||
<q-menu>
|
||||
<q-list dense style="min-width:230px">
|
||||
<q-item clickable v-close-popup @click="saveTemplate"><q-item-section avatar><q-icon name="save" /></q-item-section><q-item-section>Enregistrer la semaine comme modèle…</q-item-section></q-item>
|
||||
<q-separator v-if="weekTemplates.length" />
|
||||
<q-item-label v-if="weekTemplates.length" header>Appliquer</q-item-label>
|
||||
<q-item v-for="(tm, i) in weekTemplates" :key="i" clickable @click="applyTemplate(tm)">
|
||||
<q-item-section>{{ tm.name }}</q-item-section>
|
||||
<q-item-section side><q-btn flat dense round size="sm" icon="delete" color="grey-6" @click.stop="deleteTemplate(i)" /></q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
<q-btn unelevated color="primary" icon="auto_awesome" label="Générer" :loading="generating" @click="doGenerate" />
|
||||
<q-checkbox v-model="notifySms" label="SMS" dense size="sm"><q-tooltip>Notifier les techs par SMS à la publication</q-tooltip></q-checkbox>
|
||||
<q-btn :outline="!dirty" :unelevated="dirty" color="positive" icon="cloud_upload" :label="dirty ? ('Publier (' + dirtyCount + ')') : 'Publier'" :loading="publishing" :disable="!dirty" @click="doPublish" />
|
||||
<q-btn flat dense round icon="refresh" :loading="loading" @click="() => guard(loadWeek)" />
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="row items-center q-gutter-sm q-mb-sm">
|
||||
<q-input dense outlined clearable v-model="search" placeholder="Rechercher un technicien…" style="width:230px"><template #prepend><q-icon name="search" /></template></q-input>
|
||||
<q-select dense outlined clearable v-model="groupFilter" :options="groupOptions" emit-value map-options label="Équipe" style="width:180px" />
|
||||
<q-input dense outlined type="number" v-model.number="maxHours" label="Max h/sem" style="width:110px" />
|
||||
<q-btn dense flat icon="speed" label="Cadence équipe" @click="openTeamEditor" />
|
||||
<q-btn dense flat icon="beach_access" label="Congés" @click="openLeave" />
|
||||
<span class="text-caption text-grey-6">{{ visibleTechs.length }} / {{ techs.length }} techs</span>
|
||||
</div>
|
||||
|
||||
<!-- Demande -->
|
||||
<q-card v-if="showDemand" flat bordered class="q-mb-md">
|
||||
<q-card-section class="q-pb-none">
|
||||
<div class="row items-center">
|
||||
<div class="text-subtitle2 text-weight-bold">Demande — effectif requis par créneau</div><q-space />
|
||||
<q-btn dense flat icon="schedule" label="Types de shift" @click="openShiftEditor" />
|
||||
<q-btn dense flat icon="add" label="Ajouter" @click="addDemand" />
|
||||
<q-btn dense unelevated color="indigo" icon="playlist_add_check" label="Appliquer à la semaine" :loading="applying" class="q-ml-sm" @click="applyDemand" />
|
||||
</div>
|
||||
<div class="text-caption text-grey-7 q-mt-xs">Coche les jours <b>fériés</b> (F) dans l'en-tête · fin de semaine = sam/dim (auto). Si <b>Durée/job</b> > 0, les nombres = <b>nb de jobs</b> → effectif = ⌈jobs × durée ÷ heures du shift⌉ (compétences requises = colonne Compétences).</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<table class="demand-tbl">
|
||||
<thead><tr><th>Modèle</th><th>Zone</th><th>Compétences</th><th>Durée/job (h)</th><th>Semaine</th><th>Fin de sem.</th><th>Férié</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(d, i) in demand" :key="i">
|
||||
<td><q-select dense options-dense outlined v-model="d.shift" :options="tplOptions" emit-value map-options style="min-width:150px" @update:model-value="saveDemand" /></td>
|
||||
<td><q-input dense outlined v-model="d.zone" style="width:120px" @update:model-value="saveDemand" /></td>
|
||||
<td><q-input dense outlined v-model="d.skills" placeholder="fibre,cuivre" style="width:130px" @update:model-value="saveDemand" /></td>
|
||||
<td><q-input dense outlined type="number" step="0.5" v-model.number="d.job_h" placeholder="0" style="width:80px" @update:model-value="saveDemand" /></td>
|
||||
<td><q-input dense outlined type="number" v-model.number="d.weekday" style="width:70px" @update:model-value="saveDemand" /></td>
|
||||
<td><q-input dense outlined type="number" v-model.number="d.weekend" style="width:70px" @update:model-value="saveDemand" /></td>
|
||||
<td><q-input dense outlined type="number" v-model.number="d.holiday" style="width:70px" @update:model-value="saveDemand" /></td>
|
||||
<td><q-btn flat dense round size="sm" icon="delete" color="grey-7" @click="removeDemand(i)" /></td>
|
||||
</tr>
|
||||
<tr v-if="!demand.length"><td colspan="8" class="text-grey-6 q-pa-sm">Aucune ligne — clique « Ajouter ».</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-banner v-if="solverStats" dense rounded class="q-mb-md" :class="solverStats.shortfall ? 'bg-orange-1 text-orange-9' : 'bg-green-1 text-green-9'">
|
||||
<q-icon :name="solverStats.shortfall ? 'warning' : 'check_circle'" class="q-mr-xs" />
|
||||
{{ solverStats.assignments }} assignations · {{ solverStats.shortfall ? (solverStats.shortfall + ' poste(s) non couvert(s)') : 'couverture complète' }} · équité {{ solverStats.spread }} h · {{ solverStats.ms }} ms
|
||||
</q-banner>
|
||||
|
||||
<q-banner v-if="selection.length" dense rounded class="bg-teal-1 text-teal-9 q-mb-sm">
|
||||
{{ selection.length }} cellule(s) — assigner :
|
||||
<q-btn v-for="t in templates" :key="t.name" dense unelevated size="sm" class="q-mx-xs" :style="chip(t.color)" :label="code(t)" @click="assignBulk(t)" />
|
||||
<q-btn dense flat size="sm" icon="layers_clear" label="Libérer" @click="clearBulk" />
|
||||
<q-btn dense flat size="sm" icon="close" label="Annuler" @click="selection = []" />
|
||||
</q-banner>
|
||||
|
||||
<div class="row items-center q-gutter-xs q-mb-sm text-caption">
|
||||
<span class="text-grey-7 q-mr-xs">Légende :</span>
|
||||
<template v-for="t in templates" :key="t.name"><span class="code-chip" :style="chip(t.color)">{{ code(t) }}</span><span class="text-grey-7 q-mr-sm">{{ t.template_name }}</span></template>
|
||||
<span class="code-chip" style="background:#e0e0e0;color:#777">P</span><span class="text-grey-7 q-mr-sm">pause</span>
|
||||
<span class="free q-mr-xs">·</span><span class="text-grey-7 q-mr-sm">libre</span>
|
||||
<span class="cell-dirty-demo q-mr-xs">J</span><span class="text-grey-7 q-mr-sm">modifié (non publié)</span>
|
||||
<span class="text-teal-8">· <b>glisser</b> = sélection · shift+clic = bloc · clic en-tête = colonne · clic nom = rangée · ctrl+clic = +1</span>
|
||||
</div>
|
||||
|
||||
<div class="grid-wrap">
|
||||
<table class="roster-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="tech-col">Technicien</th>
|
||||
<th v-for="(d, di) in dayList" :key="d.iso" class="clk" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso) }" @click="maybeSelectCol(di)">
|
||||
<div class="dow">{{ d.dow }}</div><div class="dnum">{{ d.dnum }}</div>
|
||||
<q-badge v-if="gapByDay[d.iso]" color="red" floating style="top:2px;right:2px">{{ gapByDay[d.iso] }}</q-badge>
|
||||
<div class="hol-toggle" :class="{ on: isHoliday(d.iso) }" @click.stop="toggleHoliday(d.iso)"><q-tooltip>Marquer férié</q-tooltip>F</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(t, ti) in visibleTechs" :key="t.id" :class="{ paused: isPaused(t) }">
|
||||
<td class="tech-col clk" @click="maybeSelectRow(ti)">
|
||||
<q-btn flat round dense size="9px" :icon="isPaused(t) ? 'play_arrow' : 'pause'" :color="isPaused(t) ? 'grey' : 'primary'" @click.stop="togglePause(t)"><q-tooltip>{{ isPaused(t) ? 'Réactiver' : 'Pause' }}</q-tooltip></q-btn>
|
||||
{{ t.name }}
|
||||
<span v-if="t.group" class="grp">{{ t.group }}</span>
|
||||
<span v-if="t.efficiency && t.efficiency !== 1" class="eff" :class="t.efficiency < 1 ? 'fast' : 'slow'">{{ effLabel(t.efficiency) }}</span>
|
||||
<span v-if="hoursOf(t.id)" :class="hoursOf(t.id) > maxHours ? 'text-red text-weight-bold' : 'text-grey-6'"> · {{ hoursOf(t.id) }}h<q-icon v-if="hoursOf(t.id) > maxHours" name="warning" color="red" size="12px" /></span>
|
||||
</td>
|
||||
<td v-for="(d, di) in dayList" :key="d.iso" class="cell" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso), sel: isSelected(t.id, d.iso), dirty: isCellDirty(t.id, d.iso) }" @mousedown="onDown(ti, di, $event)" @mouseenter="onEnter(ti, di)" @click="onCellClick(t, d, $event, ti, di)">
|
||||
<template v-if="cellsOf(t.id, d.iso).length"><span v-for="(a, ai) in cellsOf(t.id, d.iso)" :key="ai" class="code-chip" :style="chip(cellColor(a))">{{ cellCode(a) }}<small class="ch-h">{{ cellHours(a) }}</small></span></template>
|
||||
<span v-else-if="isPaused(t)" class="code-chip" style="background:#eee;color:#999">P</span>
|
||||
<span v-else class="free">·</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!visibleTechs.length"><td :colspan="dayList.length + 1" class="text-grey-6 q-pa-md text-center">Aucun technicien (filtre ?).</td></tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="sum"><td class="tech-col">👥 Effectif</td><td v-for="d in dayList" :key="d.iso" :class="{ weekend: d.weekend }">{{ stat(d.iso).staff || '' }}</td></tr>
|
||||
<tr class="sum"><td class="tech-col">⏱ Heures</td><td v-for="d in dayList" :key="d.iso" :class="{ weekend: d.weekend }">{{ stat(d.iso).hours || '' }}</td></tr>
|
||||
<tr class="sum"><td class="tech-col">🎫 Tickets</td><td v-for="d in dayList" :key="d.iso" :class="{ weekend: d.weekend }">{{ stat(d.iso).tickets || '' }}</td></tr>
|
||||
<tr v-if="weekCost" class="sum"><td class="tech-col">💲 Coût ({{ Math.round(weekCost) }} $/sem)</td><td v-for="d in dayList" :key="d.iso" :class="{ weekend: d.weekend }">{{ dayCost(d.iso) || '' }}</td></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="text-subtitle2 text-weight-bold q-mt-lg q-mb-sm">Couverture — dispo vs requis</div>
|
||||
<div v-if="!covRows.length" class="text-grey-6 q-mb-md">Aucun besoin défini. Utilise « Demande » → « Appliquer à la semaine ».</div>
|
||||
<div v-else class="grid-wrap">
|
||||
<table class="roster-grid">
|
||||
<thead><tr><th class="tech-col">Créneau</th><th v-for="d in dayList" :key="d.iso" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso) }"><div class="dow">{{ d.dow }}</div><div class="dnum">{{ d.dnum }}</div></th></tr></thead>
|
||||
<tbody><tr v-for="row in covRows" :key="row.key"><td class="tech-col">{{ row.label }}</td><td v-for="d in dayList" :key="d.iso" class="cell cov" :style="covStyle(row.key, d.iso)">{{ covText(row.key, d.iso) }}</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="showShiftEditor">
|
||||
<q-card style="min-width:580px">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<div class="text-subtitle1 text-weight-bold">Types de shift</div><q-space />
|
||||
<q-btn flat round dense icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<table class="demand-tbl">
|
||||
<thead><tr><th>Nom</th><th>Début</th><th>Fin</th><th>Heures</th><th>Couleur</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="t in editTpls" :key="t.name">
|
||||
<td><span class="code-chip" :style="chip(t.color)">{{ (t.template_name||'?')[0].toUpperCase() }}</span> {{ t.template_name }}</td>
|
||||
<td><q-input dense outlined type="time" v-model="t.start" style="width:115px" /></td>
|
||||
<td><q-input dense outlined type="time" v-model="t.end" style="width:115px" /></td>
|
||||
<td class="text-center text-weight-medium">{{ calcHours(t.start, t.end) }} h</td>
|
||||
<td><input type="color" v-model="t.color" style="width:36px;height:26px;border:none;background:none" /></td>
|
||||
<td><q-btn flat dense round size="sm" icon="save" color="primary" @click="saveShiftTpl(t)"><q-tooltip>Enregistrer</q-tooltip></q-btn><q-btn flat dense round size="sm" icon="delete" color="grey-7" @click="delShiftTpl(t)" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<q-separator class="q-my-md" />
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-input dense outlined v-model="newTpl.template_name" label="Nouveau type" style="width:170px" />
|
||||
<q-input dense outlined type="time" v-model="newTpl.start" label="Début" style="width:110px" />
|
||||
<q-input dense outlined type="time" v-model="newTpl.end" label="Fin" style="width:110px" />
|
||||
<span class="text-caption text-grey-7">{{ calcHours(newTpl.start, newTpl.end) }} h</span>
|
||||
<input type="color" v-model="newTpl.color" style="width:36px;height:26px;border:none;background:none" />
|
||||
<q-btn dense unelevated color="primary" icon="add" label="Ajouter" @click="addShiftTpl" />
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showLeave">
|
||||
<q-card style="min-width:680px">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<div class="text-subtitle1 text-weight-bold">Congés & disponibilités</div><q-space />
|
||||
<q-select dense outlined v-model="leaveFilter" :options="['Demandé', 'Approuvé', 'Refusé', '']" style="width:130px" @update:model-value="loadLeave" />
|
||||
<q-btn flat round dense icon="close" v-close-popup class="q-ml-sm" />
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<table class="demand-tbl" style="width:100%">
|
||||
<thead><tr><th>Technicien</th><th>Type</th><th>Du</th><th>Au</th><th>Motif</th><th>Statut</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="l in leaveRows" :key="l.name">
|
||||
<td>{{ l.technician_name || l.technician }}</td><td>{{ l.availability_type }}</td><td>{{ l.from_date }}</td><td>{{ l.to_date }}</td><td>{{ l.reason }}</td>
|
||||
<td><q-badge :color="l.status === 'Approuvé' ? 'green' : l.status === 'Refusé' ? 'red' : 'orange'">{{ l.status }}</q-badge></td>
|
||||
<td><template v-if="l.status === 'Demandé'"><q-btn flat dense round size="sm" icon="check" color="green" @click="approveLeave(l, false)" /><q-btn flat dense round size="sm" icon="close" color="red" @click="approveLeave(l, true)" /></template></td>
|
||||
</tr>
|
||||
<tr v-if="!leaveRows.length"><td colspan="7" class="text-grey-6 q-pa-sm">Aucune demande.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<q-separator class="q-my-md" />
|
||||
<div class="text-caption text-weight-bold q-mb-xs">Nouvelle demande</div>
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-select dense outlined v-model="newLeave.technician" :options="techOptions" emit-value map-options label="Technicien" style="width:200px" />
|
||||
<q-select dense outlined v-model="newLeave.availability_type" :options="['Congé', 'Pause', 'Indisponible', 'Maladie']" label="Type" style="width:130px" />
|
||||
<q-input dense outlined type="date" v-model="newLeave.from_date" label="Du" style="width:140px" />
|
||||
<q-input dense outlined type="date" v-model="newLeave.to_date" label="Au" style="width:140px" />
|
||||
<q-input dense outlined v-model="newLeave.reason" label="Motif" style="width:150px" />
|
||||
<q-btn dense unelevated color="primary" icon="add" label="Créer" @click="createLeave" />
|
||||
</div>
|
||||
<div class="text-caption text-grey-7 q-mt-sm">Une demande <b>approuvée</b> rend le tech indisponible pour le solveur sur ces dates.</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showTeamEditor">
|
||||
<q-card style="min-width:900px">
|
||||
<q-card-section class="row items-center q-pb-none"><div class="text-subtitle1 text-weight-bold">Équipe — cadence & coût</div><q-space /><q-btn flat round dense icon="close" v-close-popup /></q-card-section>
|
||||
<q-card-section>
|
||||
<div class="text-caption text-grey-7 q-mb-sm">Cadence : 1.00 normal · 1.10 = +10 % (plus lent) · 0.90 = −10 % (plus rapide). Coût chargé/h = salaire × (1 + charges %) + autres (véhicule, outils, frais). Le solveur préfère les techs rapides et moins coûteux.</div>
|
||||
<div style="max-height:55vh;overflow:auto">
|
||||
<table class="demand-tbl" style="width:100%">
|
||||
<thead><tr><th>Technicien</th><th>Compétences</th><th>Cadence</th><th>Salaire/h</th><th>Charges %</th><th>Autres/h</th><th>Coût chargé/h</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="t in editTechs" :key="t.id">
|
||||
<td>{{ t.name }}<span v-if="t.group" class="grp">{{ t.group }}</span></td>
|
||||
<td><q-input dense outlined v-model="t.skills" placeholder="fibre,cuivre" style="width:140px" @blur="saveSkills(t)" /></td>
|
||||
<td><q-input dense outlined type="number" step="0.05" v-model.number="t.efficiency" style="width:80px" @blur="saveEff(t)" /></td>
|
||||
<td><q-input dense outlined type="number" step="0.5" v-model.number="t.salary" style="width:80px" @blur="saveCost(t)" /></td>
|
||||
<td><q-input dense outlined type="number" step="1" v-model.number="t.charges" style="width:80px" @blur="saveCost(t)" /></td>
|
||||
<td><q-input dense outlined type="number" step="0.5" v-model.number="t.other" style="width:80px" @blur="saveCost(t)" /></td>
|
||||
<td class="text-weight-bold text-center">{{ loadedCost(t) }} $</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-menu v-model="menu.show" :target="menu.target" anchor="bottom left" self="top left">
|
||||
<q-list dense style="min-width:170px">
|
||||
<q-item-label header>{{ menu.tech && menu.tech.name }} — {{ menu.day && menu.day.dnum }}</q-item-label>
|
||||
<q-item v-for="a in menuCellShifts" :key="'c' + a.shift">
|
||||
<q-item-section avatar><span class="code-chip" :style="chip(cellColor(a))">{{ cellCode(a) }}</span></q-item-section>
|
||||
<q-item-section>{{ a.shift_name || a.shift }} <span class="text-grey-6">{{ cellHours(a) }}h</span></q-item-section>
|
||||
<q-item-section side><q-btn flat dense round size="sm" icon="close" color="grey-7" @click="removeShiftFromMenu(a)"><q-tooltip>Retirer</q-tooltip></q-btn></q-item-section>
|
||||
</q-item>
|
||||
<q-separator />
|
||||
<q-item-label header>Ajouter un shift</q-item-label>
|
||||
<q-item v-for="t in templates" :key="t.name" clickable v-close-popup @click="addFromMenu(t)"><q-item-section avatar><span class="code-chip" :style="chip(t.color)">{{ code(t) }}</span></q-item-section><q-item-section>{{ t.template_name }}</q-item-section></q-item>
|
||||
<q-separator v-if="menuCellShifts.length" />
|
||||
<q-item v-if="menuCellShifts.length" clickable v-close-popup @click="clearOne"><q-item-section class="text-grey-7">Libérer tout</q-item-section></q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { useQuasar } from 'quasar'
|
||||
import * as roster from 'src/api/roster'
|
||||
|
||||
const $q = useQuasar()
|
||||
const DIRTY_MSG = 'Vous avez des modifications non publiées. Les abandonner ?'
|
||||
|
||||
const techs = ref([])
|
||||
const templates = ref([])
|
||||
const assignments = ref([])
|
||||
const coverageData = ref([])
|
||||
const dailyStats = ref([])
|
||||
const solverStats = ref(null)
|
||||
const loading = ref(false); const generating = ref(false); const publishing = ref(false); const applying = ref(false)
|
||||
const days = ref(7)
|
||||
const start = ref(upcomingMonday())
|
||||
const lastWeek = reactive({ start: start.value, days: days.value })
|
||||
const showDemand = ref(false)
|
||||
const drag = reactive({ on: false, ti: 0, di: 0, moved: false, base: [] })
|
||||
const justDragged = ref(false)
|
||||
const selection = ref([])
|
||||
const anchor = ref(null)
|
||||
const demand = ref([]); const holidays = ref([]); const weekTemplates = ref([])
|
||||
const history = ref([]); const future = ref([])
|
||||
const search = ref(''); const groupFilter = ref(null); const maxHours = ref(40)
|
||||
const showShiftEditor = ref(false); const editTpls = ref([])
|
||||
const showTeamEditor = ref(false); const editTechs = ref([])
|
||||
const notifySms = ref(false)
|
||||
const showLeave = ref(false); const leaveRows = ref([]); const leaveFilter = ref('Demandé')
|
||||
const newLeave = reactive({ technician: '', availability_type: 'Congé', from_date: '', to_date: '', reason: '' })
|
||||
const newTpl = reactive({ template_name: '', start: '08:00', end: '16:00', color: '#1976d2' })
|
||||
|
||||
const LS_DEMAND = 'roster-demand-v1'; const LS_HOL = 'roster-holidays-v1'; const LS_TPL = 'roster-week-templates-v1'
|
||||
|
||||
function upcomingMonday () { const d = new Date(); d.setDate(d.getDate() + ((1 - d.getDay() + 7) % 7)); return d.toISOString().slice(0, 10) }
|
||||
function thisMonday () { const d = new Date(); const diff = (d.getDay() === 0 ? -6 : 1) - d.getDay(); d.setDate(d.getDate() + diff); return d.toISOString().slice(0, 10) }
|
||||
function addDaysISO (iso, n) { const [y, m, d] = iso.split('-').map(Number); const dt = new Date(Date.UTC(y, m - 1, d)); dt.setUTCDate(dt.getUTCDate() + n); return dt.toISOString().slice(0, 10) }
|
||||
const FR_DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
|
||||
const dayList = computed(() => {
|
||||
const [y, m, dd] = start.value.split('-').map(Number); const base = new Date(Date.UTC(y, m - 1, dd)); const out = []
|
||||
for (let i = 0; i < days.value; i++) { const d = new Date(base); d.setUTCDate(d.getUTCDate() + i); const iso = d.toISOString().slice(0, 10); const dow = d.getUTCDay(); out.push({ iso, dow: FR_DOW[dow], dnum: iso.slice(8) + '/' + iso.slice(5, 7), weekend: dow === 0 || dow === 6 }) }
|
||||
return out
|
||||
})
|
||||
function dowOf (iso) { const [y, m, d] = iso.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)).getUTCDay() }
|
||||
|
||||
const tplOptions = computed(() => templates.value.map(t => ({ label: t.template_name, value: t.name })))
|
||||
const techOptions = computed(() => techs.value.map(t => ({ label: t.name, value: t.id })))
|
||||
function code (t) { return (t.template_name || t.name || '?').trim()[0].toUpperCase() }
|
||||
const codeByShift = computed(() => Object.fromEntries(templates.value.map(t => [t.name, code(t)])))
|
||||
const colorByShift = computed(() => Object.fromEntries(templates.value.map(t => [t.name, t.color || '#1976d2'])))
|
||||
const tplByName = computed(() => Object.fromEntries(templates.value.map(t => [t.name, t])))
|
||||
function cellCode (a) { return codeByShift.value[a.shift] || (a.shift_name || a.shift || '?')[0].toUpperCase() }
|
||||
function cellHours (a) { return Number(a.hours) || '' }
|
||||
function cellColor (a) { return a.color || colorByShift.value[a.shift] || '#1976d2' }
|
||||
function chip (color) { return { background: color || '#1976d2', color: '#fff' } }
|
||||
|
||||
// techs visibles (recherche + groupe + tri)
|
||||
const groupOptions = computed(() => { const s = new Set(); for (const t of techs.value) if (t.group) s.add(t.group); return [...s].sort().map(g => ({ label: g, value: g })) })
|
||||
const visibleTechs = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
return techs.value.filter(t => (!groupFilter.value || t.group === groupFilter.value) && (!q || (t.name || '').toLowerCase().includes(q) || (t.group || '').toLowerCase().includes(q)))
|
||||
.slice().sort((a, b) => (a.group || '~').localeCompare(b.group || '~') || (a.name || '').localeCompare(b.name || ''))
|
||||
})
|
||||
|
||||
const cellsByTechDay = computed(() => { const m = {}; for (const a of assignments.value) { const t = (m[a.tech] || (m[a.tech] = {})); (t[a.date] || (t[a.date] = [])).push(a) } return m })
|
||||
function cellsOf (techId, iso) { return (cellsByTechDay.value[techId] && cellsByTechDay.value[techId][iso]) || [] }
|
||||
function isPaused (t) { return t.status === 'En pause' }
|
||||
function hoursOf (techId) { let h = 0; for (const a of assignments.value) if (a.tech === techId) h += Number(a.hours) || 0; return h }
|
||||
|
||||
const serverSet = ref(new Set())
|
||||
const currentSet = computed(() => new Set(assignments.value.map(a => a.tech + '|' + a.date + '|' + a.shift)))
|
||||
const diffKeys = computed(() => { const cur = currentSet.value; const srv = serverSet.value; const d = []; for (const k of cur) if (!srv.has(k)) d.push(k); for (const k of srv) if (!cur.has(k)) d.push(k); return d })
|
||||
const dirty = computed(() => diffKeys.value.length > 0)
|
||||
const dirtyCount = computed(() => diffKeys.value.length)
|
||||
const dirtyCells = computed(() => new Set(diffKeys.value.map(k => k.slice(0, k.lastIndexOf('|')))))
|
||||
function isCellDirty (techId, iso) { return dirtyCells.value.has(techId + '|' + iso) }
|
||||
|
||||
const holSet = computed(() => new Set(holidays.value))
|
||||
function isHoliday (iso) { return holSet.value.has(iso) }
|
||||
function toggleHoliday (iso) { holidays.value = isHoliday(iso) ? holidays.value.filter(x => x !== iso) : [...holidays.value, iso]; localStorage.setItem(LS_HOL, JSON.stringify(holidays.value)) }
|
||||
const selSet = computed(() => new Set(selection.value))
|
||||
function isSelected (techId, iso) { return selSet.value.has(techId + '|' + iso) }
|
||||
|
||||
const statByDate = computed(() => Object.fromEntries(dailyStats.value.map(s => [s.date, s])))
|
||||
function stat (iso) { return statByDate.value[iso] || {} }
|
||||
|
||||
// coût de main-d'œuvre (coût chargé × heures)
|
||||
const costByTech = computed(() => Object.fromEntries(techs.value.map(t => [t.id, t.cost_h || 0])))
|
||||
const costByDate = computed(() => { const m = {}; for (const a of assignments.value) m[a.date] = (m[a.date] || 0) + (Number(a.hours) || 0) * (costByTech.value[a.tech] || 0); return m })
|
||||
const weekCost = computed(() => Object.values(costByDate.value).reduce((s, v) => s + v, 0))
|
||||
function dayCost (iso) { return Math.round(costByDate.value[iso] || 0) }
|
||||
|
||||
const shiftName = (s) => { const t = tplByName.value[s]; return t ? (t.template_name || s) : s }
|
||||
const covRows = computed(() => { const seen = {}; const rows = []; for (const c of coverageData.value) { const key = c.shift + '|' + c.zone; if (!seen[key]) { seen[key] = true; rows.push({ key, label: shiftName(c.shift) + ' · ' + c.zone }) } } return rows })
|
||||
const covByKeyDay = computed(() => { const m = {}; for (const c of coverageData.value) m[c.shift + '|' + c.zone + '|' + c.date] = c; return m })
|
||||
const gapByDay = computed(() => { const m = {}; for (const c of coverageData.value) m[c.date] = (m[c.date] || 0) + (c.shortfall || 0); return m })
|
||||
function covCell (key, iso) { return covByKeyDay.value[key + '|' + iso] }
|
||||
function covText (key, iso) { const c = covCell(key, iso); return c ? (c.assigned + '/' + c.required) : '' }
|
||||
function covStyle (key, iso) { const c = covCell(key, iso); if (!c) return {}; return c.shortfall > 0 ? { background: '#ffcdd2', color: '#b71c1c', fontWeight: 700 } : { background: '#c8e6c9', color: '#1b5e20' } }
|
||||
|
||||
// undo / redo
|
||||
function snap () { return JSON.parse(JSON.stringify(assignments.value)) }
|
||||
function pushHistory () { history.value.push(snap()); if (history.value.length > 40) history.value.shift(); future.value = [] }
|
||||
function undo () { if (!history.value.length) return; future.value.push(snap()); assignments.value = history.value.pop() }
|
||||
function redo () { if (!future.value.length) return; history.value.push(snap()); assignments.value = future.value.pop() }
|
||||
|
||||
// garde anti-perte
|
||||
function guard (fn) { if (dirty.value && !window.confirm(DIRTY_MSG)) return; fn() }
|
||||
function onWeekChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { start.value = lastWeek.start; return } loadWeek() }
|
||||
function onDaysChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { days.value = lastWeek.days; return } loadWeek() }
|
||||
function navWeek (dir) { guard(() => { start.value = addDaysISO(start.value, dir * days.value); loadWeek() }) }
|
||||
function navToday () { guard(() => { start.value = thisMonday(); loadWeek() }) }
|
||||
|
||||
// chargement
|
||||
async function loadBase () { const tr = await roster.listTechnicians(); techs.value = tr.technicians || []; const tp = await roster.listTemplates(); templates.value = tp.templates || [] }
|
||||
async function refreshTemplates () { const tp = await roster.listTemplates(); templates.value = tp.templates || [] }
|
||||
|
||||
// cadence / efficacité par tech
|
||||
function effLabel (e) { const p = Math.round((e - 1) * 100); return (p < 0 ? '−' : '+') + Math.abs(p) + '%' }
|
||||
function openTeamEditor () { editTechs.value = techs.value.map(t => ({ id: t.id, name: t.name, group: t.group, skills: (t.skills || []).join(', '), efficiency: t.efficiency || 1, salary: t.cost_salary_h || 0, charges: t.cost_charges_pct || 0, other: t.cost_other_h || 0 })); showTeamEditor.value = true }
|
||||
async function saveSkills (t) { try { await roster.setTechSkills(t.id, t.skills || ''); const tt = techs.value.find(x => x.id === t.id); if (tt) tt.skills = (t.skills || '').split(',').map(s => s.trim()).filter(Boolean); $q.notify({ type: 'positive', message: t.name + ' : compétences enregistrées' }) } catch (e) { err(e) } }
|
||||
|
||||
// congés / disponibilités
|
||||
async function openLeave () { showLeave.value = true; await loadLeave() }
|
||||
async function loadLeave () { try { leaveRows.value = (await roster.listAvailability(leaveFilter.value)).availability || [] } catch (e) { err(e) } }
|
||||
async function approveLeave (l, reject) { try { await roster.approveAvailability(l.name, { reject, approver: 'ops' }); $q.notify({ type: reject ? 'info' : 'positive', message: (l.technician_name || l.technician) + (reject ? ' refusé' : ' approuvé') }); await loadLeave() } catch (e) { err(e) } }
|
||||
async function createLeave () {
|
||||
if (!newLeave.technician || !newLeave.from_date || !newLeave.to_date) { $q.notify({ type: 'warning', message: 'Technicien + dates requis' }); return }
|
||||
const t = techs.value.find(x => x.id === newLeave.technician)
|
||||
try { await roster.requestAvailability({ technician: newLeave.technician, technician_name: t ? t.name : '', availability_type: newLeave.availability_type, from_date: newLeave.from_date, to_date: newLeave.to_date, reason: newLeave.reason }); $q.notify({ type: 'positive', message: 'Demande créée' }); newLeave.from_date = ''; newLeave.to_date = ''; newLeave.reason = ''; await loadLeave() } catch (e) { err(e) }
|
||||
}
|
||||
async function saveEff (t) { const eff = Number(t.efficiency) || 1; try { await roster.setTechEfficiency(t.id, eff); const tt = techs.value.find(x => x.id === t.id); if (tt) tt.efficiency = eff; $q.notify({ type: 'positive', message: t.name + ' : cadence ' + eff }) } catch (e) { err(e) } }
|
||||
function loadedCost (t) { return Math.round(((Number(t.salary) || 0) * (1 + (Number(t.charges) || 0) / 100) + (Number(t.other) || 0)) * 100) / 100 }
|
||||
async function saveCost (t) { try { await roster.setTechCost(t.id, { salary: t.salary, charges: t.charges, other: t.other }); const tt = techs.value.find(x => x.id === t.id); if (tt) { tt.cost_salary_h = Number(t.salary) || 0; tt.cost_charges_pct = Number(t.charges) || 0; tt.cost_other_h = Number(t.other) || 0; tt.cost_h = loadedCost(t) } $q.notify({ type: 'positive', message: t.name + ' : ' + loadedCost(t) + ' $/h chargé' }) } catch (e) { err(e) } }
|
||||
|
||||
// éditeur de types de shift (intervalle d'heures)
|
||||
function calcHours (st, et) { if (!st || !et) return 0; const [h1, m1] = st.split(':').map(Number); const [h2, m2] = et.split(':').map(Number); let mins = (h2 * 60 + m2) - (h1 * 60 + m1); if (mins < 0) mins += 1440; return Math.round(mins / 60 * 100) / 100 }
|
||||
function openShiftEditor () { editTpls.value = templates.value.map(t => ({ name: t.name, template_name: t.template_name, start: (t.start_time || '08:00:00').slice(0, 5), end: (t.end_time || '16:00:00').slice(0, 5), color: t.color || '#1976d2' })); showShiftEditor.value = true }
|
||||
async function saveShiftTpl (t) { try { await roster.updateTemplate(t.name, { start_time: t.start + ':00', end_time: t.end + ':00', hours: calcHours(t.start, t.end), color: t.color }); await refreshTemplates(); $q.notify({ type: 'positive', message: t.template_name + ' enregistré (' + calcHours(t.start, t.end) + ' h)' }) } catch (e) { err(e) } }
|
||||
async function addShiftTpl () { if (!newTpl.template_name) { $q.notify({ type: 'warning', message: 'Nom requis' }); return } try { await roster.createTemplate({ template_name: newTpl.template_name, start_time: newTpl.start + ':00', end_time: newTpl.end + ':00', hours: calcHours(newTpl.start, newTpl.end), color: newTpl.color, default_required: 1 }); newTpl.template_name = ''; await refreshTemplates(); openShiftEditor(); $q.notify({ type: 'positive', message: 'Type ajouté' }) } catch (e) { err(e) } }
|
||||
async function delShiftTpl (t) { if (!window.confirm('Supprimer le type « ' + t.template_name + ' » ?')) return; try { await roster.deleteShiftTemplate(t.name); await refreshTemplates(); editTpls.value = editTpls.value.filter(x => x.name !== t.name); $q.notify({ type: 'info', message: 'Type supprimé' }) } catch (e) { err(e) } }
|
||||
function snapshotServer (list) { serverSet.value = new Set(list.map(a => a.tech + '|' + a.date + '|' + a.shift)) }
|
||||
async function loadWeek () {
|
||||
loading.value = true
|
||||
try {
|
||||
const a = await roster.listAssignments(start.value, days.value); assignments.value = a.assignments || []
|
||||
snapshotServer(assignments.value); history.value = []; future.value = []; solverStats.value = null
|
||||
lastWeek.start = start.value; lastWeek.days = days.value
|
||||
const c = await roster.getCoverage(start.value, days.value); coverageData.value = c.coverage || []
|
||||
await loadStats()
|
||||
} catch (e) { err(e) } finally { loading.value = false }
|
||||
}
|
||||
async function loadStats () { try { const s = await roster.getStats(start.value, days.value); dailyStats.value = s.stats || [] } catch (e) { /* non bloquant */ } }
|
||||
|
||||
async function doGenerate () {
|
||||
generating.value = true
|
||||
try {
|
||||
const res = await roster.generate(start.value, days.value)
|
||||
if (res.status !== 'OPTIMAL' && res.status !== 'FEASIBLE') { err(new Error(res.error || res.message || ('solveur: ' + res.status))); return }
|
||||
pushHistory(); assignments.value = res.assignments || []; coverageData.value = res.coverage_report || []
|
||||
solverStats.value = { assignments: (res.assignments || []).length, shortfall: res.total_shortfall || 0, spread: res.spread_hours || 0, ms: res.solve_ms || 0 }
|
||||
$q.notify({ type: 'positive', message: 'Horaire généré : ' + solverStats.value.assignments + ' assignations (non publié)' })
|
||||
} catch (e) { err(e) } finally { generating.value = false }
|
||||
}
|
||||
async function doPublish () {
|
||||
publishing.value = true
|
||||
try {
|
||||
// Réécriture de semaine : efface la période + recrée la grille (anti-doublons).
|
||||
const r = await roster.publishWeek(start.value, days.value, assignments.value, notifySms.value)
|
||||
$q.notify({ type: r.errors ? 'warning' : 'positive', message: `Publié : ${r.created} assignations` + (r.deleted ? ` (${r.deleted} remplacées)` : '') + (r.errors ? ` · ${r.errors} erreurs` : '') + (r.notified ? ` · ${r.notified} SMS` : '') })
|
||||
await loadWeek()
|
||||
} catch (e) { err(e) } finally { publishing.value = false }
|
||||
}
|
||||
|
||||
// demande
|
||||
function loadLS () { try { demand.value = JSON.parse(localStorage.getItem(LS_DEMAND) || '[]') } catch { demand.value = [] } try { holidays.value = JSON.parse(localStorage.getItem(LS_HOL) || '[]') } catch { holidays.value = [] } try { weekTemplates.value = JSON.parse(localStorage.getItem(LS_TPL) || '[]') } catch { weekTemplates.value = [] } }
|
||||
function saveDemand () { localStorage.setItem(LS_DEMAND, JSON.stringify(demand.value)) }
|
||||
function addDemand () { demand.value = [...demand.value, { shift: templates.value[0] && templates.value[0].name, zone: 'Montréal', skills: '', job_h: 0, weekday: 1, weekend: 0, holiday: 0 }]; saveDemand() }
|
||||
function removeDemand (i) { demand.value = demand.value.filter((_, j) => j !== i); saveDemand() }
|
||||
async function applyDemand () {
|
||||
if (!demand.value.length) { $q.notify({ type: 'warning', message: 'Aucune ligne de demande' }); return }
|
||||
applying.value = true
|
||||
try {
|
||||
await roster.clearRequirements(start.value, days.value)
|
||||
const reqs = []
|
||||
for (const d of dayList.value) {
|
||||
const slot = isHoliday(d.iso) ? 'holiday' : (d.weekend ? 'weekend' : 'weekday')
|
||||
for (const row of demand.value) {
|
||||
const n = Number(row[slot]) || 0; if (n <= 0 || !row.shift) continue
|
||||
const jobH = Number(row.job_h) || 0
|
||||
const sh = (tplByName.value[row.shift] && tplByName.value[row.shift].hours) || 8
|
||||
const count = jobH > 0 ? Math.max(1, Math.ceil(n * jobH / sh)) : n // mode jobs → effectif
|
||||
reqs.push({ requirement_date: d.iso, shift_template: row.shift, zone: row.zone || '', required_count: count, required_skills: row.skills || '' })
|
||||
}
|
||||
}
|
||||
if (reqs.length) await roster.bulkRequirements(reqs)
|
||||
await loadWeek(); $q.notify({ type: 'positive', message: 'Demande appliquée : ' + reqs.length + ' besoins' })
|
||||
} catch (e) { err(e) } finally { applying.value = false }
|
||||
}
|
||||
|
||||
// modèles de semaine
|
||||
function saveTemplate () {
|
||||
$q.dialog({ title: 'Nouveau modèle', message: "Nom du modèle d'horaire", prompt: { model: '', type: 'text' }, cancel: true }).onOk(name => {
|
||||
if (!name) return; const byDow = {}
|
||||
for (const a of assignments.value) { const dow = dowOf(a.date); (byDow[dow] || (byDow[dow] = {}))[a.tech] = a.shift }
|
||||
weekTemplates.value = [...weekTemplates.value, { name, byDow }]; localStorage.setItem(LS_TPL, JSON.stringify(weekTemplates.value))
|
||||
$q.notify({ type: 'positive', message: 'Modèle « ' + name + ' » enregistré' })
|
||||
})
|
||||
}
|
||||
function deleteTemplate (i) { weekTemplates.value = weekTemplates.value.filter((_, j) => j !== i); localStorage.setItem(LS_TPL, JSON.stringify(weekTemplates.value)) }
|
||||
function applyTemplate (tm) {
|
||||
pushHistory()
|
||||
for (const d of dayList.value) { const map = tm.byDow[dowOf(d.iso)]; if (!map) continue
|
||||
for (const techId in map) { const tpl = tplByName.value[map[techId]]; if (!tpl) continue; const t = techs.value.find(x => x.id === techId); setCellReplace(techId, t ? t.name : techId, d.iso, tpl) } }
|
||||
$q.notify({ type: 'info', message: 'Modèle « ' + tm.name + ' » appliqué — Publier pour confirmer' })
|
||||
}
|
||||
|
||||
// édition + sélection
|
||||
const menu = reactive({ show: false, target: null, tech: null, day: null })
|
||||
function rect (sti, sdi, eti, edi) {
|
||||
const t0 = Math.min(sti, eti), t1 = Math.max(sti, eti), d0 = Math.min(sdi, edi), d1 = Math.max(sdi, edi)
|
||||
const out = []
|
||||
for (let i = t0; i <= t1; i++) for (let j = d0; j <= d1; j++) out.push(visibleTechs.value[i].id + '|' + dayList.value[j].iso)
|
||||
return out
|
||||
}
|
||||
function onDown (ti, di, ev) { if (ev.button !== 0 || ev.shiftKey || ev.ctrlKey || ev.metaKey) return; drag.on = true; drag.ti = ti; drag.di = di; drag.moved = false; drag.base = [] }
|
||||
function onEnter (ti, di) { if (!drag.on) return; drag.moved = true; selection.value = [...new Set([...drag.base, ...rect(drag.ti, drag.di, ti, di)])] }
|
||||
function onUp () { if (drag.on) { drag.on = false; if (drag.moved) justDragged.value = true } }
|
||||
function addShift (techId, techName, iso, tpl) { if (cellsOf(techId, iso).some(a => a.shift === tpl.name)) return; assignments.value = [...assignments.value, { tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }] }
|
||||
function setCellReplace (techId, techName, iso, tpl) { const kept = assignments.value.filter(a => !(a.tech === techId && a.date === iso)); kept.push({ tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }); assignments.value = kept }
|
||||
function removeShift (techId, iso, shift) { assignments.value = assignments.value.filter(a => !(a.tech === techId && a.date === iso && a.shift === shift)) }
|
||||
function clearLocal (techId, iso) { assignments.value = assignments.value.filter(x => !(x.tech === techId && x.date === iso)) }
|
||||
function onCellClick (t, d, ev, ti, di) {
|
||||
if (justDragged.value) { justDragged.value = false; return }
|
||||
if (ev.shiftKey && anchor.value) { selectBlock(ti, di); return }
|
||||
if (ev.ctrlKey || ev.metaKey) { const k = t.id + '|' + d.iso; selection.value = selSet.value.has(k) ? selection.value.filter(x => x !== k) : [...selection.value, k]; anchor.value = { ti, di }; return }
|
||||
selection.value = []; anchor.value = { ti, di }; menu.tech = t; menu.day = d; menu.target = ev.currentTarget; menu.show = true
|
||||
}
|
||||
function selectBlock (ti, di) { const a = anchor.value; const t0 = Math.min(a.ti, ti); const t1 = Math.max(a.ti, ti); const d0 = Math.min(a.di, di); const d1 = Math.max(a.di, di); const add = []; for (let i = t0; i <= t1; i++) for (let j = d0; j <= d1; j++) add.push(visibleTechs.value[i].id + '|' + dayList.value[j].iso); selection.value = [...new Set([...selection.value, ...add])] }
|
||||
function maybeSelectCol (di) { const ks = visibleTechs.value.map(t => t.id + '|' + dayList.value[di].iso); const all = ks.every(k => selSet.value.has(k)); selection.value = all ? selection.value.filter(k => !ks.includes(k)) : [...new Set([...selection.value, ...ks])] }
|
||||
function maybeSelectRow (ti) { const ks = dayList.value.map(d => visibleTechs.value[ti].id + '|' + d.iso); const all = ks.every(k => selSet.value.has(k)); selection.value = all ? selection.value.filter(k => !ks.includes(k)) : [...new Set([...selection.value, ...ks])] }
|
||||
const menuCellShifts = computed(() => (menu.tech && menu.day) ? cellsOf(menu.tech.id, menu.day.iso) : [])
|
||||
function addFromMenu (tpl) { if (menu.tech && menu.day) { pushHistory(); addShift(menu.tech.id, menu.tech.name, menu.day.iso, tpl) } }
|
||||
function removeShiftFromMenu (a) { pushHistory(); removeShift(a.tech, a.date, a.shift) }
|
||||
function clearOne () { if (menu.tech && menu.day) { pushHistory(); clearLocal(menu.tech.id, menu.day.iso); menu.show = false } }
|
||||
function assignBulk (tpl) { pushHistory(); for (const k of selection.value) { const [tid, iso] = k.split('|'); const t = techs.value.find(x => x.id === tid); addShift(tid, t ? t.name : tid, iso, tpl) } selection.value = [] }
|
||||
function clearBulk () { pushHistory(); for (const k of selection.value) { const [tid, iso] = k.split('|'); clearLocal(tid, iso) } selection.value = [] }
|
||||
|
||||
async function togglePause (t) { try { const paused = !isPaused(t); await roster.pauseTechnician(t.id, paused); t.status = paused ? 'En pause' : 'Disponible'; $q.notify({ type: 'info', message: t.name + (paused ? ' en pause' : ' réactivé') }) } catch (e) { err(e) } }
|
||||
function err (e) { $q.notify({ type: 'negative', message: '' + (e.message || e) }) }
|
||||
|
||||
function onKey (e) { const z = e.key.toLowerCase() === 'z'; if ((e.ctrlKey || e.metaKey) && z) { e.preventDefault(); if (e.shiftKey) redo(); else undo() } }
|
||||
function onUnload (e) { if (dirty.value) { e.preventDefault(); e.returnValue = '' } }
|
||||
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } await loadWeek() })
|
||||
onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload) })
|
||||
onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return false })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid-wrap { overflow-x: auto; border: 1px solid #e0e0e0; border-radius: 6px; max-width: 100%; }
|
||||
.roster-grid { border-collapse: collapse; font-size: 12px; width: 100%; user-select: none; -webkit-user-select: none; }
|
||||
.roster-grid th, .roster-grid td { border: 1px solid #eee; text-align: center; padding: 2px; }
|
||||
.roster-grid thead th { position: sticky; top: 0; background: #fafafa; z-index: 1; }
|
||||
.tech-col { position: sticky; left: 0; background: #fff; text-align: left !important; white-space: nowrap; padding: 2px 8px !important; min-width: 195px; z-index: 2; }
|
||||
.roster-grid thead .tech-col { z-index: 3; }
|
||||
.roster-grid tbody tr:hover td { background: #f0f7ff; }
|
||||
.roster-grid tbody tr:hover .tech-col { background: #f0f7ff; }
|
||||
th.weekend, td.weekend { background: #f5f5f5; }
|
||||
th.holiday, td.holiday { background: #fff3e0; }
|
||||
th.clk, td.clk { cursor: pointer; }
|
||||
.dow { font-size: 10px; color: #999; text-transform: uppercase; }
|
||||
.dnum { font-size: 11px; font-weight: 600; }
|
||||
.grp { font-size: 9px; color: #999; background: #f0f0f0; border-radius: 3px; padding: 0 4px; margin-left: 2px; }
|
||||
.hol-toggle { font-size: 9px; color: #ccc; cursor: pointer; border: 1px solid #eee; border-radius: 3px; width: 14px; margin: 1px auto 0; line-height: 12px; }
|
||||
.hol-toggle.on { background: #ff9800; color: #fff; border-color: #ff9800; }
|
||||
.cell { cursor: pointer; min-height: 24px; }
|
||||
.cell:hover { outline: 2px solid #1976d2; outline-offset: -2px; }
|
||||
.cell.sel { outline: 2px solid #00897b; outline-offset: -2px; background: #e0f2f1; }
|
||||
.cell.dirty { box-shadow: inset 0 0 0 2px #ff9800; }
|
||||
.cell.cov { cursor: default; font-size: 11px; }
|
||||
.code-chip { display: inline-block; min-width: 18px; padding: 1px 5px; border-radius: 4px; font-weight: 700; font-size: 11px; line-height: 16px; margin: 1px; }
|
||||
.cell-dirty-demo { display: inline-block; min-width: 18px; padding: 0 5px; border-radius: 4px; font-weight: 700; font-size: 11px; background: #1976d2; color: #fff; box-shadow: inset 0 0 0 2px #ff9800; }
|
||||
.ch-h { opacity: .7; font-weight: 400; font-size: 9px; margin-left: 1px; }
|
||||
.free { color: #ccc; }
|
||||
tr.paused .tech-col { color: #aaa; }
|
||||
tfoot .sum td { background: #fafafa; font-size: 11px; color: #555; font-weight: 600; }
|
||||
tfoot .sum .tech-col { background: #fafafa; }
|
||||
.eff { font-size: 9px; border-radius: 3px; padding: 0 4px; margin-left: 3px; font-weight: 600; }
|
||||
.eff.fast { color: #1b5e20; background: #c8e6c9; }
|
||||
.eff.slow { color: #b71c1c; background: #ffe0b2; }
|
||||
.demand-tbl { border-collapse: collapse; }
|
||||
.demand-tbl th { font-size: 11px; color: #888; font-weight: 600; padding: 2px 6px; text-align: left; }
|
||||
.demand-tbl td { padding: 2px 4px; }
|
||||
</style>
|
||||
155
apps/ops/src/pages/RendezVousPage.vue
Normal file
155
apps/ops/src/pages/RendezVousPage.vue
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<template>
|
||||
<q-page padding>
|
||||
<div class="row items-center q-mb-md q-gutter-sm">
|
||||
<div class="text-h6 text-weight-bold">Rendez-vous clients</div>
|
||||
<q-space />
|
||||
<q-toggle v-model="onlyPending" label="À planifier seulement" dense />
|
||||
<q-btn flat dense round icon="refresh" :loading="loadingJobs" @click="loadJobs" />
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-md">
|
||||
<!-- Worklist -->
|
||||
<div class="col-12 col-md-4">
|
||||
<q-list bordered separator class="rounded-borders">
|
||||
<q-item-label header>Jobs ({{ filteredJobs.length }})</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.name }}</q-item-label>
|
||||
<q-item-label caption>{{ j.service_location || '—' }} · {{ j.duration_h || 1 }}h</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-badge :color="j.scheduled_date ? 'green' : 'orange'" :label="j.scheduled_date ? (j.scheduled_date.slice(5) + (j.start_time ? ' ' + j.start_time.slice(0,5) : '')) : 'à planifier'" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="!filteredJobs.length"><q-item-section class="text-grey-6">Aucun job.</q-item-section></q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
|
||||
<!-- Booking panel -->
|
||||
<div class="col-12 col-md-8">
|
||||
<q-banner v-if="!sel" class="bg-grey-2">Sélectionne un job à gauche pour prendre rendez-vous.</q-banner>
|
||||
<q-card v-else flat bordered>
|
||||
<q-card-section class="q-pb-none">
|
||||
<div class="text-subtitle1 text-weight-bold">{{ sel.name }}</div>
|
||||
<div class="text-caption text-grey-7">{{ sel.service_location || '—' }} · durée {{ params.duration }}h · tech actuel: {{ sel.assigned_tech || '—' }}</div>
|
||||
</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.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" />
|
||||
<q-select dense outlined v-model="params.days" :options="[7,14,21]" label="Jours" emit-value map-options style="width:90px" />
|
||||
</q-card-section>
|
||||
|
||||
<q-tabs v-model="mode" dense align="left" class="text-primary">
|
||||
<q-tab name="client" label="3 dispos du client" />
|
||||
<q-tab name="propose" label="Proposer des créneaux" />
|
||||
</q-tabs>
|
||||
<q-separator />
|
||||
|
||||
<!-- 3 dispos client -->
|
||||
<q-card-section v-if="mode === 'client'">
|
||||
<div class="text-caption text-grey-7 q-mb-sm">Saisis les 3 disponibilités du client, en ordre de préférence. On place dans le 1er tenable.</div>
|
||||
<div v-for="(p, i) in prefs" :key="i" class="row items-center q-gutter-sm q-mb-xs">
|
||||
<q-badge color="grey-7">{{ i + 1 }}</q-badge>
|
||||
<q-input dense outlined type="date" v-model="p.date" style="width:160px" />
|
||||
<q-input dense outlined type="time" v-model="p.start" style="width:120px" />
|
||||
</div>
|
||||
<q-btn unelevated color="primary" icon="search" label="Vérifier" :loading="fitting" class="q-mt-sm" @click="doFit" />
|
||||
|
||||
<div v-if="fit" class="q-mt-md">
|
||||
<q-banner v-if="fit.chosen" dense rounded class="bg-green-1 text-green-9">
|
||||
✅ Choix <b>#{{ fit.chosen.rank }}</b> retenu : {{ frDate(fit.chosen.date) }} {{ fit.chosen.start }}–{{ fit.chosen.end }} · {{ fit.chosen.tech_name }}
|
||||
<q-btn unelevated color="positive" icon="check" label="Confirmer" class="q-ml-md" :loading="confirming" @click="confirm(fit.chosen)" />
|
||||
</q-banner>
|
||||
<q-banner v-else dense rounded class="bg-orange-1 text-orange-9">
|
||||
⚠️ Aucune des 3 dispos n'est tenable. Créneaux proposés :
|
||||
<div class="q-mt-xs">
|
||||
<q-chip v-for="(s, k) in fit.proposed" :key="k" dense>{{ frDate(s.date) }} {{ s.start }} ({{ s.available }} dispo)</q-chip>
|
||||
<span v-if="!fit.proposed.length" class="text-grey-7">aucun — élargis la période ou la compétence.</span>
|
||||
</div>
|
||||
<div class="text-caption q-mt-xs">Passe à « Proposer des créneaux » pour en assigner un.</div>
|
||||
</q-banner>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<!-- Proposer -->
|
||||
<q-card-section v-else>
|
||||
<q-btn unelevated color="primary" icon="search" label="Trouver des créneaux" :loading="finding" @click="findSlots" />
|
||||
<div v-if="slots.length" class="q-mt-md">
|
||||
<div class="text-caption text-grey-7 q-mb-xs">{{ slots.length }} créneaux — clique pour sélectionner :</div>
|
||||
<div class="slot-grid">
|
||||
<div v-for="(s, k) in slots" :key="k" class="slot" :class="{ on: chosen === s }" @click="chosen = s">
|
||||
<div class="text-weight-medium">{{ frDate(s.date) }}</div>
|
||||
<div>{{ s.start }}–{{ s.end }}</div>
|
||||
<div class="text-caption text-grey-7">{{ s.tech_name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<q-btn v-if="chosen" unelevated color="positive" icon="check" label="Confirmer ce créneau" class="q-mt-md" :loading="confirming" @click="confirm(chosen)" />
|
||||
</div>
|
||||
<div v-else-if="searched" class="text-grey-6 q-mt-md">Aucun créneau — élargis la période, la zone ou la compétence (le roster doit être publié).</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import * as roster from 'src/api/roster'
|
||||
|
||||
const $q = useQuasar()
|
||||
const jobs = ref([])
|
||||
const onlyPending = ref(true)
|
||||
const loadingJobs = ref(false)
|
||||
const sel = ref(null)
|
||||
const mode = ref('client')
|
||||
const params = reactive({ skill: '', zone: '', duration: 1, start: todayISO(), days: 14 })
|
||||
const prefs = ref([{ date: '', start: '' }, { date: '', start: '' }, { date: '', start: '' }])
|
||||
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)
|
||||
|
||||
function todayISO () { return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) }
|
||||
const FR_DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
|
||||
function frDate (iso) { if (!iso) return ''; const [y, m, d] = iso.split('-').map(Number); const dt = new Date(Date.UTC(y, m - 1, d)); return FR_DOW[dt.getUTCDay()] + ' ' + iso.slice(8) + '/' + iso.slice(5, 7) }
|
||||
|
||||
const filteredJobs = computed(() => onlyPending.value ? jobs.value.filter(j => !j.scheduled_date || j.booking_status !== 'Confirmé') : jobs.value)
|
||||
|
||||
async function loadJobs () { loadingJobs.value = true; try { jobs.value = (await roster.bookJobs()).jobs || [] } catch (e) { err(e) } finally { loadingJobs.value = false } }
|
||||
function selectJob (j) { sel.value = j; params.duration = Number(j.duration_h) || 1; fit.value = null; slots.value = []; chosen.value = null; searched.value = false }
|
||||
|
||||
async function doFit () {
|
||||
const valid = prefs.value.filter(p => p.date && p.start)
|
||||
if (!valid.length) { $q.notify({ type: 'warning', message: 'Saisis au moins une dispo' }); return }
|
||||
fitting.value = true
|
||||
try { fit.value = await roster.bookFit({ skill: params.skill, zone: params.zone, duration: params.duration, prefs: valid }) } catch (e) { err(e) } finally { fitting.value = false }
|
||||
}
|
||||
async function findSlots () {
|
||||
finding.value = true; searched.value = true; chosen.value = null
|
||||
try { slots.value = (await roster.bookSlots({ skill: params.skill, zone: params.zone, duration: params.duration, start: params.start, days: params.days, limit: 40 })).slots || [] } catch (e) { err(e) } finally { finding.value = false }
|
||||
}
|
||||
async function confirm (s) {
|
||||
confirming.value = true
|
||||
try {
|
||||
const prefsUsed = prefs.value.filter(p => p.date && p.start)
|
||||
const r = await roster.bookConfirm({ job: sel.value.name, tech: s.tech, date: s.date, start: s.start, duration: params.duration, prefs: prefsUsed })
|
||||
if (r.ok === false) { err(new Error(r.error || 'échec')); return }
|
||||
$q.notify({ type: 'positive', message: `RDV confirmé : ${frDate(s.date)} ${s.start} · ${s.tech_name || s.tech}` })
|
||||
await loadJobs(); const cur = jobs.value.find(j => j.name === sel.value.name); sel.value = cur || null; fit.value = null; slots.value = []; chosen.value = null
|
||||
} catch (e) { err(e) } finally { confirming.value = false }
|
||||
}
|
||||
function err (e) { $q.notify({ type: 'negative', message: '' + (e.message || e) }) }
|
||||
|
||||
onMounted(loadJobs)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slot-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 6px; }
|
||||
.slot { border: 1px solid #e0e0e0; border-radius: 6px; padding: 6px 8px; cursor: pointer; font-size: 12px; text-align: center; }
|
||||
.slot:hover { border-color: #1976d2; }
|
||||
.slot.on { background: #c8e6c9; border-color: #2e7d32; }
|
||||
</style>
|
||||
|
|
@ -38,6 +38,8 @@ const routes = [
|
|||
{ path: 'email-queue', component: () => import('src/pages/EmailQueuePage.vue') },
|
||||
{ path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') },
|
||||
{ path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') },
|
||||
{ path: 'planification', component: () => import('src/pages/PlanificationPage.vue') },
|
||||
{ path: 'rdv', component: () => import('src/pages/RendezVousPage.vue') },
|
||||
{ path: 'agent-flows', component: () => import('src/pages/AgentFlowsPage.vue') },
|
||||
{ path: 'network', component: () => import('src/pages/NetworkPage.vue') },
|
||||
// Gift campaigns — list, new wizard (CSV upload + matching), per-campaign detail with live SSE updates
|
||||
|
|
|
|||
|
|
@ -1,10 +1,26 @@
|
|||
# Module Shifts — spécification (intégré au Dispatch, sans paie)
|
||||
|
||||
> Statut : spec v1 (2026-06-02). Décision d'architecture déjà prise
|
||||
> (cf. `memory/project_punch_shift_tool.md`) : **build-our-own dans le domaine
|
||||
> dispatch**, PAS Frappe HR. Raisons : ERPNext sur **PostgreSQL** (hrms = MariaDB-
|
||||
> first), multitenant **shared-DB par org** (hrms = site/company-per-tenant), et
|
||||
> géofence sur **adresse de job dynamique** (hrms = Shift Locations fixes).
|
||||
> **Statut : spec v2 (2026-06-03) — architecture résolue.**
|
||||
> Parcours : on a d'abord exploré **Frappe HR sur PostgreSQL** (déployé+validé à
|
||||
> hr.gigafibre.ca, cf. `reference_frappe_hr_postgres.md`) — mais (1) son UI Desk
|
||||
> n'est pas assez conviviale (l'usager la compare à Odoo Planning) et (2) elle est
|
||||
> **déconnectée des vrais techs/jobs** qui vivent dans l'ERPNext de FACTURATION
|
||||
> (`Dispatch Technician` / `Dispatch Job`). → On revient donc à la conclusion d'origine :
|
||||
> **build-our-own, intégré au domaine dispatch.**
|
||||
>
|
||||
> **Décisions arrêtées :**
|
||||
> - **Moteur (« Roster AI ») = OR-Tools CP-SAT** (Python), BÂTI + validé →
|
||||
> `services/roster-solver/`. Contraintes dures/souples « façon Timefold ».
|
||||
> - **Données du roster = dans l'ERPNext de facturation** (custom doctypes, à côté
|
||||
> de Dispatch Technician/Job) → intégration triviale des features dispatch.
|
||||
> - **UI = page « Planification » dans Ops** (Vue/Quasar, style Odoo : grille Gantt,
|
||||
> drag-drop, bouton « Générer »), à côté du Dispatch.
|
||||
> - L'instance Frappe HR (hr.gigafibre.ca) n'est PAS le foyer du roster (techs
|
||||
> ailleurs). Gardée seulement si on veut de la vraie RH (soldes de congés) plus tard.
|
||||
>
|
||||
> **Features demandées par l'usager (2026-06-03) :** modèles de shifts → **dispo vs
|
||||
> requis** par jour ; **mettre un tech en pause → ses tickets dispatch passent en
|
||||
> ROUGE** ; demande de **vacances** ; demande de **modification de shift**.
|
||||
|
||||
## 1. Périmètre
|
||||
|
||||
|
|
|
|||
3
services/roster-solver/.gitignore
vendored
Normal file
3
services/roster-solver/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
11
services/roster-solver/Dockerfile
Normal file
11
services/roster-solver/Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY solver.py app.py ./
|
||||
|
||||
EXPOSE 8090
|
||||
# 1 worker : CP-SAT est déjà multi-thread (num_search_workers=8)
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8090"]
|
||||
68
services/roster-solver/README.md
Normal file
68
services/roster-solver/README.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# Roster AI — solveur d'horaires (OR-Tools CP-SAT)
|
||||
|
||||
Microservice d'**optimisation sous contraintes** (« façon Timefold », mais Python
|
||||
sans JVM) qui génère des horaires de techniciens. Agnostique du backend : il ne
|
||||
connaît ni Frappe ni ERPNext — on lui passe des techs + dispos + besoins de
|
||||
couverture, il rend des assignations optimales.
|
||||
|
||||
## Place dans l'architecture
|
||||
```
|
||||
[Ops « Planification »] → [targo-hub lib/roster.js] → [roster-solver /solve]
|
||||
│ lit techs/jobs (Dispatch Technician/Job, ERPNext)
|
||||
└→ écrit les assignations validées
|
||||
```
|
||||
Le hub rassemble les entrées (techs, compétences, dispos, congés, besoins par
|
||||
jour/zone), appelle `/solve`, puis écrit les assignations retenues.
|
||||
|
||||
## API
|
||||
- `GET /health`
|
||||
- `POST /solve` → corps :
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"horizon": { "start": "2026-06-08", "days": 7 },
|
||||
"shift_templates": [
|
||||
{ "id": "jour", "name": "Jour 8h-16h", "hours": 8 }
|
||||
],
|
||||
"technicians": [
|
||||
{ "id": "T001", "name": "Marc", "skills": ["fibre"],
|
||||
"max_hours_week": 40, "max_days": 5, "cost_per_h": 38,
|
||||
"zone_home": "Montréal", "preferred_off": ["sam","dim"],
|
||||
"unavailable": ["2026-06-10"] } // dates de congé/pause
|
||||
],
|
||||
"coverage": [
|
||||
{ "date": "2026-06-08", "shift": "jour", "zone": "Montréal",
|
||||
"required": 2, "required_skills": ["fibre"] }
|
||||
],
|
||||
"weights": { "uncovered": 1000, "fairness": 5, "cost": 1, "preference": 8, "continuity": 4 },
|
||||
"max_seconds": 10
|
||||
}
|
||||
```
|
||||
|
||||
Réponse : `assignments[]`, `coverage_report[]` (requis vs assigné vs **shortfall**),
|
||||
`tech_hours{}`, `spread_hours`, `total_shortfall`, `explanations[]`, `status`.
|
||||
|
||||
## Modèle de contraintes
|
||||
**Dures** (faisabilité) :
|
||||
- 1 shift max par tech par jour
|
||||
- compétences : un tech ne compte pour un poste que s'il a *toutes* les compétences requises
|
||||
- disponibilité : pas d'assignation un jour de congé/pause (`unavailable`)
|
||||
- `max_hours_week`, `max_days`
|
||||
|
||||
**Souples** (objectif pondéré, minimisé) :
|
||||
- `uncovered` — postes non couverts (priorité écrasante → on voit les manques au lieu d'échouer)
|
||||
- `fairness` — écart d'heures max-min entre techs
|
||||
- `cost` — coût horaire total
|
||||
- `preference` — pénalité de travail un jour « préféré off »
|
||||
- `continuity` — *bonus* si la zone du poste = zone d'attache du tech (moins de déplacement)
|
||||
|
||||
> Couverture en **soft** = le solveur rend toujours le meilleur horaire possible
|
||||
> même en sous-effectif, et **rapporte les manques** (ta demande « dispo vs requis »).
|
||||
|
||||
## Dév
|
||||
```bash
|
||||
python -m venv .venv && . .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python test_solver.py # démo + vérifs sur sample_request.json
|
||||
uvicorn app:app --port 8090 # serveur
|
||||
```
|
||||
43
services/roster-solver/app.py
Normal file
43
services/roster-solver/app.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""
|
||||
Roster AI — API HTTP (FastAPI) autour du solveur OR-Tools CP-SAT.
|
||||
|
||||
Endpoints :
|
||||
GET /health → liveness
|
||||
POST /solve → résout un horaire (payload = voir README/sample_request.json)
|
||||
|
||||
Pensé pour tourner à côté du hub (targo-hub l'appelle), pas exposé publiquement.
|
||||
"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import Any
|
||||
import os
|
||||
|
||||
from solver import solve_roster
|
||||
|
||||
app = FastAPI(title="Roster AI Solver", version="0.1.0")
|
||||
|
||||
API_TOKEN = os.environ.get("ROSTER_SOLVER_TOKEN", "")
|
||||
|
||||
|
||||
class SolveRequest(BaseModel):
|
||||
horizon: dict
|
||||
shift_templates: list
|
||||
technicians: list
|
||||
coverage: list = []
|
||||
weights: dict | None = None
|
||||
max_seconds: float | None = None
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True, "service": "roster-solver"}
|
||||
|
||||
|
||||
@app.post("/solve")
|
||||
def solve(req: SolveRequest):
|
||||
try:
|
||||
result = solve_roster(req.model_dump())
|
||||
return result
|
||||
except Exception as e: # noqa: BLE001 — renvoyer l'erreur proprement au hub
|
||||
return JSONResponse(status_code=400, content={"status": "ERROR", "message": str(e)})
|
||||
4
services/roster-solver/requirements.txt
Normal file
4
services/roster-solver/requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
ortools>=9.10
|
||||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.29
|
||||
pydantic>=2.6
|
||||
26
services/roster-solver/sample_request.json
Normal file
26
services/roster-solver/sample_request.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"horizon": { "start": "2026-06-08", "days": 7 },
|
||||
"shift_templates": [
|
||||
{ "id": "jour", "name": "Jour 8h-16h", "start_h": 8, "end_h": 16, "hours": 8 },
|
||||
{ "id": "soir", "name": "Soir 14h-22h", "start_h": 14, "end_h": 22, "hours": 8 }
|
||||
],
|
||||
"technicians": [
|
||||
{ "id": "T001", "name": "Marc Tremblay", "skills": ["fibre", "cuivre"], "max_hours_week": 40, "max_days": 5, "cost_per_h": 38, "zone_home": "Montréal", "preferred_off": ["sam", "dim"], "unavailable": [] },
|
||||
{ "id": "T002", "name": "Sophie Gagnon", "skills": ["fibre"], "max_hours_week": 40, "max_days": 5, "cost_per_h": 35, "zone_home": "Laval", "preferred_off": ["dim"], "unavailable": ["2026-06-10", "2026-06-11"] },
|
||||
{ "id": "T003", "name": "Hugo Thibert", "skills": ["fibre", "cuivre", "aerien"], "max_hours_week": 44, "max_days": 6, "cost_per_h": 42, "zone_home": "Montréal", "preferred_off": ["dim"], "unavailable": [] },
|
||||
{ "id": "T004", "name": "Émilie Roy", "skills": ["fibre"], "max_hours_week": 32, "max_days": 4, "cost_per_h": 33, "zone_home": "Laval", "preferred_off": ["sam", "dim"], "unavailable": [] }
|
||||
],
|
||||
"coverage": [
|
||||
{ "date": "2026-06-08", "shift": "jour", "zone": "Montréal", "required": 2, "required_skills": ["fibre"] },
|
||||
{ "date": "2026-06-08", "shift": "jour", "zone": "Laval", "required": 1, "required_skills": ["fibre"] },
|
||||
{ "date": "2026-06-09", "shift": "jour", "zone": "Montréal", "required": 2, "required_skills": ["fibre"] },
|
||||
{ "date": "2026-06-09", "shift": "soir", "zone": "Montréal", "required": 1, "required_skills": ["aerien"] },
|
||||
{ "date": "2026-06-10", "shift": "jour", "zone": "Montréal", "required": 2, "required_skills": ["fibre"] },
|
||||
{ "date": "2026-06-10", "shift": "jour", "zone": "Laval", "required": 1, "required_skills": ["fibre"] },
|
||||
{ "date": "2026-06-11", "shift": "jour", "zone": "Montréal", "required": 2, "required_skills": ["cuivre"] },
|
||||
{ "date": "2026-06-12", "shift": "jour", "zone": "Montréal", "required": 3, "required_skills": ["fibre"] },
|
||||
{ "date": "2026-06-13", "shift": "jour", "zone": "Montréal", "required": 1, "required_skills": ["fibre"] }
|
||||
],
|
||||
"weights": { "uncovered": 1000, "fairness": 5, "cost": 1, "preference": 8, "continuity": 4 },
|
||||
"max_seconds": 10
|
||||
}
|
||||
224
services/roster-solver/solver.py
Normal file
224
services/roster-solver/solver.py
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
"""
|
||||
Roster AI — solveur d'optimisation d'horaires (OR-Tools CP-SAT).
|
||||
|
||||
Modèle de contraintes "façon Timefold" : contraintes DURES (faisabilité) +
|
||||
contraintes SOUPLES (objectif pondéré). Agnostique du backend : on lui passe
|
||||
des techniciens + disponibilités + besoins de couverture, il rend des
|
||||
assignations optimales (+ un rapport de couverture dispo-vs-requis).
|
||||
|
||||
Entrée / sortie : voir README.md et sample_request.json.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import date, timedelta
|
||||
from ortools.sat.python import cp_model
|
||||
|
||||
FR_ABBR = ["lun", "mar", "mer", "jeu", "ven", "sam", "dim"] # date.weekday(): lun=0..dim=6
|
||||
|
||||
|
||||
def _parse_date(s: str) -> date:
|
||||
y, m, d = (int(x) for x in s.split("-"))
|
||||
return date(y, m, d)
|
||||
|
||||
|
||||
def fr_day(d: date) -> str:
|
||||
return FR_ABBR[d.weekday()]
|
||||
|
||||
|
||||
def solve_roster(req: dict) -> dict:
|
||||
"""Résout un horaire. `req` = payload décrit dans le README. Retourne un dict."""
|
||||
# ── Entrées ──────────────────────────────────────────────────────────────
|
||||
horizon = req["horizon"]
|
||||
start = _parse_date(horizon["start"])
|
||||
n_days = int(horizon["days"])
|
||||
days = [start + timedelta(days=i) for i in range(n_days)]
|
||||
day_set = {d.isoformat() for d in days}
|
||||
|
||||
shifts = {s["id"]: s for s in req["shift_templates"]}
|
||||
techs = req["technicians"]
|
||||
tech_by_id = {t["id"]: t for t in techs}
|
||||
|
||||
W = {
|
||||
"uncovered": 1000, # pénalité par poste non couvert (priorité #1)
|
||||
"assignment": 8, # pénalité par assignation → couvrir le requis SANS sur-staffer
|
||||
"efficiency": 3, # préférer les techs plus rapides (facteur temps bas)
|
||||
"fairness": 5, # écart d'heures max-min
|
||||
"cost": 1, # coût horaire
|
||||
"preference": 8, # travailler un jour "préféré off"
|
||||
"continuity": 4, # bonus si la zone == zone d'attache du tech
|
||||
**(req.get("weights") or {}),
|
||||
}
|
||||
|
||||
# ── Slots de couverture (date, shift, zone, requis, compétences) ──────────
|
||||
# On ne garde que les slots dont la date est dans l'horizon et le shift connu.
|
||||
slots = []
|
||||
for c in req.get("coverage", []):
|
||||
if c["date"] not in day_set or c["shift"] not in shifts:
|
||||
continue
|
||||
slots.append({
|
||||
"date": c["date"],
|
||||
"shift": c["shift"],
|
||||
"zone": c.get("zone", "—"),
|
||||
"required": int(c.get("required") or 1),
|
||||
"skills": set(c.get("required_skills") or []),
|
||||
"hours": int(shifts[c["shift"]].get("hours") or 8),
|
||||
})
|
||||
|
||||
model = cp_model.CpModel()
|
||||
|
||||
# ── Variables de décision : y[(tech_id, slot_idx)] ∈ {0,1} ────────────────
|
||||
# On ne crée la variable QUE si le tech est qualifié (compétences) ET
|
||||
# disponible (date pas dans unavailable). Élaguer réduit la taille du modèle.
|
||||
y = {}
|
||||
for ti, t in enumerate(techs):
|
||||
tskills = set(t.get("skills", []))
|
||||
unavailable = set(t.get("unavailable", []))
|
||||
for si, slot in enumerate(slots):
|
||||
if slot["date"] in unavailable:
|
||||
continue
|
||||
if not slot["skills"].issubset(tskills):
|
||||
continue
|
||||
y[(t["id"], si)] = model.NewBoolVar(f"y_{t['id']}_{si}")
|
||||
|
||||
# ── Contrainte DURE : ≤ 1 shift par tech par jour ─────────────────────────
|
||||
for t in techs:
|
||||
by_date = {}
|
||||
for si, slot in enumerate(slots):
|
||||
if (t["id"], si) in y:
|
||||
by_date.setdefault(slot["date"], []).append(y[(t["id"], si)])
|
||||
for _d, vars_ in by_date.items():
|
||||
if len(vars_) > 1:
|
||||
model.AddAtMostOne(vars_)
|
||||
|
||||
# ── Heures + jours travaillés par tech (pour DURES max + SOUPLE équité) ───
|
||||
tech_hours = {}
|
||||
for t in techs:
|
||||
terms = []
|
||||
days_vars = []
|
||||
for si, slot in enumerate(slots):
|
||||
if (t["id"], si) in y:
|
||||
terms.append(slot["hours"] * y[(t["id"], si)])
|
||||
days_vars.append(y[(t["id"], si)])
|
||||
h = model.NewIntVar(0, 24 * n_days, f"hours_{t['id']}")
|
||||
model.Add(h == (sum(terms) if terms else 0)) # parenthèses: sinon model.Add(0) quand terms vide
|
||||
tech_hours[t["id"]] = h
|
||||
# DURE : max heures / semaine
|
||||
if t.get("max_hours_week") is not None:
|
||||
model.Add(h <= int(t["max_hours_week"]))
|
||||
# DURE : max jours (≤1/jour, donc somme des y = nb de jours)
|
||||
if t.get("max_days") is not None and days_vars:
|
||||
model.Add(sum(days_vars) <= int(t["max_days"]))
|
||||
|
||||
# ── Couverture SOUPLE : shortfall[slot] = postes manquants (≥0) ───────────
|
||||
shortfalls = []
|
||||
coverage_report = []
|
||||
for si, slot in enumerate(slots):
|
||||
assigned_vars = [y[(t["id"], si)] for t in techs if (t["id"], si) in y]
|
||||
short = model.NewIntVar(0, slot["required"], f"short_{si}")
|
||||
# sum(assigned) + short >= required → short absorbe le manque
|
||||
model.Add(sum(assigned_vars) + short >= slot["required"])
|
||||
shortfalls.append(short)
|
||||
coverage_report.append((si, slot, assigned_vars, short))
|
||||
|
||||
# ── Équité SOUPLE : minimiser (max_h - min_h) parmi les techs actifs ──────
|
||||
# On borne max_h et min_h sur tous les techs qui ont au moins une variable.
|
||||
active = [t["id"] for t in techs if any((t["id"], si) in y for si in range(len(slots)))]
|
||||
spread = model.NewIntVar(0, 24 * n_days, "spread")
|
||||
if active:
|
||||
max_h = model.NewIntVar(0, 24 * n_days, "max_h")
|
||||
min_h = model.NewIntVar(0, 24 * n_days, "min_h")
|
||||
for tid in active:
|
||||
model.Add(max_h >= tech_hours[tid])
|
||||
model.Add(min_h <= tech_hours[tid])
|
||||
model.Add(spread == max_h - min_h)
|
||||
else:
|
||||
model.Add(spread == 0)
|
||||
|
||||
# ── Objectif pondéré ──────────────────────────────────────────────────────
|
||||
obj = []
|
||||
# 1) couverture (priorité écrasante)
|
||||
for short in shortfalls:
|
||||
obj.append(W["uncovered"] * short)
|
||||
# 2) équité
|
||||
obj.append(W["fairness"] * spread)
|
||||
# 3) coût + 4) préférences + 5) continuité de zone
|
||||
for ti, t in enumerate(techs):
|
||||
cost_h = int(t.get("cost_per_h") or 0)
|
||||
pref_off = set(t.get("preferred_off", []))
|
||||
zone_home = t.get("zone_home")
|
||||
tf = int(round((float(t.get("time_factor") or 1.0)) * 10)) # 10=normal, 11=+10% lent, 9=-10% rapide
|
||||
for si, slot in enumerate(slots):
|
||||
v = y.get((t["id"], si))
|
||||
if v is None:
|
||||
continue
|
||||
# pénalité par assignation : couvrir le requis sans sur-staffer
|
||||
obj.append(W["assignment"] * v)
|
||||
# préférer les techs plus rapides (facteur temps bas)
|
||||
obj.append(W["efficiency"] * tf * v)
|
||||
# coût
|
||||
if cost_h:
|
||||
obj.append(W["cost"] * cost_h * slot["hours"] * v)
|
||||
# préférence : pénalité si le jour est "préféré off"
|
||||
if fr_day(_parse_date(slot["date"])) in pref_off:
|
||||
obj.append(W["preference"] * v)
|
||||
# continuité de zone : bonus (= pénalité négative) si même zone d'attache
|
||||
if zone_home and slot["zone"] == zone_home:
|
||||
obj.append(-W["continuity"] * v)
|
||||
model.Minimize(sum(obj))
|
||||
|
||||
# ── Résolution ────────────────────────────────────────────────────────────
|
||||
solver = cp_model.CpSolver()
|
||||
solver.parameters.max_time_in_seconds = float(req.get("max_seconds") or 10)
|
||||
solver.parameters.num_search_workers = 8
|
||||
status = solver.Solve(model)
|
||||
status_name = solver.StatusName(status)
|
||||
|
||||
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
|
||||
return {"status": status_name, "assignments": [], "coverage_report": [],
|
||||
"tech_hours": {}, "objective": None,
|
||||
"message": "Aucune solution (contraintes dures incompatibles)."}
|
||||
|
||||
# ── Construire le résultat ────────────────────────────────────────────────
|
||||
assignments = []
|
||||
for ti, t in enumerate(techs):
|
||||
for si, slot in enumerate(slots):
|
||||
v = y.get((t["id"], si))
|
||||
if v is not None and solver.Value(v) == 1:
|
||||
assignments.append({
|
||||
"tech": t["id"], "tech_name": t.get("name", t["id"]),
|
||||
"date": slot["date"], "shift": slot["shift"],
|
||||
"shift_name": shifts[slot["shift"]].get("name", slot["shift"]),
|
||||
"zone": slot["zone"], "hours": slot["hours"],
|
||||
})
|
||||
|
||||
cov = []
|
||||
for si, slot, assigned_vars, short in coverage_report:
|
||||
n_assigned = sum(solver.Value(v) for v in assigned_vars)
|
||||
cov.append({
|
||||
"date": slot["date"], "shift": slot["shift"], "zone": slot["zone"],
|
||||
"required": slot["required"], "assigned": int(n_assigned),
|
||||
"shortfall": int(solver.Value(short)),
|
||||
})
|
||||
|
||||
hours_out = {tid: int(solver.Value(h)) for tid, h in tech_hours.items()}
|
||||
|
||||
# Explications lisibles (pour l'UI)
|
||||
explanations = []
|
||||
for tid in sorted(hours_out, key=lambda k: -hours_out[k]):
|
||||
n_shifts = sum(1 for a in assignments if a["tech"] == tid)
|
||||
if n_shifts:
|
||||
explanations.append(f"{tech_by_id[tid].get('name', tid)} : {n_shifts} shift(s), {hours_out[tid]} h")
|
||||
total_short = sum(c["shortfall"] for c in cov)
|
||||
if total_short:
|
||||
explanations.insert(0, f"⚠️ {total_short} poste(s) non couvert(s) — effectif insuffisant ce(s) jour(s).")
|
||||
|
||||
return {
|
||||
"status": status_name,
|
||||
"assignments": assignments,
|
||||
"coverage_report": cov,
|
||||
"tech_hours": hours_out,
|
||||
"objective": solver.ObjectiveValue(),
|
||||
"spread_hours": int(solver.Value(spread)),
|
||||
"total_shortfall": total_short,
|
||||
"explanations": explanations,
|
||||
"solve_ms": int(solver.WallTime() * 1000),
|
||||
}
|
||||
84
services/roster-solver/test_solver.py
Normal file
84
services/roster-solver/test_solver.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""
|
||||
Test/démo du solveur : charge sample_request.json, résout, et imprime un
|
||||
horaire lisible + le rapport de couverture (dispo vs requis). Sert aussi de
|
||||
vérification (assert : statut faisable + chaque tech respecte ses contraintes).
|
||||
|
||||
python test_solver.py
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from solver import solve_roster
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def main():
|
||||
with open(os.path.join(HERE, "sample_request.json")) as f:
|
||||
req = json.load(f)
|
||||
|
||||
res = solve_roster(req)
|
||||
|
||||
print(f"\n=== STATUT : {res['status']} ({res.get('solve_ms')} ms) ===")
|
||||
assert res["status"] in ("OPTIMAL", "FEASIBLE"), "solveur infaisable"
|
||||
|
||||
# Grille employé × jour
|
||||
days = [req["horizon"]["start"]]
|
||||
from datetime import date, timedelta
|
||||
y, m, d = (int(x) for x in req["horizon"]["start"].split("-"))
|
||||
start = date(y, m, d)
|
||||
days = [(start + timedelta(days=i)).isoformat() for i in range(req["horizon"]["days"])]
|
||||
|
||||
by_tech = defaultdict(dict)
|
||||
for a in res["assignments"]:
|
||||
by_tech[a["tech_name"]][a["date"]] = f"{a['shift']}@{a['zone']}"
|
||||
|
||||
print("\n=== HORAIRE (Roster AI) ===")
|
||||
hdr = "Technicien".ljust(16) + "".join(dd[5:].ljust(12) for dd in days)
|
||||
print(hdr)
|
||||
for t in req["technicians"]:
|
||||
row = t["name"].ljust(16)
|
||||
for dd in days:
|
||||
row += (by_tech.get(t["name"], {}).get(dd, "·")).ljust(12)
|
||||
print(row)
|
||||
|
||||
print("\n=== HEURES / TECH (équité, écart =", res.get("spread_hours"), "h) ===")
|
||||
for tid, h in sorted(res["tech_hours"].items(), key=lambda kv: -kv[1]):
|
||||
name = next((t["name"] for t in req["technicians"] if t["id"] == tid), tid)
|
||||
print(f" {name.ljust(16)} {h} h")
|
||||
|
||||
print("\n=== COUVERTURE (dispo vs requis) ===")
|
||||
for c in res["coverage_report"]:
|
||||
flag = " ✅" if c["shortfall"] == 0 else f" ❌ MANQUE {c['shortfall']}"
|
||||
print(f" {c['date']} {c['shift']:5} {c['zone']:10} requis={c['required']} assigné={c['assigned']}{flag}")
|
||||
|
||||
print("\n=== EXPLICATIONS ===")
|
||||
for e in res["explanations"]:
|
||||
print(" " + e)
|
||||
|
||||
# Vérifs dures
|
||||
# 1) ≤ 1 shift/jour/tech
|
||||
seen = set()
|
||||
for a in res["assignments"]:
|
||||
key = (a["tech"], a["date"])
|
||||
assert key not in seen, f"double-booking {key}"
|
||||
seen.add(key)
|
||||
# 2) compétences respectées
|
||||
skill_of = {t["id"]: set(t["skills"]) for t in req["technicians"]}
|
||||
cov_skills = {(c["date"], c["shift"], c.get("zone", "—")): set(c.get("required_skills", []))
|
||||
for c in req["coverage"]}
|
||||
for a in res["assignments"]:
|
||||
req_sk = cov_skills.get((a["date"], a["shift"], a["zone"]), set())
|
||||
assert req_sk.issubset(skill_of[a["tech"]]), f"compétence manquante: {a}"
|
||||
# 3) indispo respectée
|
||||
unavail = {t["id"]: set(t.get("unavailable", [])) for t in req["technicians"]}
|
||||
for a in res["assignments"]:
|
||||
assert a["date"] not in unavail[a["tech"]], f"assigné un jour indispo: {a}"
|
||||
|
||||
print("\n✅ Toutes les contraintes dures respectées (1 shift/jour, compétences, indispos).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -52,6 +52,7 @@ module.exports = {
|
|||
LEGACY_DB_NAME: env('LEGACY_DB_NAME', 'gestionclient'),
|
||||
OMLX_URL: env('OMLX_URL', 'http://127.0.0.1:8000'),
|
||||
TRACCAR_URL: env('TRACCAR_URL', 'http://tracker.targointernet.com:8082'),
|
||||
ROSTER_SOLVER_URL: env('ROSTER_SOLVER_URL', 'http://roster-solver:8090'),
|
||||
JWT_SECRET: env('JWT_SECRET'),
|
||||
FIELD_APP_URL: env('FIELD_APP_URL', 'https://msg.gigafibre.ca'),
|
||||
EXTERNAL_URL: env('EXTERNAL_URL', 'https://msg.gigafibre.ca'),
|
||||
|
|
|
|||
653
services/targo-hub/lib/roster.js
Normal file
653
services/targo-hub/lib/roster.js
Normal file
|
|
@ -0,0 +1,653 @@
|
|||
'use strict'
|
||||
/**
|
||||
* roster.js — Planification (« Roster AI »).
|
||||
*
|
||||
* Orchestre le solveur d'horaires OR-Tools (service roster-solver) avec les
|
||||
* données réelles de l'ERPNext de facturation :
|
||||
* - techniciens HUMAINS : Dispatch Technician (resource_type='human')
|
||||
* - compétences : tags (_user_tags) du tech
|
||||
* - disponibilité : status ('En pause'), absence_from/until, Tech Availability (Approuvé)
|
||||
* - modèles de shifts : Shift Template
|
||||
* - besoins de couverture : Shift Requirement (→ « dispo vs requis »)
|
||||
* - assignations : Shift Assignment (statut Proposé/Publié)
|
||||
*
|
||||
* Le solveur ne fait QUE proposer ; /publish écrit les Shift Assignment.
|
||||
* Aucune paie : on planifie + approuve, c'est tout.
|
||||
*
|
||||
* Routes (préfixe /roster) :
|
||||
* GET /roster/technicians → techs humains + skills + indispos
|
||||
* GET /roster/templates → modèles de shifts
|
||||
* POST /roster/templates → créer un modèle
|
||||
* GET /roster/requirements?start=&days= → besoins de couverture
|
||||
* POST /roster/requirements → créer un besoin
|
||||
* GET /roster/assignments?start=&days= → assignations existantes
|
||||
* GET /roster/coverage?start=&days= → dispo vs requis (par besoin)
|
||||
* POST /roster/generate {start,days,weights} → propose un horaire (n'écrit rien)
|
||||
* POST /roster/publish {assignments} → écrit les Shift Assignment (Publié)
|
||||
* POST /roster/availability {…} → demande congé/pause (Tech Availability)
|
||||
* POST /roster/availability/:name/approve → approuve une demande
|
||||
* POST /roster/technician/:id/pause {paused,reason} → met/retire un tech en pause
|
||||
*/
|
||||
const http = require('http')
|
||||
const crypto = require('crypto')
|
||||
const { json, parseBody } = require('./helpers')
|
||||
const erp = require('./erp')
|
||||
const cfg = require('./config')
|
||||
|
||||
const SOLVER_URL = cfg.ROSTER_SOLVER_URL || 'http://roster-solver:8090'
|
||||
const PAUSE_STATUS = 'En pause'
|
||||
const AVAIL_STATUS = 'Disponible'
|
||||
|
||||
// ── Date helpers (local, sans dépendance) ──────────────────────────────────
|
||||
function iso (d) { return d.toISOString().slice(0, 10) }
|
||||
function parseISO (s) { const [y, m, dd] = s.split('-').map(Number); return new Date(Date.UTC(y, m - 1, dd)) }
|
||||
function addDays (d, n) { const r = new Date(d); r.setUTCDate(r.getUTCDate() + n); return r }
|
||||
function rangeDates (start, days) {
|
||||
const s = parseISO(start); const out = []
|
||||
for (let i = 0; i < days; i++) out.push(iso(addDays(s, i)))
|
||||
return out
|
||||
}
|
||||
function splitCsv (s) {
|
||||
return String(s || '').split(',').map(x => x.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
// POST au solveur via le module http natif (comme erpFetch — fiable dans le
|
||||
// process long du hub, contrairement au fetch global undici).
|
||||
function postSolver (path, body) {
|
||||
const data = JSON.stringify(body)
|
||||
const u = new URL(SOLVER_URL + path)
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({
|
||||
hostname: u.hostname, port: u.port || 80, path: u.pathname + u.search, method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
|
||||
timeout: 30000,
|
||||
}, (res) => {
|
||||
let d = ''
|
||||
res.on('data', c => { d += c })
|
||||
res.on('end', () => { try { resolve(JSON.parse(d)) } catch { resolve({ status: 'ERROR', message: 'réponse solveur invalide' }) } })
|
||||
})
|
||||
req.on('error', reject)
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('solveur: timeout')) })
|
||||
req.write(data); req.end()
|
||||
})
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
||||
// Réessai des écritures ERPNext. Le shim frappe_pg tourne en SERIALIZABLE → sur
|
||||
// les lignes chaudes (Dispatch Technician maj en continu par le GPS/dispatch) un
|
||||
// SELECT…FOR UPDATE peut lever "could not serialize access due to concurrent
|
||||
// update" (HTTP 500). La requête a été rollback → réessayer est sûr (idempotent).
|
||||
async function retryWrite (fn, tries = 5) {
|
||||
let r
|
||||
for (let i = 0; i < tries; i++) {
|
||||
r = await fn()
|
||||
if (r.ok || (r.status && r.status < 500)) return r
|
||||
await sleep(120 * (i + 1))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Lecture des techniciens humains + compétences + indisponibilités ────────
|
||||
async function fetchTechnicians () {
|
||||
const rows = await erp.list('Dispatch Technician', {
|
||||
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'],
|
||||
limit: 500,
|
||||
})
|
||||
return rows.map(t => ({
|
||||
id: t.technician_id || t.name,
|
||||
name: t.full_name || t.technician_id,
|
||||
status: t.status,
|
||||
group: t.tech_group || '',
|
||||
efficiency: Number(t.efficiency) || 1,
|
||||
cost_salary_h: Number(t.cost_salary_h) || 0,
|
||||
cost_charges_pct: Number(t.cost_charges_pct) || 0,
|
||||
cost_other_h: Number(t.cost_other_h) || 0,
|
||||
cost_h: Math.round(((Number(t.cost_salary_h) || 0) * (1 + (Number(t.cost_charges_pct) || 0) / 100) + (Number(t.cost_other_h) || 0)) * 100) / 100,
|
||||
color: t.color_hex || '#1976d2',
|
||||
phone: t.phone,
|
||||
employee: t.employee,
|
||||
skills: splitCsv(t.skills || t._user_tags), // champ skills (ou tags Frappe)
|
||||
absence_from: t.absence_from,
|
||||
absence_until: t.absence_until,
|
||||
}))
|
||||
}
|
||||
|
||||
// Construit, pour chaque tech, la liste des dates indisponibles dans l'horizon.
|
||||
async function buildUnavailability (techs, dateList) {
|
||||
const start = dateList[0]
|
||||
const end = dateList[dateList.length - 1]
|
||||
const byTech = {}
|
||||
for (const t of techs) byTech[t.id] = new Set()
|
||||
|
||||
// 1) status « En pause » → indispo sur tout l'horizon (pause active)
|
||||
for (const t of techs) {
|
||||
if (t.status === PAUSE_STATUS) dateList.forEach(d => byTech[t.id].add(d))
|
||||
// 2) fenêtre d'absence du Dispatch Technician
|
||||
if (t.absence_from && t.absence_until) {
|
||||
for (const d of dateList) if (d >= t.absence_from && d <= t.absence_until) byTech[t.id].add(d)
|
||||
}
|
||||
}
|
||||
// 3) Tech Availability approuvées qui chevauchent l'horizon
|
||||
const avs = await erp.list('Tech Availability', {
|
||||
filters: [['status', '=', 'Approuvé'], ['from_date', '<=', end], ['to_date', '>=', start]],
|
||||
fields: ['technician', 'from_date', 'to_date', 'availability_type'],
|
||||
limit: 500,
|
||||
})
|
||||
for (const a of avs) {
|
||||
if (!byTech[a.technician]) continue
|
||||
for (const d of dateList) if (d >= a.from_date && d <= a.to_date) byTech[a.technician].add(d)
|
||||
}
|
||||
return byTech
|
||||
}
|
||||
|
||||
// ── Modèles + besoins ───────────────────────────────────────────────────────
|
||||
async function fetchTemplates () {
|
||||
const rows = await erp.list('Shift Template', {
|
||||
filters: [['active', '=', 1]],
|
||||
fields: ['name', 'template_name', 'start_time', 'end_time', 'hours', 'color', 'zone', 'default_required', 'required_skills'],
|
||||
limit: 100,
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
async function fetchRequirements (start, days) {
|
||||
const dates = rangeDates(start, days)
|
||||
return erp.list('Shift Requirement', {
|
||||
filters: [['requirement_date', 'in', dates]],
|
||||
fields: ['name', 'requirement_date', 'shift_template', 'zone', 'required_count', 'required_skills'],
|
||||
limit: 1000,
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchAssignments (start, days) {
|
||||
const dates = rangeDates(start, days)
|
||||
const rows = await erp.list('Shift Assignment', {
|
||||
filters: [['assignment_date', 'in', dates]],
|
||||
fields: ['name', 'technician', 'technician_name', 'assignment_date', 'shift_template', 'zone', 'hours', 'status', 'source'],
|
||||
limit: 2000,
|
||||
})
|
||||
// Normaliser vers la forme canonique {tech, date, shift} (= sortie du solveur + UI)
|
||||
return rows.map(r => ({
|
||||
name: r.name, tech: r.technician, tech_name: r.technician_name, date: r.assignment_date,
|
||||
shift: r.shift_template, zone: r.zone, hours: r.hours, status: r.status, source: r.source,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Construit le payload du solveur + l'appelle ─────────────────────────────
|
||||
async function generate (start, days, weights) {
|
||||
const dateList = rangeDates(start, days)
|
||||
// Séquentiel volontaire : le backend frappe (peu de workers) reset des
|
||||
// connexions sous rafale concurrente → erp.list renvoie [] par intermittence.
|
||||
const techs = await fetchTechnicians()
|
||||
const templates = await fetchTemplates()
|
||||
const requirements = await fetchRequirements(start, days)
|
||||
const unavail = await buildUnavailability(techs, dateList)
|
||||
|
||||
const shift_templates = templates.map(t => ({
|
||||
id: t.name, name: t.template_name || t.name,
|
||||
hours: Number(t.hours) || 8,
|
||||
}))
|
||||
|
||||
const technicians = techs.map(t => ({
|
||||
id: t.id, name: t.name, skills: t.skills,
|
||||
max_hours_week: 40, max_days: 5, cost_per_h: t.cost_h || 0,
|
||||
zone_home: null, preferred_off: [], time_factor: t.efficiency || 1,
|
||||
unavailable: [...unavail[t.id]],
|
||||
}))
|
||||
|
||||
const coverage = requirements.map(r => ({
|
||||
date: r.requirement_date, shift: r.shift_template, zone: r.zone || '—',
|
||||
required: Number(r.required_count) || 1,
|
||||
required_skills: splitCsv(r.required_skills),
|
||||
}))
|
||||
|
||||
const payload = { horizon: { start, days }, shift_templates, technicians, coverage, weights: weights || undefined, max_seconds: 12 }
|
||||
|
||||
const result = await postSolver('/solve', payload)
|
||||
// enrichir avec le nom + couleur pour l'UI
|
||||
const nameById = Object.fromEntries(techs.map(t => [t.id, t.name]))
|
||||
const colorByTpl = Object.fromEntries(templates.map(t => [t.name, t.color || '#1976d2']))
|
||||
for (const a of (result.assignments || [])) {
|
||||
a.tech_name = nameById[a.tech] || a.tech
|
||||
a.color = colorByTpl[a.shift] || '#1976d2'
|
||||
}
|
||||
return { ...result, counts: { technicians: technicians.length, templates: shift_templates.length, requirements: coverage.length } }
|
||||
}
|
||||
|
||||
// Écrit les assignations retenues comme Shift Assignment (Publié).
|
||||
async function publish (assignments) {
|
||||
const created = []; const errors = []
|
||||
for (const a of assignments || []) {
|
||||
const r = await retryWrite(() => erp.create('Shift Assignment', {
|
||||
technician: a.tech, technician_name: a.tech_name || '',
|
||||
assignment_date: a.date, shift_template: a.shift, zone: a.zone || '',
|
||||
hours: Number(a.hours) || 0, status: 'Publié', source: a.source || 'solveur',
|
||||
}))
|
||||
if (r.ok) created.push(r.name); else errors.push({ a, error: r.error })
|
||||
}
|
||||
return { ok: errors.length === 0, created: created.length, errors }
|
||||
}
|
||||
|
||||
// dispo vs requis : pour chaque besoin, compte les assignations publiées correspondantes
|
||||
async function coverage (start, days) {
|
||||
const reqs = await fetchRequirements(start, days)
|
||||
const asgs = await fetchAssignments(start, days)
|
||||
const key = (d, s, z) => `${d}|${s}|${z || '—'}`
|
||||
const counts = {}
|
||||
for (const a of asgs) {
|
||||
if (a.status === 'Annulé') continue
|
||||
counts[key(a.date, a.shift, a.zone)] = (counts[key(a.date, a.shift, a.zone)] || 0) + 1
|
||||
}
|
||||
return reqs.map(r => {
|
||||
const assigned = counts[key(r.requirement_date, r.shift_template, r.zone)] || 0
|
||||
const required = Number(r.required_count) || 0
|
||||
return { date: r.requirement_date, shift: r.shift_template, zone: r.zone || '—', required, assigned, shortfall: Math.max(0, required - assigned) }
|
||||
})
|
||||
}
|
||||
|
||||
// ── Prise de RDV : disponibilité consciente du roster ──────────────────────
|
||||
// Renvoie les fenêtres libres où un tech EN SHIFT publié ce jour-là, avec la
|
||||
// compétence requise, est disponible (trous dans son shift moins les jobs déjà
|
||||
// pointés). Sert aux 2 canaux : on propose au client, ou on valide son choix.
|
||||
function timeToH (t) { if (!t) return 0; const [h, m] = String(t).split(':').map(Number); return (h || 0) + (m || 0) / 60 }
|
||||
function hToTime (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') }
|
||||
|
||||
async function loadBookingData (start, days) {
|
||||
const dates = rangeDates(start, days)
|
||||
const asgs = await fetchAssignments(start, days)
|
||||
const techs = await fetchTechnicians()
|
||||
const templates = await fetchTemplates()
|
||||
const techById = Object.fromEntries(techs.map(t => [t.id, t]))
|
||||
const tplByName = Object.fromEntries(templates.map(t => [t.name, t]))
|
||||
const jobs = await erp.list('Dispatch Job', {
|
||||
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned']]],
|
||||
fields: ['assigned_tech', 'scheduled_date', 'start_time', 'duration_h'], limit: 2000,
|
||||
})
|
||||
const booked = {}
|
||||
for (const j of jobs) {
|
||||
if (!j.start_time) continue
|
||||
const k = j.assigned_tech + '|' + j.scheduled_date
|
||||
;(booked[k] || (booked[k] = [])).push({ s: timeToH(j.start_time), e: timeToH(j.start_time) + (Number(j.duration_h) || 1) })
|
||||
}
|
||||
return { asgs, techById, tplByName, booked }
|
||||
}
|
||||
|
||||
// Trous libres d'un tech (dans son shift, moins jobs pointés), filtrés compétence/zone.
|
||||
function techGaps (a, d, skill, zone) {
|
||||
const t = d.techById[a.tech]; if (!t || t.status === PAUSE_STATUS) return null
|
||||
if (skill && !(t.skills || []).includes(skill)) return null
|
||||
if (zone && a.zone && a.zone !== zone) return null
|
||||
const tpl = d.tplByName[a.shift]; if (!tpl) return null
|
||||
const sh = timeToH(tpl.start_time) || 8; const eh = timeToH(tpl.end_time) || (sh + (Number(tpl.hours) || 8))
|
||||
const day = (d.booked[a.tech + '|' + a.date] || []).slice().sort((x, y) => x.s - y.s)
|
||||
let cursor = sh; const gaps = []
|
||||
for (const b of day) { if (b.s > cursor) gaps.push([cursor, b.s]); cursor = Math.max(cursor, b.e) }
|
||||
if (cursor < eh) gaps.push([cursor, eh])
|
||||
return { tech: t, gaps }
|
||||
}
|
||||
|
||||
async function bookingSlots ({ skill, zone, duration = 1, start, days = 7, limit = 24, aggregate = false } = {}) {
|
||||
const dur = Number(duration) || 1
|
||||
const d = await loadBookingData(start, days)
|
||||
const out = []
|
||||
for (const a of d.asgs) {
|
||||
if (a.status === 'Annulé') continue
|
||||
const g = techGaps(a, d, skill, zone); if (!g) continue
|
||||
for (const [gs, ge] of g.gaps) { let s = gs; while (s + dur <= ge) { out.push({ date: a.date, start: hToTime(s), end: hToTime(s + dur), start_h: s, tech: a.tech, tech_name: g.tech.name, zone: a.zone || '', shift: a.shift }); s += dur } }
|
||||
}
|
||||
if (aggregate) { // client : 1 fenêtre par (date,heure) + nb de techs dispo, sans exposer qui
|
||||
const byWin = {}
|
||||
for (const s of out) { const k = s.date + '|' + s.start; (byWin[k] || (byWin[k] = { date: s.date, start: s.start, end: s.end, start_h: s.start_h, available: 0 })).available++ }
|
||||
return Object.values(byWin).sort((x, y) => x.date.localeCompare(y.date) || x.start_h - y.start_h).slice(0, limit)
|
||||
}
|
||||
out.sort((x, y) => x.date.localeCompare(y.date) || x.start_h - y.start_h)
|
||||
return out.slice(0, limit)
|
||||
}
|
||||
|
||||
// Fit : le client fournit 3 dispos classées → on place dans le 1er choix tenable,
|
||||
// sinon 2e, sinon 3e. Si aucune ne tient → on PROPOSE nos créneaux (fallback).
|
||||
async function fitBooking ({ skill, zone, duration = 1, prefs = [] } = {}) {
|
||||
const dur = Number(duration) || 1
|
||||
const dates = [...new Set((prefs || []).map(p => p.date).filter(Boolean))].sort()
|
||||
if (!dates.length) return { chosen: null, proposed: [] }
|
||||
const span = Math.max(1, Math.round((parseISO(dates[dates.length - 1]) - parseISO(dates[0])) / 86400000) + 1)
|
||||
const d = await loadBookingData(dates[0], span)
|
||||
const byDate = {}; for (const a of d.asgs) (byDate[a.date] || (byDate[a.date] = [])).push(a)
|
||||
for (let i = 0; i < prefs.length; i++) {
|
||||
const p = prefs[i]; const ps = timeToH(p.start); const pe = ps + dur
|
||||
for (const a of (byDate[p.date] || [])) {
|
||||
if (a.status === 'Annulé') continue
|
||||
const g = techGaps(a, d, skill, zone); if (!g) continue
|
||||
if (g.gaps.some(([gs, ge]) => gs <= ps && ge >= pe)) {
|
||||
return { chosen: { rank: i + 1, date: p.date, start: p.start, end: hToTime(pe), tech: a.tech, tech_name: g.tech.name, zone: a.zone || '', shift: a.shift }, proposed: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
const proposed = await bookingSlots({ skill, zone, duration: dur, start: dates[0], days: 14, limit: 6, aggregate: true })
|
||||
return { chosen: null, proposed }
|
||||
}
|
||||
|
||||
// ── Portail public de prise de RDV (staging — PAS sur Lovable tant que non validé) ──
|
||||
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 })
|
||||
return rows[0] || null
|
||||
}
|
||||
async function confirmWindow (jobName, date, start, duration) {
|
||||
const day = await bookingSlots({ duration, start: date, days: 1, limit: 300 })
|
||||
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
|
||||
const r = await retryWrite(() => erp.update('Dispatch Job', jobName, { scheduled_date: date, start_time: st, assigned_tech: slot.tech, status: 'assigned', booking_status: 'Confirmé' }))
|
||||
return r.ok ? { ok: true, confirmed: true, date, start, tech: slot.tech_name } : { ok: false, message: r.error || 'échec' }
|
||||
}
|
||||
|
||||
const BOOK_HTML = `<!doctype html><html lang=fr><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>Prendre rendez-vous — Gigafibre</title>
|
||||
<style>body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;margin:0;background:#f4f6f8;color:#1a1a1a}.wrap{max-width:560px;margin:0 auto;padding:16px}.card{background:#fff;border-radius:12px;box-shadow:0 1px 4px rgba(0,0,0,.08);padding:20px;margin-bottom:12px}h1{font-size:20px;margin:0 0 4px}.sub{color:#666;font-size:13px;margin-bottom:8px}.brand{color:#1565c0;font-weight:800}.day{font-weight:700;margin:14px 0 6px;font-size:13px;color:#444}.slots{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:8px}.slot{border:1px solid #d0d7de;border-radius:8px;padding:10px 6px;text-align:center;cursor:pointer;font-size:14px;position:relative}.slot:hover{border-color:#1565c0}.slot.sel{background:#e3f2fd;border-color:#1565c0;font-weight:700}.rank{position:absolute;top:-8px;right:-8px;background:#1565c0;color:#fff;width:20px;height:20px;border-radius:50%;font-size:12px;line-height:20px}.btn{background:#1565c0;color:#fff;border:0;border-radius:8px;padding:13px 18px;font-size:15px;font-weight:600;cursor:pointer;width:100%;margin-top:12px}.btn:disabled{background:#b0bec5}.hint{font-size:12px;color:#666;margin:8px 0}.ok{background:#e8f5e9;color:#1b5e20;padding:16px;border-radius:8px;text-align:center}.err{background:#ffebee;color:#b71c1c;padding:12px;border-radius:8px}</style></head>
|
||||
<body><div class=wrap><div class=card><h1>Prendre <span class=brand>rendez-vous</span></h1><div class=sub id=jobinfo>Chargement…</div><div id=content></div></div><div class=sub style=text-align:center>Gigafibre · propulsé par Targo</div></div>
|
||||
<script>
|
||||
const token=new URLSearchParams(location.search).get('token')||'';const FR=['dim','lun','mar','mer','jeu','ven','sam'];let picks=[];
|
||||
function frDate(iso){const a=iso.split('-').map(Number);const dt=new Date(Date.UTC(a[0],a[1]-1,a[2]));return FR[dt.getUTCDay()]+' '+iso.slice(8)+'/'+iso.slice(5,7)}
|
||||
async function load(){const r=await fetch('/book/api/options?token='+encodeURIComponent(token)).then(x=>x.json()).catch(()=>({ok:false}));const info=document.getElementById('jobinfo'),c=document.getElementById('content');
|
||||
if(!r.ok){info.textContent='';c.innerHTML='<div class=err>Lien invalide ou expiré. Contactez-nous.</div>';return}
|
||||
if(r.job.scheduled){info.innerHTML='Votre rendez-vous est déjà confirmé : <b>'+frDate(r.job.scheduled)+'</b>.';c.innerHTML='';return}
|
||||
info.innerHTML='Choisissez vos disponibilités <b>en ordre de préférence</b> (jusqu’à 3). On confirme le 1er créneau possible.';
|
||||
if(!r.windows.length){c.innerHTML='<div class=err>Aucune disponibilité pour le moment — nous vous contacterons.</div>';return}
|
||||
const byDay={};r.windows.forEach(w=>{(byDay[w.date]=byDay[w.date]||[]).push(w)});let h='';
|
||||
Object.keys(byDay).forEach(d=>{h+='<div class=day>'+frDate(d)+'</div><div class=slots>'+byDay[d].map(w=>'<div class=slot data-d="'+w.date+'" data-s="'+w.start+'">'+w.start+'–'+w.end+'</div>').join('')+'</div>'});
|
||||
h+='<div class=hint id=hint>Touchez 1 à 3 créneaux.</div><button class=btn id=go disabled>Confirmer mes disponibilités</button>';c.innerHTML=h;
|
||||
c.querySelectorAll('.slot').forEach(el=>el.onclick=()=>toggle(el));document.getElementById('go').onclick=submit}
|
||||
function toggle(el){const k=el.dataset.d+'|'+el.dataset.s,i=picks.indexOf(k);if(i>=0)picks.splice(i,1);else if(picks.length<3)picks.push(k);render()}
|
||||
function render(){document.querySelectorAll('.slot').forEach(el=>{const k=el.dataset.d+'|'+el.dataset.s,i=picks.indexOf(k);el.classList.toggle('sel',i>=0);let b=el.querySelector('.rank');if(i>=0){if(!b){b=document.createElement('div');b.className='rank';el.appendChild(b)}b.textContent=i+1}else if(b)b.remove()});document.getElementById('hint').textContent=picks.length?picks.length+' choix sélectionné(s) — le 1er sera priorisé.':'Touchez 1 à 3 créneaux.';document.getElementById('go').disabled=!picks.length}
|
||||
async function submit(){const go=document.getElementById('go');go.disabled=true;go.textContent='Envoi…';const prefs=picks.map(k=>({date:k.split('|')[0],start:k.split('|')[1]}));const r=await fetch('/book/api/submit?token='+encodeURIComponent(token),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode:'rank',prefs})}).then(x=>x.json()).catch(()=>({ok:false}));const c=document.getElementById('content');
|
||||
if(r.ok&&r.confirmed)c.innerHTML='<div class=ok>✅ Rendez-vous confirmé : <b>'+frDate(r.date)+' à '+r.start+'</b>.<br>Merci !</div>';
|
||||
else if(r.ok)c.innerHTML='<div class=ok>Merci ! '+(r.message||'Nous vous confirmerons sous peu.')+'</div>';
|
||||
else c.innerHTML='<div class=err>'+(r.message||r.error||'Une erreur est survenue.')+'</div>'}
|
||||
load();
|
||||
</script></body></html>`
|
||||
|
||||
async function handlePublicBooking (req, res, method, path, url) {
|
||||
if (path === '/book' && method === 'GET') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(BOOK_HTML) }
|
||||
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: 21, aggregate: true, limit: 60 })
|
||||
return json(res, 200, { ok: true, job: { location: job.service_location || '', 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
|
||||
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 }) }
|
||||
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.' })
|
||||
}
|
||||
return json(res, 400, { ok: false, error: 'requête invalide' })
|
||||
}
|
||||
return json(res, 404, { error: 'not found' })
|
||||
}
|
||||
|
||||
// Stats par jour : effectif (techs distincts), heures planifiées, tickets dispatch.
|
||||
async function statsByDay (start, days) {
|
||||
const dates = rangeDates(start, days)
|
||||
const asgs = await fetchAssignments(start, days)
|
||||
const jobs = await erp.list('Dispatch Job', {
|
||||
filters: [['scheduled_date', 'in', dates]],
|
||||
fields: ['name', 'scheduled_date'], limit: 3000,
|
||||
})
|
||||
const by = {}
|
||||
for (const d of dates) by[d] = { date: d, staff: new Set(), hours: 0, tickets: 0 }
|
||||
for (const a of asgs) { if (a.status === 'Annulé') continue; const x = by[a.date]; if (x) { x.staff.add(a.tech); x.hours += Number(a.hours) || 0 } }
|
||||
for (const j of jobs) { const x = by[j.scheduled_date]; if (x) x.tickets++ }
|
||||
return dates.map(d => ({ date: d, staff: by[d].staff.size, hours: by[d].hours, tickets: by[d].tickets }))
|
||||
}
|
||||
|
||||
// ── Routeur ──────────────────────────────────────────────────────────────────
|
||||
// technician_id n'est pas le docname → résoudre le docname Dispatch Technician.
|
||||
async function resolveTechName (techId) {
|
||||
const f = await erp.list('Dispatch Technician', { filters: [['technician_id', '=', techId]], fields: ['name'], limit: 1 })
|
||||
return f.length ? f[0].name : null
|
||||
}
|
||||
|
||||
async function handle (req, res, method, path, url) {
|
||||
const qs = url.searchParams
|
||||
const start = qs.get('start')
|
||||
const days = parseInt(qs.get('days') || '7', 10)
|
||||
|
||||
if (path === '/roster/technicians' && method === 'GET') {
|
||||
const techs = await fetchTechnicians()
|
||||
return json(res, 200, { technicians: techs, count: techs.length })
|
||||
}
|
||||
if (path === '/roster/templates' && method === 'GET') {
|
||||
return json(res, 200, { templates: await fetchTemplates() })
|
||||
}
|
||||
if (path === '/roster/templates' && method === 'POST') {
|
||||
const b = await parseBody(req)
|
||||
const r = await erp.create('Shift Template', {
|
||||
template_name: b.template_name, start_time: b.start_time, end_time: b.end_time,
|
||||
hours: b.hours, color: b.color || '#1976d2', zone: b.zone || '',
|
||||
default_required: b.default_required || 1, required_skills: b.required_skills || '', active: 1,
|
||||
})
|
||||
return json(res, r.ok ? 200 : 500, r)
|
||||
}
|
||||
if (path === '/roster/requirements' && method === 'GET') {
|
||||
if (!start) return json(res, 400, { error: 'start requis (YYYY-MM-DD)' })
|
||||
return json(res, 200, { requirements: await fetchRequirements(start, days) })
|
||||
}
|
||||
if (path === '/roster/requirements' && method === 'POST') {
|
||||
const b = await parseBody(req)
|
||||
const r = await erp.create('Shift Requirement', {
|
||||
requirement_date: b.requirement_date, shift_template: b.shift_template, zone: b.zone || '',
|
||||
required_count: b.required_count || 1, required_skills: b.required_skills || '',
|
||||
})
|
||||
return json(res, r.ok ? 200 : 500, r)
|
||||
}
|
||||
if (path === '/roster/assignments' && method === 'GET') {
|
||||
if (!start) return json(res, 400, { error: 'start requis' })
|
||||
return json(res, 200, { assignments: await fetchAssignments(start, days) })
|
||||
}
|
||||
if (path === '/roster/coverage' && method === 'GET') {
|
||||
if (!start) return json(res, 400, { error: 'start requis' })
|
||||
return json(res, 200, { coverage: await coverage(start, days) })
|
||||
}
|
||||
if (path === '/roster/stats' && method === 'GET') {
|
||||
if (!start) return json(res, 400, { error: 'start requis' })
|
||||
return json(res, 200, { stats: await statsByDay(start, days) })
|
||||
}
|
||||
// Prise de RDV : créneaux dispo (roster + compétence + zone) pour proposer/valider
|
||||
if (path === '/roster/book/slots' && method === 'GET') {
|
||||
if (!start) return json(res, 400, { error: 'start requis' })
|
||||
return json(res, 200, { slots: await bookingSlots({ skill: qs.get('skill') || '', zone: qs.get('zone') || '', duration: qs.get('duration') || 1, start, days, limit: parseInt(qs.get('limit') || '24', 10), aggregate: qs.get('aggregate') === '1' }) })
|
||||
}
|
||||
// Jobs à planifier (worklist du répartiteur)
|
||||
if (path === '/roster/book/jobs' && method === 'GET') {
|
||||
const rows = await erp.list('Dispatch Job', {
|
||||
filters: [['status', 'in', ['open', 'assigned']]],
|
||||
fields: ['name', 'customer_name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'start_time', 'assigned_tech', 'booking_status', 'status'],
|
||||
orderBy: 'modified desc', limit: 100,
|
||||
})
|
||||
return json(res, 200, { jobs: rows })
|
||||
}
|
||||
// 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' })
|
||||
const job = await erp.get('Dispatch Job', b.job, { fields: ['name', 'booking_token'] })
|
||||
if (!job) return json(res, 404, { error: 'job introuvable' })
|
||||
let token = job.booking_token
|
||||
if (!token) { token = crypto.randomBytes(12).toString('hex'); const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { booking_token: token })); if (!r.ok) return json(res, 500, r) }
|
||||
return json(res, 200, { ok: true, token, url: (cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca') + '/book?token=' + token })
|
||||
}
|
||||
// Fit : 3 dispos classées du client → 1er choix tenable, sinon proposer
|
||||
if (path === '/roster/book/fit' && method === 'POST') {
|
||||
const b = await parseBody(req)
|
||||
return json(res, 200, await fitBooking({ skill: b.skill || '', zone: b.zone || '', duration: b.duration || 1, prefs: b.prefs || [] }))
|
||||
}
|
||||
// Confirmer un RDV sur un Dispatch Job existant
|
||||
if (path === '/roster/book/confirm' && method === 'POST') {
|
||||
const b = await parseBody(req)
|
||||
if (!b.job) return json(res, 400, { error: 'job requis' })
|
||||
const st = (b.start || '').length === 5 ? b.start + ':00' : b.start
|
||||
const patch = { scheduled_date: b.date, start_time: st, status: 'assigned', booking_status: 'Confirmé', booking_prefs: JSON.stringify(b.prefs || []) }
|
||||
if (b.tech) patch.assigned_tech = b.tech
|
||||
if (b.duration) patch.duration_h = b.duration
|
||||
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
|
||||
return json(res, r.ok ? 200 : 500, r)
|
||||
}
|
||||
// Créer plusieurs besoins d'un coup (depuis l'éditeur de demande)
|
||||
if (path === '/roster/requirements/bulk' && method === 'POST') {
|
||||
const b = await parseBody(req); const errors = []; let created = 0
|
||||
for (const rq of b.requirements || []) {
|
||||
const r = await retryWrite(() => erp.create('Shift Requirement', {
|
||||
requirement_date: rq.requirement_date, shift_template: rq.shift_template,
|
||||
zone: rq.zone || '', required_count: rq.required_count || 1, required_skills: rq.required_skills || '',
|
||||
}))
|
||||
if (r.ok) created++; else errors.push(rq)
|
||||
}
|
||||
return json(res, 200, { ok: errors.length === 0, created, errors: errors.length })
|
||||
}
|
||||
// Vider les besoins d'une période (avant de ré-appliquer la demande)
|
||||
if (path === '/roster/requirements/clear' && method === 'POST') {
|
||||
const b = await parseBody(req)
|
||||
const reqs = await fetchRequirements(b.start, b.days || 7)
|
||||
let deleted = 0
|
||||
for (const rq of reqs) { const r = await retryWrite(() => erp.remove('Shift Requirement', rq.name)); if (r.ok) deleted++ }
|
||||
return json(res, 200, { ok: true, deleted })
|
||||
}
|
||||
if (path === '/roster/generate' && method === 'POST') {
|
||||
const b = await parseBody(req)
|
||||
if (!b.start) return json(res, 400, { error: 'start requis' })
|
||||
try {
|
||||
return json(res, 200, await generate(b.start, b.days || 7, b.weights))
|
||||
} catch (e) {
|
||||
return json(res, 502, { error: 'solveur injoignable ou erreur: ' + e.message })
|
||||
}
|
||||
}
|
||||
if (path === '/roster/publish' && method === 'POST') {
|
||||
const b = await parseBody(req)
|
||||
return json(res, 200, await publish(b.assignments))
|
||||
}
|
||||
// Publier = réécrire la semaine (efface tout sur la période, recrée la grille).
|
||||
// Idempotent + anti-doublons (contrairement au diff par case).
|
||||
if (path === '/roster/publish-week' && method === 'POST') {
|
||||
const b = await parseBody(req)
|
||||
const existing = await fetchAssignments(b.start, b.days || 7)
|
||||
let deleted = 0
|
||||
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
|
||||
for (const a of (b.assignments || [])) {
|
||||
const r = await retryWrite(() => erp.create('Shift Assignment', {
|
||||
technician: a.tech, technician_name: a.tech_name || '', assignment_date: a.date,
|
||||
shift_template: a.shift, zone: a.zone || '', hours: Number(a.hours) || 0,
|
||||
status: 'Publié', source: a.source || 'solveur',
|
||||
}))
|
||||
if (r.ok) created++; else errors++
|
||||
}
|
||||
let notified = 0
|
||||
if (b.notify && created) { // SMS opt-in aux techs (Twilio) — non bloquant
|
||||
try {
|
||||
const techs = await fetchTechnicians()
|
||||
const phoneById = Object.fromEntries(techs.map(t => [t.id, t.phone]))
|
||||
const tplName = Object.fromEntries((await fetchTemplates()).map(t => [t.name, t.template_name || t.name]))
|
||||
const byTech = {}
|
||||
for (const a of (b.assignments || [])) (byTech[a.tech] || (byTech[a.tech] = [])).push(a)
|
||||
const sendSms = require('./twilio').sendSmsInternal
|
||||
for (const tid in byTech) {
|
||||
const phone = phoneById[tid]; if (!phone) continue
|
||||
const lines = byTech[tid].slice().sort((x, y) => x.date.localeCompare(y.date)).map(a => a.date.slice(5) + ' ' + (tplName[a.shift] || a.shift)).join(' · ')
|
||||
try { await sendSms(phone, 'Targo — votre horaire publié : ' + lines); notified++ } catch (e) { /* skip ce tech */ }
|
||||
}
|
||||
} catch (e) { /* notif non bloquante */ }
|
||||
}
|
||||
return json(res, 200, { ok: errors === 0, created, deleted, errors, notified })
|
||||
}
|
||||
// Modifier / supprimer un type de shift (Shift Template)
|
||||
const mTpl = path.match(/^\/roster\/template\/(.+)$/)
|
||||
if (mTpl && method === 'PUT') {
|
||||
const name = decodeURIComponent(mTpl[1]); const b = await parseBody(req)
|
||||
const patch = {}
|
||||
for (const f of ['start_time', 'end_time', 'hours', 'color', 'zone', 'default_required', 'required_skills', 'active']) if (b[f] !== undefined) patch[f] = b[f]
|
||||
const r = await retryWrite(() => erp.update('Shift Template', name, patch))
|
||||
return json(res, r.ok ? 200 : 500, r)
|
||||
}
|
||||
if (mTpl && method === 'DELETE') {
|
||||
const name = decodeURIComponent(mTpl[1]); const r = await retryWrite(() => erp.remove('Shift Template', name))
|
||||
return json(res, r.ok ? 200 : 500, r)
|
||||
}
|
||||
if (path === '/roster/availability' && method === 'GET') {
|
||||
const status = qs.get('status') || ''
|
||||
const rows = await erp.list('Tech Availability', {
|
||||
filters: status ? [['status', '=', status]] : [],
|
||||
fields: ['name', 'technician', 'technician_name', 'availability_type', 'from_date', 'to_date', 'reason', 'status', 'approver'],
|
||||
orderBy: 'modified desc', limit: 200,
|
||||
})
|
||||
return json(res, 200, { availability: rows })
|
||||
}
|
||||
if (path === '/roster/availability' && method === 'POST') {
|
||||
const b = await parseBody(req)
|
||||
const r = await erp.create('Tech Availability', {
|
||||
technician: b.technician, technician_name: b.technician_name || '',
|
||||
availability_type: b.availability_type || 'Congé', status: 'Demandé',
|
||||
from_date: b.from_date, to_date: b.to_date, reason: b.reason || '',
|
||||
})
|
||||
return json(res, r.ok ? 200 : 500, r)
|
||||
}
|
||||
const mApprove = path.match(/^\/roster\/availability\/(.+)\/approve$/)
|
||||
if (mApprove && method === 'POST') {
|
||||
const name = decodeURIComponent(mApprove[1])
|
||||
const b = await parseBody(req)
|
||||
const r = await retryWrite(() => erp.update('Tech Availability', name, { status: b.reject ? 'Refusé' : 'Approuvé', approver: b.approver || '' }))
|
||||
return json(res, r.ok ? 200 : 500, r)
|
||||
}
|
||||
const mSkills = path.match(/^\/roster\/technician\/(.+)\/skills$/)
|
||||
if (mSkills && method === 'POST') {
|
||||
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() }))
|
||||
return json(res, r.ok ? 200 : 500, { ...r, technician: techId })
|
||||
}
|
||||
const mCost = path.match(/^\/roster\/technician\/(.+)\/cost$/)
|
||||
if (mCost && method === 'POST') {
|
||||
const techId = decodeURIComponent(mCost[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, { cost_salary_h: Number(b.salary) || 0, cost_charges_pct: Number(b.charges) || 0, cost_other_h: Number(b.other) || 0 }))
|
||||
return json(res, r.ok ? 200 : 500, { ...r, technician: techId })
|
||||
}
|
||||
const mEff = path.match(/^\/roster\/technician\/(.+)\/efficiency$/)
|
||||
if (mEff && method === 'POST') {
|
||||
const techId = decodeURIComponent(mEff[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, { efficiency: Number(b.efficiency) || 1 }))
|
||||
return json(res, r.ok ? 200 : 500, { ...r, technician: techId, efficiency: Number(b.efficiency) || 1 })
|
||||
}
|
||||
const mPause = path.match(/^\/roster\/technician\/(.+)\/pause$/)
|
||||
if (mPause && method === 'POST') {
|
||||
const techId = decodeURIComponent(mPause[1])
|
||||
const b = await parseBody(req)
|
||||
// technician_id n'est pas le docname → retrouver le doc
|
||||
const techName = await resolveTechName(techId)
|
||||
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
|
||||
const patch = { status: b.paused ? PAUSE_STATUS : AVAIL_STATUS }
|
||||
if (b.paused && b.reason) patch.absence_reason = b.reason
|
||||
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch))
|
||||
return json(res, r.ok ? 200 : 500, { ...r, technician: techId, status: patch.status })
|
||||
}
|
||||
// Supprimer une assignation publiée
|
||||
const mDelA = path.match(/^\/roster\/assignment\/(.+)$/)
|
||||
if (mDelA && method === 'DELETE') {
|
||||
const name = decodeURIComponent(mDelA[1])
|
||||
const r = await retryWrite(() => erp.remove('Shift Assignment', name))
|
||||
return json(res, r.ok ? 200 : 500, r)
|
||||
}
|
||||
return json(res, 404, { error: 'roster: route inconnue ' + path })
|
||||
}
|
||||
|
||||
module.exports = { handle, handlePublicBooking, generate, publish, coverage, fetchTechnicians }
|
||||
|
|
@ -126,6 +126,10 @@ const server = http.createServer(async (req, res) => {
|
|||
if (path.startsWith('/serviceability')) return require('./lib/serviceability').handle(req, res, method, path)
|
||||
// Admin view of ERPNext outbound Email Queue (view/delete/purge).
|
||||
if (path.startsWith('/email-queue')) return require('./lib/email-queue').handle(req, res, method, path, url)
|
||||
// Planification (Roster AI) — modèles de shifts, génération via solveur OR-Tools, pause/vacances.
|
||||
if (path.startsWith('/roster')) return require('./lib/roster').handle(req, res, method, path, url)
|
||||
// Portail public de prise de RDV (staging) — page + API client, PUBLIC (pas de SSO).
|
||||
if (path === '/book' || path.startsWith('/book/')) return require('./lib/roster').handlePublicBooking(req, res, method, path, url)
|
||||
if (path.startsWith('/campaigns')) return require('./lib/campaigns').handle(req, res, method, path)
|
||||
// Gift redirect wrapper — short public URLs in campaign emails that
|
||||
// 302 to the underlying Giftbit shortlink (subject to our expiry/revoke).
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user