diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js
new file mode 100644
index 0000000..b1da4d0
--- /dev/null
+++ b/apps/ops/src/api/roster.js
@@ -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)
diff --git a/apps/ops/src/composables/useHelpers.js b/apps/ops/src/composables/useHelpers.js
index e135a26..09f5816 100644
--- a/apps/ops/src/composables/useHelpers.js
+++ b/apps/ops/src/composables/useHelpers.js
@@ -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'
diff --git a/apps/ops/src/config/nav.js b/apps/ops/src/config/nav.js
index 119c5a9..2a2b0ae 100644
--- a/apps/ops/src/config/nav.js
+++ b/apps/ops/src/config/nav.js
@@ -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' },
diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue
new file mode 100644
index 0000000..6aba3af
--- /dev/null
+++ b/apps/ops/src/pages/PlanificationPage.vue
@@ -0,0 +1,550 @@
+
+
+
+
Planification
+
{{ dirtyCount }} non publié(s)
+
+
+ Semaine précédente
+
+ Semaine suivante
+
+
+
+
Annuler (Ctrl+Z)
+
Rétablir (Ctrl+Shift+Z)
+
+
+
+
+ Enregistrer la semaine comme modèle…
+
+ Appliquer
+
+ {{ tm.name }}
+
+
+
+
+
+
+
Notifier les techs par SMS à la publication
+
+
guard(loadWeek)" />
+
+
+
+
+
+
+
+
+
+ {{ visibleTechs.length }} / {{ techs.length }} techs
+
+
+
+
+
+
+
Demande — effectif requis par créneau
+
+
+
+
+ Coche les jours fériés (F) dans l'en-tête · fin de semaine = sam/dim (auto). Si Durée/job > 0, les nombres = nb de jobs → effectif = ⌈jobs × durée ÷ heures du shift⌉ (compétences requises = colonne Compétences).
+
+
+
+ Modèle Zone Compétences Durée/job (h) Semaine Fin de sem. Férié
+
+
+
+
+
+
+
+
+
+
+
+ Aucune ligne — clique « Ajouter ».
+
+
+
+
+
+
+
+ {{ solverStats.assignments }} assignations · {{ solverStats.shortfall ? (solverStats.shortfall + ' poste(s) non couvert(s)') : 'couverture complète' }} · équité {{ solverStats.spread }} h · {{ solverStats.ms }} ms
+
+
+
+ {{ selection.length }} cellule(s) — assigner :
+
+
+
+
+
+
+ Légende :
+ {{ code(t) }} {{ t.template_name }}
+ P pause
+ · libre
+ J modifié (non publié)
+ · glisser = sélection · shift+clic = bloc · clic en-tête = colonne · clic nom = rangée · ctrl+clic = +1
+
+
+
+
+
+
+ Technicien
+
+ {{ d.dow }}
{{ d.dnum }}
+ {{ gapByDay[d.iso] }}
+ Marquer férié F
+
+
+
+
+
+
+ {{ isPaused(t) ? 'Réactiver' : 'Pause' }}
+ {{ t.name }}
+ {{ t.group }}
+ {{ effLabel(t.efficiency) }}
+ · {{ hoursOf(t.id) }}h
+
+
+ {{ cellCode(a) }}{{ cellHours(a) }}
+ P
+ ·
+
+
+ Aucun technicien (filtre ?).
+
+
+ 👥 Effectif {{ stat(d.iso).staff || '' }}
+ ⏱ Heures {{ stat(d.iso).hours || '' }}
+ 🎫 Tickets {{ stat(d.iso).tickets || '' }}
+ 💲 Coût ({{ Math.round(weekCost) }} $/sem) {{ dayCost(d.iso) || '' }}
+
+
+
+
+ Couverture — dispo vs requis
+ Aucun besoin défini. Utilise « Demande » → « Appliquer à la semaine ».
+
+
+ Créneau {{ d.dow }}
{{ d.dnum }}
+ {{ row.label }} {{ covText(row.key, d.iso) }}
+
+
+
+
+
+
+ Types de shift
+
+
+
+
+
+
+
+
+
+ {{ calcHours(newTpl.start, newTpl.end) }} h
+
+
+
+
+
+
+
+
+
+
+ Congés & disponibilités
+
+
+
+
+
+ Technicien Type Du Au Motif Statut
+
+
+ {{ l.technician_name || l.technician }} {{ l.availability_type }} {{ l.from_date }} {{ l.to_date }} {{ l.reason }}
+ {{ l.status }}
+
+
+ Aucune demande.
+
+
+
+ Nouvelle demande
+
+
+
+
+
+
+
+
+ Une demande approuvée rend le tech indisponible pour le solveur sur ces dates.
+
+
+
+
+
+
+ Équipe — cadence & coût
+
+ 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.
+
+
+ Technicien Compétences Cadence Salaire/h Charges % Autres/h Coût chargé/h
+
+
+ {{ t.name }}{{ t.group }}
+
+
+
+
+
+ {{ loadedCost(t) }} $
+
+
+
+
+
+
+
+
+
+
+ {{ menu.tech && menu.tech.name }} — {{ menu.day && menu.day.dnum }}
+
+ {{ cellCode(a) }}
+ {{ a.shift_name || a.shift }} {{ cellHours(a) }}h
+ Retirer
+
+
+ Ajouter un shift
+ {{ code(t) }} {{ t.template_name }}
+
+ Libérer tout
+
+
+
+
+
+
+
+
diff --git a/apps/ops/src/pages/RendezVousPage.vue b/apps/ops/src/pages/RendezVousPage.vue
new file mode 100644
index 0000000..0cb1fee
--- /dev/null
+++ b/apps/ops/src/pages/RendezVousPage.vue
@@ -0,0 +1,155 @@
+
+
+
+
Rendez-vous clients
+
+
+
+
+
+
+
+
+
+ Jobs ({{ filteredJobs.length }})
+
+
+ {{ j.name }}
+ {{ j.service_location || '—' }} · {{ j.duration_h || 1 }}h
+
+
+
+
+
+ Aucun job.
+
+
+
+
+
+
Sélectionne un job à gauche pour prendre rendez-vous.
+
+
+ {{ sel.name }}
+ {{ sel.service_location || '—' }} · durée {{ params.duration }}h · tech actuel: {{ sel.assigned_tech || '—' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Saisis les 3 disponibilités du client, en ordre de préférence. On place dans le 1er tenable.
+
+ {{ i + 1 }}
+
+
+
+
+
+
+
+ ✅ Choix #{{ fit.chosen.rank }} retenu : {{ frDate(fit.chosen.date) }} {{ fit.chosen.start }}–{{ fit.chosen.end }} · {{ fit.chosen.tech_name }}
+
+
+
+ ⚠️ Aucune des 3 dispos n'est tenable. Créneaux proposés :
+
+ {{ frDate(s.date) }} {{ s.start }} ({{ s.available }} dispo)
+ aucun — élargis la période ou la compétence.
+
+ Passe à « Proposer des créneaux » pour en assigner un.
+
+
+
+
+
+
+
+
+
{{ slots.length }} créneaux — clique pour sélectionner :
+
+
+
{{ frDate(s.date) }}
+
{{ s.start }}–{{ s.end }}
+
{{ s.tech_name }}
+
+
+
+
+ Aucun créneau — élargis la période, la zone ou la compétence (le roster doit être publié).
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/ops/src/router/index.js b/apps/ops/src/router/index.js
index 28d5955..3df2b8a 100644
--- a/apps/ops/src/router/index.js
+++ b/apps/ops/src/router/index.js
@@ -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
diff --git a/docs/shifts-module-spec.md b/docs/shifts-module-spec.md
index d72a6a3..cfff48e 100644
--- a/docs/shifts-module-spec.md
+++ b/docs/shifts-module-spec.md
@@ -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
diff --git a/services/roster-solver/.gitignore b/services/roster-solver/.gitignore
new file mode 100644
index 0000000..77ac754
--- /dev/null
+++ b/services/roster-solver/.gitignore
@@ -0,0 +1,3 @@
+.venv/
+__pycache__/
+*.pyc
diff --git a/services/roster-solver/Dockerfile b/services/roster-solver/Dockerfile
new file mode 100644
index 0000000..ec2f768
--- /dev/null
+++ b/services/roster-solver/Dockerfile
@@ -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"]
diff --git a/services/roster-solver/README.md b/services/roster-solver/README.md
new file mode 100644
index 0000000..109ad9d
--- /dev/null
+++ b/services/roster-solver/README.md
@@ -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
+```
diff --git a/services/roster-solver/app.py b/services/roster-solver/app.py
new file mode 100644
index 0000000..3d31e3f
--- /dev/null
+++ b/services/roster-solver/app.py
@@ -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)})
diff --git a/services/roster-solver/requirements.txt b/services/roster-solver/requirements.txt
new file mode 100644
index 0000000..26b1862
--- /dev/null
+++ b/services/roster-solver/requirements.txt
@@ -0,0 +1,4 @@
+ortools>=9.10
+fastapi>=0.110
+uvicorn[standard]>=0.29
+pydantic>=2.6
diff --git a/services/roster-solver/sample_request.json b/services/roster-solver/sample_request.json
new file mode 100644
index 0000000..a5222e3
--- /dev/null
+++ b/services/roster-solver/sample_request.json
@@ -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
+}
diff --git a/services/roster-solver/solver.py b/services/roster-solver/solver.py
new file mode 100644
index 0000000..0a869b9
--- /dev/null
+++ b/services/roster-solver/solver.py
@@ -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),
+ }
diff --git a/services/roster-solver/test_solver.py b/services/roster-solver/test_solver.py
new file mode 100644
index 0000000..4a79b8f
--- /dev/null
+++ b/services/roster-solver/test_solver.py
@@ -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())
diff --git a/services/targo-hub/lib/config.js b/services/targo-hub/lib/config.js
index 8ea958e..135fc03 100644
--- a/services/targo-hub/lib/config.js
+++ b/services/targo-hub/lib/config.js
@@ -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'),
diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js
new file mode 100644
index 0000000..de7e20d
--- /dev/null
+++ b/services/targo-hub/lib/roster.js
@@ -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 = `
Prendre rendez-vous — Gigafibre
+
+Prendre rendez-vous Chargement…
Gigafibre · propulsé par Targo
+`
+
+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 }
diff --git a/services/targo-hub/server.js b/services/targo-hub/server.js
index c5375b2..29299a8 100644
--- a/services/targo-hub/server.js
+++ b/services/targo-hub/server.js
@@ -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).