P4 — the FULL optimize orchestration now lives in the hub
(POST /roster/optimize-plan): pool (buildUnassigned), day bucketing
(dated-in-window → its day; overdue/undated → day 1 with spillover to
next day if unplaced), vehicles from real shift windows (assignments ×
templates) or assumed 8-16 weekdays (assumed_shift map returned; never
weekends; absences excluded), existing load (occupancyByTechDay) reduces
capacity, origins = home else TARGO depot, priorities from j.priority,
per-job AM/PM windows + duration overrides accepted, OSRM matrix +
OR-Tools solve per day (shared solveVrp, also used by /optimize-routes).
Reusable by the morning auto-plan cron and the field app.
Address rules (user cases baked in, hub-side):
- SAME ADDRESS = ONE STOP: pool jobs sharing an address key (~10m coords
or normalized address) are merged into one solver node (service times
summed) → same tech, single stop. Verified: install + TV boîtier of
the same customer (Cazaville) grouped on Stéphane, 🔗 badges.
- COORDINATION: a pool job at the address of an ALREADY-ASSIGNED job
that day is pinned onto THAT tech (they're going anyway) — verified:
"Réinstallation #253028" pinned to Frédérique who already has the
install there; capable flag kept honest (⚠ if skill missing).
SPA: optimizeSuggestion → hub-first (dates/techs/opts/dur_overrides/
job_windows), entries enriched client-side (lvl/eff/noShift from
assumed_shift); local orchestration kept as emergency fallback. Review
shows 🔗 teal (coordonné avec déjà-assigné) / 🔗 indigo (N jobs même
adresse = 1 arrêt); map merges same-coord stops into one numbered pin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P1 — self-hosted OSRM replaces ALL paid Mapbox routing in the SPA:
- hub: POST /roster/osrm-route (geometry + total + per-leg km/min) and
/roster/osrm-table (durations/distances matrix, Mapbox-shaped) proxying
the local OSRM; osrmGet helper.
- SPA: loadRealRoutes (review map), kanban travel matrix, day-editor
matrix, day-map route geometry → all via hub OSRM. 0 paid
directions/matrix calls left (tiles + geocoding only).
Review usability (evaluate whether times fit):
- Click a job → the SAME job-detail dialog as the grid (address, ticket
thread, duration, team). openEntryDetail maps the pool job.
- Per-task time is editable (q-popup-edit on the "X.Xh"): updates the
plan live, survives Ré-optimiser (durOverride), persists duration_h
via patchJob (hub whitelist extended, 0-24h).
- Real travel time BETWEEN stops: one OSRM route per tech×day (cached)
→ "🚗 X min" rows between entries + "domicile → 1er arrêt";
haversine ≈ fallback when coords missing.
Verified: OSRM proxies live (route 7.8km/12min with legs 7+6min, table
3x3); review shows 10 real leg rows; detail dialog shows address;
duration popup opens/cancels cleanly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- shared priorityMeta/PRIORITY_LEVELS → 3 levels: Haute (RED) · Moyenne
(neutral grey) · Basse. Default/medium = grey outlined_flag. Aliases
urgent→high, normal→medium for old conversation data.
- conversations: priority endpoint accepts 'medium' (keeps old values for
back-compat); the flag menu now shows the 3 levels.
- jobs: dropped the parallel job-flags override store — the job flag now
writes the REAL ERPNext priority (low/medium/high) via patchJob/
updateJob, same source as the pool sheet (no more two mechanisms).
setEntryPriority reuses patchJob. Solver: high never dropped + served
early, low dropped first.
Verified: job flag + conversation flag both grey-by-default, menu Haute/
Moyenne/Basse, high=red.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The urgent flag was a red emoji shown red before being set — wrong.
Now reuses the exact Communications component: priorityMeta() + a
priority list (Urgent/Haute/Normale/Basse/Aucune) — grey outlined_flag
by default, colored only when set.
- SPA: per-job flag = q-btn(icon=priorityMeta(...).icon) + q-menu list,
same as ConversationPanel. setEntryPriority(name,lvl) (renamed to avoid
clashing with the existing pool setJobPriority(j,p)). medium (ERPNext
default) shows as grey/none.
- hub: job-flags store now holds a dispatch PRIORITY (urgent|high|normal|
low) overriding j.priority in buildUnassigned — needed because the
ERPNext job-priority field is whitelisted to low/medium/high (can't do
urgent), just like conversations keep their own priority.
Lesson applied: grep for an existing symbol before declaring (2nd
collision this session — techsForJob, setJobPriority).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Like the omnichannel inbox urgent flag, but for jobs — a 🚩 that
persists and drives the optimizer.
- hub: durable job-flags store (data/job-flags.json) mirroring
job-levels — getJobFlags/setJobFlag + POST /roster/job-flag {name,
urgent}; buildUnassigned sets j.urgent; invalidatePool on write.
- SPA: 🚩 chip on each review entry (replaces the session ⚡). Optimistic
local toggle + persists via roster.setJobFlag; reflects j.urgent from
the pool. Flagged jobs → priority_boost (never dropped) + urgent_weight
(served early). Bumped urgent early-weight 6→10.
Verified: flag round-trips (set→urgent, revert→cleared) through the pool;
chips render (🚩/AM/PM).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
estimateForJob: an INSTALLATION whose location matches a pre-wired site
(Lac des pins, Camping, Domaine Dauphinais) takes ¼ of the normal time
(infra already in place). Applied at the estimator source → flows into
est_min everywhere (greedy duration, VRP service_min, occupancy, travel).
Floored at 15 min; adds a "site pré-câblé ×¼" label. Verified: Lac des
pins install 120→30 min; normal install unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the haversine straight-line estimate with real road travel times
from a self-hosted OSRM (free/offline — Mapbox Matrix is billable).
- hub: osrmMatrix() calls OSRM /table for the job+home coords (node order
matches route_solver: jobs then homes), returns a minutes matrix.
Handles PARTIAL coords — OSRM for geolocated nodes, 0/neutral for the
rest (matches the solver's own fallback), so a few tech homes without
coords don't disable OSRM for the geolocated majority. /roster/
optimize-routes injects body.matrix; graceful fallback to haversine if
OSRM is unreachable or too few points.
- config: OSRM_URL (default http://osrm:5000).
- services/osrm/README.md: reproducible setup (Québec extract, MLD
pipeline, osrm-routed on erpnext_erpnext network).
Deployed + verified: OSRM /table hit with the full coord set, no
fallback; optimizer routes on real road times.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Real vehicle-routing optimization behind the Suggérer UI, reusing our
existing OR-Tools solver service (no new stack). Fixes the greedy's
structural limits (sector-splitting, no global optimization).
- roster-solver: new route_solver.py (OR-Tools Routing / VRPTW).
Minimizes real travel; skills = hard filter (VehicleVar ∈ allowed∪{-1};
SetAllowedVehiclesForIndex has a broken Span typemap in ortools 9.15);
on-site service time within each tech's shift window; optional per-job
time windows; unfittable jobs left unassigned (drop penalty) instead of
infeasible; specialist bias via skill order (per-vehicle arc cost).
New POST /route endpoint. Dockerfile now COPYs route_solver.py.
Unit-tested: skills respected, sectors consolidated, edge cases safe.
- hub: POST /roster/optimize-routes → proxies to solver /route.
- ops SPA: "⚡ Optimiser" strategy. Greedy buckets jobs into days +
placeholders, then the solver re-optimizes each day (assignment +
routes) among shifted techs; result maps into the same review dialog
(occupation bars, route map, swap/merge reused). Graceful fallback to
greedy if the solver is unreachable (never drops jobs). shiftWindowMin
derives each vehicle's shift from templates.
Phase 2 (later): OSRM/Mapbox road-time matrix for exact travel times.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Niveau requis PAR JOB persistant : store hub durable (job-levels.json) + endpoint
POST /roster/job-level + buildUnassigned enrichit required_level ; pastille « niv »
écrit via l'API (survit reload/session). NB : l'API Custom Field ERPNext v16 échoue
(IndexError) sur ce doctype custom → store hub (migratable en champ ERPNext plus tard).
- Lasso FREEFORM : tracé libre (souris + tactile) → polygone SVG + point-in-polygon sur
les pins/amas projetés (remplace le rectangle) + expansion des amas (getClusterLeaves).
- D v2 : carte des tournées = routes ROUTIÈRES réelles (Mapbox Directions par tech,
domicile→arrêts) + distance/temps RÉELS dans la légende (cache par signature ;
fallback segments droits + estimation haversine).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré /
juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis
par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts,
sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle
week-end + placeholder « en attente du quart », clustering + lasso + filtre-date
sur la carte, accès rapide « À assigner » (badge).
Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à
l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive
+ timeout fetch global + authFetch durci → fin des rechargements manuels.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hub (lib/roster.js, vision.js, legacy-dispatch-sync.js, server.js) + Ops + pont legacy.
- Capacité par jour AM/PM (Soir = réserve garde/urgence, jamais offerte) calculée
client-side sur les techs visibles -> suit le filtre de compétence.
- Modèle de durée ADDITIF (caractéristiques, tableur inline) + auto-détection
DÉTERMINISTE par mots-clés (sans IA permanente) ; est_min branché sur capacité + pool.
- Capture terrain passive : endpoints publics /field (job/tech/checkpoint/ts/photo/
device/vision), tokens HMAC signés sans PII ; dérive actual_start/end. UI hébergée
public/field-app.html (liste/carte Mapbox/Street View/photo/scan MLKit->Gemini).
- Chrono job (start/finish), repositionnement carte (set-location), vue satellite,
Street View clic-droit, année devant les dates dues groupées.
- Sync techniciens : rapport de réconciliation 3 systèmes (staff legacy / Dispatch
Technician / groupe Authentik), application MANUELLE, zéro écriture Authentik (+11 fiches).
- vision.js : extractEquipment() réutilisable (marque/modèle/série/MAC/codes-barres).
- Pont legacy (ops_reassign.php) : désassignation reflétée, fermeture ticket, retour
au pool ; notification courriel à l'assignation.
Déployé sur le hub ; ce commit aligne le repo sur l'état en production.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- FIX réordonnancement : flèches ↑↓ (fiable) + drag-drop basé sur le DROP (au lieu du splice live jittery)
- durée éditable par job en MINUTES (pas de 5, best practice précision) → persistée via reorder-jobs (duration_h)
- temps de transport estimé entre 2 jobs (haversine sur coords Service Location, 40km/h + 5min) affiché entre les lignes
→ en attendant la géoloc live (Capacitor background-geolocation, noté pour plus tard)
- hub : occupancyByTechDay renvoie lat/lon par job ; reorder-jobs accepte duration_h ; total h en pied
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- clic sur le progressbar → q-dialog ciblé sur le tech×jour (garde le contexte de la grille derrière) :
timeline visuelle (blocs colorés par compétence) + liste éditable des jobs
- réordonnancement par DRAG-DROP (dragstart/dragover/dragend → route_order) + sélecteur de priorité + Enregistrer
- retrait d'un job (✕ → hub POST /roster/unassign-job : assigned_tech null, status open → retour au pool)
- bouton « Dispatch » comme échappatoire vers le tableau complet (gotoDispatch)
- réutilise occupancy/cellBands/cellBlocks/blockStyle + reorderJobs ; best-practice détail-drawer (pas de navigation pleine page)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Blocs d'occupation = 1 job, coloré par la COULEUR DE SA COMPÉTENCE (palette skills éditable),
au lieu du dégradé vert→rouge par taux. Hub occupancyByTechDay attache skill+job à chaque bloc
(skillForJob||deptToSkill) ; blockStyle → getTagColor(blk.skill).
- Clic sur le progressbar (.tl, @click.stop+@mousedown.stop) → menu : liste des jobs du tech×jour avec
réordonnancement (flèches → route_order) + re-priorisation (select) + Enregistrer.
Hub POST /roster/reorder-jobs (route_order/priority, séquentiel) ; tri occupancy par route_order.
- Clic HORS du progressbar dans la cellule → menu shift inchangé (créer/modifier).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Couleurs liées aux skills (éditable/cohérent) : hub deptToSkill() déduit une compétence du type legacy
→ /roster/unassigned-jobs renvoie required_skill ; PlanificationPage colore la carte par getTagColor(required_skill)
(même couleur que le chip skill) ; bordure 5px
- Fil complet du ticket : hub /dispatch/legacy-sync/ticket-thread (ticket_msg + auteur staff, HTML nettoyé) ;
api legacyTicketThread ; RightPanel bouton « 💬 Voir le fil / commentaires » (chargé au clic, messages+auteurs+dates)
- Order-by du pool dispatch : useBottomPanel.bottomSort (date|city|priority) + dropdown ⇅ dans BottomPanel
(ville = 2e segment adresse / token sujet avant |)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Panneau « jobs à assigner » v2 : multi-sélection (cases), groupes parent-enfant
surlignés, heuristique terrain/à distance (activation/netadmin pré-décochés),
pré-total d'heures, aperçu d'occupation PROJETÉE au survol (barre fantôme + badge).
Fix barre d'occupation figée après drop : /roster/assign-job pose désormais un
start_time (premier-trou-libre dans le shift) + garantit duration_h, sinon le job
compte dans les heures mais n'affiche aucun bloc. Nouvel endpoint
/roster/backfill-start-times (idempotent) pour rattraper l'historique.
Infobulle de cellule : nb de jobs + liste triée par priorité (occupancyByTechDay
renvoie jobs[]). Timeline contextuelle par ressource (dialogue, 0 appel réseau).
Lisibilité du drag : fantôme compact semi-transparent décalé sous le curseur
(ne masque plus l'aperçu) + source estompée.
Scoring de priorité : hook proximité (neutre — secteur géré manuellement),
réservé à 20% du score quand la géoloc arrivera.
Refactor hub : helper partagé firstFitStart (assign-job + backfill).
Nettoyage : retrait code mort (onDeleteRosterTag, projUsedH), carte des sections
en tête de PlanificationPage. Doc : docs/features/roster.md + index.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clic sur une case → touche « A » (bascule) ou menu « Marquer absent ce jour / Retirer l'absence » →
crée/supprime une Tech Availability d'1 jour APPROUVÉE (hachurée tout de suite). Multi-sélection
supportée (A marque toutes, re-A retire). Endpoint POST /roster/absence/set {tech,date,type,remove}
(remove ne touche que les absences d'un jour, pas les vacances multi-jours). Type défaut Congé.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Avant: « Appliquer à la semaine » n'écrivait qu'une semaine → 1 seul tech (rotation hebdo). Maintenant
« Générer la garde » matérialise la rotation sur N semaines (4/8/12/26) d'un coup, écrit direct (publié),
navigable semaine par semaine — comme un évènement récurrent. Endpoint /roster/garde/apply : wipe ciblé
des shifts de garde dans l'horizon + recréation (idempotent, reflète l'édition de la séquence). Saut
d'absent conservé. (source='manuel' car le Select n'autorise que solveur/manuel.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Avant: /roster/publish-week supprimait TOUTES les assignations de la semaine puis les recréait toutes
(~2N écritures ERPNext séquentielles → plusieurs secondes/dizaines de s). Maintenant: diff par clé
tech|date|shift — supprime seulement les retirées, crée seulement les nouvelles, ignore les inchangées.
Republier sans changement: 87 assignations → 0 écriture, 37 ms (vs ~174 écritures avant). Une modif
de quelques cellules = quelques écritures seulement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tech Availability gagne long_term (Check). Case à cocher « Longue durée » dans le dialogue Congés.
absencesByTechDay encode « (longue durée) » → applyTemplate force « à remplacer » (pas juste sauter
comme des vacances), même si l'absence ne couvre pas toute la semaine. Complète l'intelligence
permanent vs vacances (avant: déduit de « absent toute la semaine »).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Garde (on_call) exclue partout des heures travaillées + du coût:
- hub statsByDay: heures = somme des shifts NON-garde ; nouveau compteur on_call/jour (techs en dispo).
- Ops: hoursOf (heures/tech + alerte heures supp) et costByDate/weekCost excluent la garde.
- nouvelle ligne de pied '🛡️ Garde' = nb de techs en disponibilité/jour (si applicable).
Cohérent avec l'occupation (déjà hors-garde) : la garde = réserve d'urgence, ni offerte ni facturée.
Vérifié 8 juin: 112 h travaillées (garde 6 h exclue), garde=1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Modèle: champ on_call (Check) sur Shift Template. Un quart garde:
- N'est JAMAIS offert au booking client (techGaps retourne null) — vérifié: tech Jour+Garde
n'offre que la fenêtre Jour, aucun créneau dans la plage de garde.
- Est EXCLU du dénominateur d'occupation (heures offrables), affiché à part.
- Timeline: bande HACHURÉE (vs neutre pour l'offrable) + 🛡️ dans le label + tag (garde) en infobulle.
- Éditeur de modèles: bascule '🛡️ Garde' pour créer/marquer un quart de garde.
hub: fetchTemplates expose on_call; create/update template le gèrent. Champ ajouté à
setup_dispatch_custom_fields.py (persistance). Démo: Garde 18h-minuit marquée on_call.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Avant: 'J8' ne distinguait pas 7-15 de 9-17 → mêmes créneaux apparents, dispo réelle différente.
Maintenant chaque cellule affiche: chip (lettre) + intervalle '7–15', et une mini-timeline sur un
axe de journée (06:00→21:00) où la fenêtre du shift est positionnée (donc 7-15 à gauche, 9-17 à
droite = visuellement distinctes) avec les blocs de jobs pris (couleur selon charge) → les TROUS
restants = créneaux offrables. Infobulle = intervalle + h occupées/h (%).
- hub occupancyByTechDay renvoie {h, blocks:[{s,e}]} (heures de début réelles des jobs).
- ops: cellWindow/axisPos/shiftStyle/blockStyle, rendu .tl/.tl-shift/.tl-blk + tick midi.
- démo 8 juin: modèles Matinal 7-15 + Décalé 9-17, techs alignés (7→13.8, 9→18.6 surbooké).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Chaque cellule tech×jour avec un shift affiche, sous le chip (J8), une mini-barre + % colorés
(vert <70, orange 70-99, rouge >=100 surbooké) + infobulle = intervalle du shift + h occupées/h.
Occupation = Σ duration_h des Dispatch Jobs planifiés assignés ce jour ÷ Σ heures du shift.
- hub: occupancyByTechDay(start,days) + GET /roster/occupancy → map 'TECH|date': heures.
- ops api: getOccupancy ; PlanificationPage: occCells (computed), cellOcc/occColor/cellInterval,
rendu barre + q-tooltip, chargé dans loadStats. Données démo semaine 8 juin (45/85/120%).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bug: le copilote comprenait l'absence mais n'agissait pas → aucun impact. Causes:
1) prompt « par défaut analyse » → ne déclenchait pas l'action ;
2) marquer une absence n'excluait PAS des créneaux (techGaps ne testait que le statut
global En pause, pas les Tech Availability approuvées par jour) ;
3) loadBookingData calculait unavail mais ne le retournait pas (oubli) → garde inerte.
Fixes:
- roster.js: loadBookingData inclut unavail (buildUnavailability = En pause + absence_from/until
+ Tech Availability approuvées) ; techGaps exclut le tech absent ce jour-là ; export bookingSlots/fetchTemplates.
- roster-assistant.js: nouvel outil gerer_absence = crée l'absence (par jour) + trouve les RDV
impactés + RÉASSIGNE auto à un autre tech libre du même créneau (IROPS), renvoie les
« à reporter ». Nouvel outil ajouter_disponibilite (tech à l'acte ouvre un créneau). Prompt
orienté ACTION (signaler une absence = instruction d'exécution).
- Validé prod (lab): copilote crée l'absence ✓, booking exclut le tech absent (109→104) ✓,
rematch DJ-…→Antoine même créneau ✓ ; données de test nettoyées.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- getBookingPolicy() (sous-objet 'booking' du fichier policy): lead_hours, day_start/end,
days_offered, horizon_days, max_per_day, hold_minutes. Appliquée dans bookingSlots →
cohérent pour /book + vue agent + fit. ignorePolicy au moment de confirmer (slot encore libre).
- Holds en mémoire (Map TTL): /roster/book/hold {date,start,minutes|release} → fenêtre retirée
des dispos des autres pendant le hold; libérée à la confirmation. Évite le double-booking
pendant qu'un agent/client choisit.
- roster-assistant: DEFAULT_POLICY.booking + booking_fields/weekdays (descripteurs UI), fusion fine.
- Testé: plage 10-14h filtre bien; hold 10:00 dispo 12→11.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Avant: liste plate jj/mm peu lisible. Maintenant: bandeau de semaine ('Semaine du 8 – 14 juin'),
en-têtes de jour complets (lundi 8 juin), sections Matin/Après-midi. Sélection 1-3 préférences inchangée.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Avant: le job gardait scheduled_date → page /book court-circuitait en 'déjà confirmé' sans options.
Maintenant un report = vrai retour au pool → le client choisit un nouveau créneau (vérifié: 50 créneaux proposés, SMS livré).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>