From f4138cdd758c0a1b3a5156c8837fa24e1d096a45 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Wed, 3 Jun 2026 16:42:44 -0400 Subject: [PATCH] Roster AI (planification) + prise de rendez-vous client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/ops/src/api/roster.js | 64 ++ apps/ops/src/composables/useHelpers.js | 5 + apps/ops/src/config/nav.js | 2 + apps/ops/src/pages/PlanificationPage.vue | 550 +++++++++++++++++ apps/ops/src/pages/RendezVousPage.vue | 155 +++++ apps/ops/src/router/index.js | 2 + docs/shifts-module-spec.md | 26 +- services/roster-solver/.gitignore | 3 + services/roster-solver/Dockerfile | 11 + services/roster-solver/README.md | 68 +++ services/roster-solver/app.py | 43 ++ services/roster-solver/requirements.txt | 4 + services/roster-solver/sample_request.json | 26 + services/roster-solver/solver.py | 224 +++++++ services/roster-solver/test_solver.py | 84 +++ services/targo-hub/lib/config.js | 1 + services/targo-hub/lib/roster.js | 653 +++++++++++++++++++++ services/targo-hub/server.js | 4 + 18 files changed, 1920 insertions(+), 5 deletions(-) create mode 100644 apps/ops/src/api/roster.js create mode 100644 apps/ops/src/pages/PlanificationPage.vue create mode 100644 apps/ops/src/pages/RendezVousPage.vue create mode 100644 services/roster-solver/.gitignore create mode 100644 services/roster-solver/Dockerfile create mode 100644 services/roster-solver/README.md create mode 100644 services/roster-solver/app.py create mode 100644 services/roster-solver/requirements.txt create mode 100644 services/roster-solver/sample_request.json create mode 100644 services/roster-solver/solver.py create mode 100644 services/roster-solver/test_solver.py create mode 100644 services/targo-hub/lib/roster.js 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 @@ + + + + + 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 @@ + + + + + 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).