Demande user : garder l'historique du véhicule au moment du hit géofence — rester exact
même si le tech change de véhicule un autre jour. Les événements (en_route/on_site/departed)
enregistrent désormais deviceId+deviceName ; rec.device = véhicule du passage. runScan
(live) fige le device du fix courant ; reconcileFromTrack (rejeu trace) fige le device
rejoué ce jour-là. timeline() renvoie device ; GeofenceTimeline affiche « Véhicule : … ».
Vérifié : hub OK, timeline() expose device (null sur les 351 events antérieurs = normal ;
capturé pour les prochains passages). Build spa OK, md5 match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Demande user : afficher les véhicules Traccar avec les initiales du TECHNICIEN assigné,
et ne retomber sur les initiales du device Traccar QUE si aucun tech n'est assigné au véhicule.
- hub /roster/tech-positions : part désormais de TOUS les devices Traccar (pas seulement ceux
liés à un tech). Pour chaque device localisé : techName si un tech y est assigné (via
traccar_device_id), sinon deviceName ; renvoie techName + deviceName + name résolu (tech>device).
Exclut l'île nulle (0,0 = jamais localisé). Live : 23 positions (14 tech, 9 device-only).
- RouteMap : libellé marqueur = initials(techName || deviceName || name) (tech prioritaire).
- PlanificationPage dayLivePositions : les véhicules SANS tech s'affichent aussi (gris),
filtrés par visibilité seulement s'ils ont un tech.
PlanificationPage.vue = co-édité → DÉPLOYÉ (build) mais NON commité (hunk à isoler).
Vérifié : endpoint live OK ; build spa OK, md5 match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug (capture LEG-254850 « Patrick Leclair », qui a DÉMÉNAGÉ Saint-Louis→Franklin) :
l'adresse affichée + le pin carte pointaient l'ANCIENNE propriété. Cause : buildJob
prenait address+coords de la DELIVERY legacy (t.dv_*, périmée), alors que
service_location est apparié par la VILLE du ticket (= l'adresse pour laquelle le
billet a été créé).
Fix : on écrase address+coords par la Service Location LIÉE UNIQUEMENT si la delivery
correspond à une AUTRE Service Location DU MÊME CLIENT (déménagement/multi-adresses).
Un SITE EXTERNE (ex. cabane à sucre) ne matche aucune SL → on garde l'adresse du billet.
+ postal_code au fetch SL. Discriminant via _slCache (déjà peuplé, pas de re-fetch).
Backfill prod : 519 jobs open/assigned → 15 « propre-autre-adresse » corrigés (pin <1 km
d'une autre SL du client), 26 sites externes gardés, ~31 même-ville laissés (précision).
LEG-254850 corrigé à part (→ Franklin).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Le copilote STAFF peut répondre à toute question client. 5 lecteurs ajoutés
(audience:staff), réutilisant les getters du répondeur client (agent.execTool) :
- customer_overview : résumé 360 (identité + SOLDE + abonnements + tickets ouverts)
- customer_balance : solde du compte + factures récentes
- customer_subscriptions / customer_equipment
- customer_history : tickets passés → « un problème est-il déjà survenu ? »
Client résolu via find_customer (id C-xxxx) ; prompt mis à jour (règle QUESTIONS CLIENT).
Corrige aussi un bug LATENT : geminiChat n'avait pas reasoningEffort:'none' →
avec 35 outils le « thinking » de gemini-2.5-flash mangeait maxTokens et renvoyait
une réponse VIDE (« Aucune action détectée »). Thinking coupé + maxTokens 900→1200.
Vérifié live : « solde de C-LPB4 » → « 0 $, aucune facture impayée » ; « résume le
compte » → 360 (Louis-Paul Bourdon, solde, forfaits, 4 tickets) ; « un problème
déjà arrivé ? » → « 7 problèmes, dont 4 ouverts ». Régression OK (check_service, suspend).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remplace le 2e champ « rechercher un client » du chat par une liste d'options
que l'ASSISTANT propose DANS le fil (comme un menu de désambiguïsation). Plus
naturel, plus simple : on écrit une seule chose, et si le client est ambigu
l'assistant liste les candidats probables — un clic les envoie comme tour suivant.
Backend (staff-agent.js + agent-tools.json) :
- lecteur find_customer(query) → CANDIDATS via /collab/customer-search (réutilise
l'endpoint de l'ancien typeahead). 1 candidat → l'assistant prend l'id ; >1 →
il demande « lequel ? » sans réénumérer (l'UI affiche la liste).
- execToolPlan capte les candidats (find_customer + check_service ambigu) dans
ctx.options ; plan() renvoie { options } ; prompt : règle « identifier le client ».
Frontend (OrchestratorDialog.vue + MainLayout.vue) :
- retire le q-select entitySearch (+ la prop et le binding MainLayout) ; composer
= un seul champ. Rend d.options en q-list cliquable ; pickOption envoie
« label (id) » comme tour suivant ; options effacées à l'envoi/au reset.
Vérifié live (dev→hub prod) : « redémarre le modem du client Tremblay » → 1 champ,
8 options cliquables + « lequel ? » ; clic « Tremblay (Filtr-Aqua) Sylvain » →
tour « … (C-SYLVT…) » envoyé, options effacées, l'assistant continue ; 0 erreur console.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Louis : un reboot coupe l'accès du client au moins 3 min (pas ~2). L'aperçu de
device_action(reboot) le dit désormais clairement et reboot/speed passent en
severity 'high' (l'ONU redémarre = vraie coupure), aux 2 chemins (TR-069 tech-3
+ SNMP tech-2). Rappel : le reboot ne part JAMAIS sans clic Confirmer humain.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Le reboot d'olt-ops = SNMP Raisecom (tech-2) seulement → pour un ONU tech-3
(TPLG, la plus grosse flotte) device_action(reboot) tombait en « non exécutable »
et l'assistant proposait un déplacement. Ajout : devices.rebootBySerial(serial)
(résout le n° de série → _id ACS via fetchDeviceDetails, pose une tâche GenieACS
{name:reboot} + connection_request). device_action route le reboot vers TR-069
(params.via=tr069) quand olt-ops ne peut pas ; sinon SNMP (tech-2) inchangé.
Toujours staged → confirmation humaine avant le moindre redémarrage.
Vérifié live (staging, aucun reboot tiré) : « redémarre le modem de C-LPB4 »
→ device_action staged « Redémarrer le modem (TR-069) — ONU TPLGC4160688 ».
Résolution série→ACS validée (TPLGC4160688 → E4FAC4-Device2-…). 1er reboot réel
= déclenché par l'utilisateur via le bouton Confirmer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
techGaps prend un bufferH optionnel : rétrécit chaque trou d'un tampon de
trajet (15 min) aux bords qui touchent un JOB existant (pas aux bords début/
fin de quart) → on n'offre plus au client un RDV collé entre 2 interventions.
Appliqué à bookingSlots (offres client ; 0 si ignorePolicy = confirm/interne)
et fitBooking (Option B : la dispo proposée par le client doit laisser le
trajet). firstFitStart (drag-drop) garde bufferH=0 → placement inchangé.
Vérifié live : /book garde 20 offres agrégées ; 694 créneaux/tech avec tampon
vs 850 sans → les créneaux serrés entre 2 jobs ne sont plus proposés.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Le trajet entre jobs était compté comme du temps LIBRE → un tech dont la
journée est pleine de route entre villes (ex. Saint-Anicet ↔ Huntingdon)
paraissait « avoir de la place ». Corrigé aux 2 endroits :
- techOccupancy (bandes « Trouver un créneau » + resource_availability de
l'assistant) : free_h = quart − occupé − TRAJET. Le trajet est estimé le
long de la tournée (jobs pointés dans l'ordre horaire puis jobs sans heure
à la suite — sinon une journée de jobs legacy sans heure paraît sans route),
borné au temps restant (jamais négatif). Nouveaux champs travel_h (jour+total).
- suggestSlots : réserve le trajet RÉEL — arriver depuis le job précédent ET
repartir vers le job suivant (avant : buffer fixe 15 min à l'avant seulement,
le travelMin calculé n'était qu'affiché). Bord = début/fin de quart → 0 ;
bord = job → trajet estimé (coords) ou repli 15 min si coords du nouveau job
inconnues. Un créneau n'est offert que si le trou contient arrivée+job+départ.
Helper travelH() partagé (barème existant ≈ dist×1,5 min/km, borné 5–90).
Vérifié live : Houssam Nabout lun 20/07 (5 jobs 2 villes) passe de « 1 h libre /
90% » à « 0 h libre / 100% plein » ; techs à 1 job ou même lieu → trajet 0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Le chat STAFF peut désormais lancer les 7 opérations OLT à distance (Do Stuff)
par langage naturel : reboot · speed · suspend · unsuspend · activate · replace
· remove. Un seul outil device_action(action, serial|customer_id, [profileid],
[new_sn]) → résout l'ONU du client au besoin, APERÇU via olt-ops.plan (effet,
cible, avertissements, can_run), puis exécution via olt-ops.run.
Sécurité (écriture réseau conséquente) :
- staged comme WRITE → aperçu + bouton « Confirmer et exécuter » (jamais auto).
- double-verrou : confirm humain + run({confirm:true}) du module + idempotencyKey
(fixé au plan → un double-clic rejoue la même clé) + acteur journalisé.
- permission reboot_provision (capacité « Équipement » existante, assignable).
- can_run honoré : une action non branchée pour la techno de l'ONU (ex. reboot
tech-3 = TR-069) n'est PAS proposée — l'assistant l'explique.
- prompt : APPELER l'outil EST la proposition (pas de « voulez-vous ? » en texte).
Vérifié live : NL « suspends l'internet de C-LPB4 » → device_action(suspend)
staged, severity high, cible TPLGC4160688/OLT résolue. GET /staff-agent/tools
liste device_action (15 outils).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Complète le diagnostic : nouveau lecteur check_service (customer|query) → état LIVE du service via ticket-collab.serviceStatus
(modem/ONU en ligne/hors ligne, Rx dBm, abonnement) + conseil « correctif à distance vs déplacement ». Prompt : pour un
problème de CONNEXION (pas d'internet / lent / signal / hors ligne), appeler check_service AVANT de proposer un déplacement
— si en ligne → tenter un redémarrage à distance ; si hors ligne + signal faible/nul → déplacement (réparation) justifié ;
annoncer le constat (en ligne/hors ligne · Rx) avant de recommander. Évite les camions inutiles.
Vérifié : check_service C-LPB4 → « Hors ligne » (tr069) ; outil listé dans /staff-agent/tools. Déployé, hub sain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Avant : l'assistant posait une question (« Souhaitez-vous que je crée… ? ») mais le dialogue était mono-coup — impossible
de répondre (pas de fil, chaque envoi repartait de zéro). Maintenant c'est une conversation :
- Backend staff-agent.plan(text, email, history) : accepte les tours précédents (user/assistant, 10 derniers) → contexte ;
POST /staff-agent {text, history}.
- OrchestratorDialog : fil de bulles (user/assistant), le champ se vide après envoi pour RÉPONDRE, historique renvoyé à
chaque tour, bouton « Envoyer » en mode conversation. Le plan (actions à confirmer) suit le dernier tour.
Vérifié : (backend) « vérifier signal C-LPB4 » → diagnostic (réparation, 12 techs/431h) ; « oui, crée » AVEC history →
propose create_job(C-LPB4, Réparation). (UI) 4 bulles [user,assistant,user,assistant], champ vidé, action « Créer un job »
affichée, 0 erreur console. Déployé.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Complète la boucle diagnostic (raison→compétence→RESSOURCE dispo→créneau) demandée : nouveau lecteur resource_availability
(skill[, after_date, days]) → nb de techs QUALIFIÉS + heures libres sur la période (via dispatch.techOccupancy). Prompt mis
à jour : après resolve_skill, appeler resource_availability (combien dispo) puis find_slot (créneaux) avant de proposer.
Vérifié : skill=réparation → 12 techs, 431 h libres/7j ; outil listé dans /staff-agent/tools. Déployé, hub sain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Suite au « Failed to create dispatch job » : au-delà du fix nextJobRef (6022cdf), on rend l'assistant plus INTERACTIF
et on capte l'adresse tapée :
- create_job accepte désormais 'address' (texte libre) → transmis à agentCreateDispatchJob → stocké sur le Dispatch Job
(avant, l'adresse dictée était perdue si pas de lieu de service lié). Vérifié : job.address = « 2338 rue Ste-Clotilde ».
- Nouveau lecteur resolve_skill (raison → compétence(s) requise(s) via skill-resolver) → l'agent DIAGNOSTIQUE la ressource
nécessaire. Vérifié : « voir signal fibre » → installation.
- Prompt système : pour une intervention, procéder par étapes — (1) cerner la raison (question si vague), (2) resolve_skill,
(3) find_slot (dispo réelle), (4) SEULEMENT ensuite proposer create_job/assign_tech/proposer_rdv_client + résumer le
diagnostic avant l'action. L'agent ne saute plus à la création.
Déployé (3 fichiers), hub sain, resolve_skill listé dans /staff-agent/tools.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bug : nextJobRef() faisait 'for..of r.data' alors que la liste ERPNext est sous r.data.data (comme partout ailleurs
dans dispatch.js). r.data étant un objet → 'object is not iterable' → catch → repli ymd+'-001' à CHAQUE appel. Donc le
1er job du jour passe, les suivants COLLISIONNENT sur le même ticket_id (autoname) → POST rejeté → createDispatchJob
lève 'Failed to create dispatch job'. Touchait TOUTE création via createDispatchJob (agent NL create_job, /dispatch/
create-job, chaînes d'installation), pas seulement l'assistant.
Fix : itérer r.data.data. Vérifié : 2 créations consécutives → refs 20260719-001 puis -002 (distincts, incrémentés),
plus d'erreur 'not iterable'. Déployé, hub sain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Réconciliation de l'audit : isole et commite UNIQUEMENT mon travail déployé-non-commité dans 7 fichiers co-édités,
sans toucher au working tree (donc sans capturer le travail en cours des autres sessions, qui reste non commité) :
- server.js : SSE event 'ready' + retry:3000 ; routes /olt/onu/plan|run + /olt/wifi-clients.
- conversation.js : endpoint /conversations/my-inbox-counts (accueil : en attente / suivis / en retard).
- useConversations.js : armSSE (reconnexion+resync+repli poll 25s) + cleanup.
- PlanificationPage.vue : chips géofence (Lane 1c) + sélecteur « Générer l'horaire » (Move 3) + isLate (arrivée en retard).
- SettingsPage.vue : section « Thème & couleurs » (ThemeEditor).
- IssueDetail.vue + ConversationPanel.vue : props source-issue/customer/service-location/phone (« Proposer au client »).
EXCLUS (restent non commités, propriété d'autres sessions) : /service-location, /conversations/msg-visibility, retrait
champ Mapbox, fix conv-message 'belongs', hiérarchie parent_incident, JobMediaModule, etc. Vérifié : 0 marqueur étranger
dans le staged. Tout est déjà LIVE ; ceci n'aligne que le dépôt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Avant : une fois confirmé, le lien /book était un cul-de-sac (« déjà confirmé »). Maintenant le client peut, depuis SON lien :
- « Modifier mon rendez-vous » → ré-ouvre le sélecteur (mêmes créneaux) → nouveau choix écrase l'ancien.
- « Annuler mon rendez-vous » (2 clics de sécurité) → POST /book/api/cancel : libère le créneau, repasse le job en
« À reporter » (open, sans tech/heure) pour recontact ; le token reste valide (« Reprendre un rendez-vous »).
Réduit les appels au support pour reporter/annuler. Vérifié : les 3 états (confirmé→Modifier→sélecteur ; annuler 2 clics→
annulé→reprendre), 0 erreur console ; live à msg.gigafibre.ca/book, /book/api/cancel token-gated (404 sans token). Hub sain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
À la confirmation d'un créneau (confirmWindow, donc Option A « choisir » ET Option B « proposer 3 » qui aboutit), on
texte le client : « votre rendez-vous technique est confirmé : <date> à <heure> (technicien X) ». Déclenché par SA
sélection (transactionnel), fire-and-forget (ne bloque pas la réponse). Complète le .ics — le client a une trace immédiate.
Téléphone résolu depuis Customer.mobile_no. Déployé, hub sain ; pas de test d'envoi réel (client réel).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Après confirmation d'un créneau (Option A), le client obtient un bouton .ics (Apple/Google/Outlook) pré-rempli :
date/heure + durée, « Rendez-vous Gigafibre — <service> », technicien en description, adresse en LOCATION. Réduit les
oublis/no-shows côté client. Heure flottante (fuseau de l'appareil = Québec). Vérifié : flux confirmer→lien, .ics
bien formé (DTSTART/DTEND/SUMMARY), 0 erreur console ; live à msg.gigafibre.ca/book. Hub redémarré, sain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Step 2/3. Avant : zéro créneau en ligne → cul-de-sac « nous vous contacterons ». Maintenant : le client saisit
jusqu'à 3 disponibilités libres (date + Matin/Après-midi/Soir) → soumises en mode:rank → fitBooking tente de placer,
sinon enregistre en 'Proposé' pour le répartiteur. Le client n'est jamais coincé.
Vérifié : repli (mock empty) rend le formulaire + soumet OK ; aucune régression avec créneaux (8 tuiles + bascule 2 modes) ;
0 erreur console ; live à msg.gigafibre.ca/book (marqueurs présents). Hub redémarré, sain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Step 1/3 des améliorations. AvailabilityByReason (dialogue partagé, monté dans le ticket ET la conversation) n'offrait
que « Trouver un créneau » (assigner soi-même). Ajout du 2e chemin — laisser le CLIENT choisir — là où le diagnostic se fait :
- Bouton « Proposer au client » → api proposeAppointment → hub /roster/book/propose : résout OU **crée** un Dispatch Job
ouvert (via createDispatchJob) puis envoie le lien /book par texto. Toast avec « Copier le lien » si pas de SMS.
- roster.js : /roster/book/propose crée le job ouvert si aucun (client ou sujet requis) + pose required_skill.
- Props de contexte passées par les 2 panneaux (source_issue/customer/service_location/phone) — édition additive des
panneaux co-édités = NON commitée ici (déployée via le bundle).
Vérifié : endpoint 404 attendu sans contexte ; « Proposer au client » compilé dans le bundle ; build OK, leak-clean, déployé.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pont agent→client vers la page /book livrée : au lieu d'assigner soi-même, le staff laisse le CLIENT choisir.
- Hub POST /roster/book/propose : résout le job ({job}|{ticket}|{customer}) → assure booking_token → envoie le lien
/book par texto (formulation « nouveau RDV »). 404 « créez d'abord l'intervention » si aucun job ouvert.
- Outil NL proposer_rdv_client (agent-tools.json + WRITES staff-agent.js, plan→confirm→run) → l'agent le déclenche
depuis ⌘K en langage naturel. staff-agent était DÉJÀ déployé (assign_tech direct + find_slot existaient déjà).
Vérifié live : outil listé dans /staff-agent/tools ; /roster/book/propose répond (404 attendu sans job).
Reste (différé, nécessite édition des parents co-édités → deploy-only) : bouton « Proposer au client » in-ticket/conversation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Réécrit BOOK_HTML : marque Gigafibre (vert #00C853, esprit gigafibre.ca) au lieu du bleu générique, mobile-first, 2 modes
via bascule segmentée —
- « Choisir un créneau » (Option A) : sélection unique → hold EXCLUSIF 5 min avec compte à rebours visible → confirmer ;
à l'expiration le créneau redevient disponible (message + rechargement).
- « Proposer mes dispos » (Option B) : jusqu'à 3 créneaux classés → 1er possible confirmé, sinon enregistrés en 'Proposé'.
Consomme les endpoints publics /book/api/{options,hold,submit} (déjà déployés). Vérifié : rendu + les 2 flux (mock),
puis live à msg.gigafibre.ca/book (marqueurs présents). Hub redémarré, sain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Complète le backend RDV 2 voies (les 2 modes ont maintenant leur chemin, tous gardés F + hold 5 min) :
- POST /book/api/hold : hold EXCLUSIF 5 min à la sélection d'un créneau (Option A) ; release possible ; retour au
pool à l'expiration (bookingSlots re-soustrait les holds vivants).
- POST /book/api/submit mode:'pick' : le client confirme le créneau choisi → confirmWindow (garde F : si le ticket
est déjà assigné à un autre tech dans F, enregistré en 'Proposé' au lieu d'écraser).
- (mode:'rank' = Option B « le client propose 3 dispos » inchangé : fitBooking → confirmWindow.)
Routes PUBLIC via le préfixe /book existant (aucun changement server.js). Vérifié live : token bidon → 404 lien invalide.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Découverte du mapping : la confirmation de RDV (confirmWindow + POST /roster/book/confirm) écrivait assigned_tech
SANS la garde « déjà assigné dans F » que /roster/assign-job applique → une confirmation client (ou staff) pouvait
écraser une assignation faite manuellement dans F. Même classe que le bug double-assignation.
- fConflict(legacyId, techId) : garde PARTAGÉE extraite (ticketAssignState), réutilisée par les 3 chemins d'écriture
(assign-job refactorisé pour l'utiliser → plus de dérive).
- confirmWindow (chemin CLIENT, pas de force) : si conflit F, N'ÉCRASE PAS — enregistre le créneau choisi
(booking_status Proposé) et laisse le répartiteur confirmer le bon tech. Message client « nous confirmerons sous peu ».
- POST /roster/book/confirm (chemin STAFF) : 409 conflict sauf force=true → l'OPS réaffiche « déjà assigné dans F à X ».
- hold_minutes défaut 10 → 5 (décision Louis) : réservation exclusive 5 min → retour au pool → indicatif (revalidé
à la confirmation via la garde ci-dessus).
Déployé (hub redémarré, sain, SSE clients se reconnectent via 187115c/89f7efc).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Avant : on associait un GPS à un tech UNIQUEMENT depuis le popover du tech (vue Semaine). Ajout du sens inverse demandé :
partir de l'APPAREIL. Hub roster.js :
- GET /roster/traccar-devices : roster enrichi (appareil + tech(s) assigné(s) + unassigned + duplicate + dernier signal),
croise Traccar getDevices × Dispatch Technician.traccar_device_id. Vérifié live (45 appareils, ex. #4 → Benjamin Djanpou).
- POST /roster/traccar-device-assign {device_id,tech_id} : assigne un appareil→tech en LIBÉRANT d'abord tout autre tech qui
le détient (le champ vit sur le tech → sinon 2 techs pointeraient le même device ; le doublon est aussi signalé dans la vue).
SPA : GpsDevicesDialog.vue (q-table statut/appareil/dernier signal/technicien via TechSelect + filtre « non associés »),
ouvert par un bouton « Appareils GPS » dans la barre Tournées de Planification. api/roster : listTraccarDevicesRoster + assignTraccarDevice.
PlanificationPage (bouton + mount) = co-édité → déployé, non commité.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Précision coords (le levier amont unique : alimente géofence + matrice OSRM + solveur, tous lus à chaud) :
- fibreByDelivery(deliveryId) : AUTORITÉ #0 = le DROP d'installation exact via ticket.delivery_id → service → fibre.*_service_id
(placemark d'infra si lié, sinon coord fibre). Câblé dans buildJob (avant resolveDevCoords) ET geolocateJobs (refreshFibre,
seuil de réécriture 30 m car drop exact). Le tick horaire l'applique tout seul désormais.
- BACKFILL exécuté : 15 jobs actifs corrigés (14 fibre_service_placemarks) — dont LEG-247012 (7,5 km !) et LEG-254147 (14 km) qui
étaient grossièrement mal géocodés → snappés au drop exact. Idempotent (re-run 0).
Hardening auto-dispatch (audit) :
- osrmMatrix : un nœud SANS coords n'est plus à 0 min de tout (« adjacent gratuit » → insertion n'importe où) mais à 180 min
(« loin/inconnu ») → le solveur ne chaîne plus gratuitement les jobs sans coords au milieu des vrais arrêts.
Restent des jobs sans source coord (ex. lots camping Lac des Pins sans ligne fibre) = data gap F, pas résolvable ici.
Suivi recommandé (non fait, flag) : valider bornes QC sur les chemins de LECTURE (pas que l'ingestion) ; retirer les défauts
tech silencieux (Montréal/Ste-Clotilde) ; sortir les jobs sans coords du solveur vers le pool « à situer » ; invalider le cache
matrice SPA quand les coords changent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diagnostic (Nathan Morrisseau 2026-07-18) : 3 jobs où le tech était à 8-13m pendant 37-166 min sont restés « En route »,
jamais « Arrivé ». Cause : le scan live n'accroche « on_site » que si le hub tourne 3 min EN CONTINU avec le tech dans le
rayon, et le compteur de séjour (enterMs) était en mémoire, persisté SEULEMENT à un changement d'état → tout redémarrage du
hub le remettait à zéro. 18/07 = 0 arrivée pour TOUTE la flotte (seul jour), alors que « en route » (1 tick) s'accrochait.
Fixes (les 3 demandés) :
- FIX 1 dwell persisté : runScan marque le store 'dirty' quand enterMs change → survit aux redémarrages.
- FIX 2 passe TRACE rétrospective : reconcileFromTrack(date) rejoue l'historique GPS Traccar complet du jour (traccar.getHistory
ajouté) et DÉRIVE arrivée/départ → insensible aux redémarrages/ticks manqués/bursts. Planifiée /15 min sur le jour courant
(scan live conservé pour la réactivité UI) + route GET /roster/geofence-reconcile?date=&dry=1 (backfill/diag).
- FIX 3 rayon 150→250m (+ sortie 230→350m), aligné sur l'arrivée-auto de l'app terrain → couvre le géocodage rural imprécis.
Vérifié live : backfill 18/07 → LEG-254929/255009/255042 = Arrivé+Reparti (source track), idempotent (re-run 0/0), today OK.
Restent en_route : LEG-254770 (187m, séjour <3min) + LEG-255029 (579m, coords à corriger) = imprécision géocodage, pas géofence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- field-app.html (app tech) : carte Mapbox GL → MapLibre GL + tuiles raster OSM (aucun jeton). C'était le DERNIER Mapbox vivant.
- NOUVEAU « 🚀 Meilleur trajet du jour (trafic en direct) » : ordre optimal des arrêts via NOTRE OSRM /table + plus-proche-voisin (le plugin /trip n'est pas compilé), puis handoff à Google Maps multi-arrêts → trafic EN DIRECT gratuit (OSM seul n'a pas de trafic). Endpoint hub GET /field/route?t=&from=lat,lon.
- config/erpnext.js MAPBOX_TOKEN vidé + JobEditModal (module mort) : vignette statique api.mapbox.com → lien OSM. Bundle SPA désormais SANS jeton Mapbox ni api.mapbox.com (vérifié).
- serveFieldApp : retrait de l'injection du jeton Mapbox.
Vérifié live : field-app rendu = MapLibre (0 api.mapbox.com) ; OSRM /table Ok ; ordre A→C→B correct depuis origine ouest ; /field/route 200. Reste (co-édité, déployé non-commité) : tooltips PlanificationPage « Mapbox »→« OSRM/OSM » + retrait carte réglages Mapbox.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- extrait n8nRequest(verb,url,body,timeout) partagé par dostuff() et wifiClients() (fin de la double implémentation https-GET-with-body)
- effets déplacés dans ACTIONS[action].effect → supprime l'échelle de if dans plan()
Comportement préservé (vérifié live : plan tech-3 suspend identique, gardes run inchangées). Déployé prod.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Native detail sections (fin de la grille+desk pour les derniers doctypes cliquables de la fiche client) :
- PaymentMethodDetail / PaymentArrangementDetail / VoipLineDetail — LECTURE SEULE (aucune charge/paiement déclenché ; mot de passe SIP jamais affiché). Enregistrés dans SECTION_MAP.
Phase 2 G3 (parité écriture fibre tech-2 Raisecom, ~10 500 ONUs) :
- olt-ops.js : écritures SNMP natives (net-snmp) suspend/rétablir (VLAN 40↔666) / redémarrer / vitesse (line-profile), OIDs EXTRAITS DU DRIVER F raisecom_rcmg.php (vérifiés à la source) + SAVE 8886.1.2.1.1.0 après chaque set. Encodages olt_id (slot*1e7+port*1e5+ontid) et onu_id ((slot+32)*8388608+port*65536+ontid) — round-trip vérifié. Repli coords ONU depuis le poller SNMP live (olt-snmp.getOnuBySerial) car les coords Raisecom ne sont pas dans Service Equipment. Gaté : env OLT_RW_COMMUNITY requis (absent → run refuse proprement, 0 set), confirm+idempotencyKey obligatoires. Vérifié live : plan tech-2 refuse sans coords/RW ; run refuse ; 0 écriture réseau tirée.
- OnuActionsPanel : ajout « Redémarrer ».
Déployé prod (hub olt-ops + SPA). DetailModal SECTION_MAP (5 entrées ajoutées) = déployé mais co-édité → non commité.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- capacityByDay compte les jobs assignés SANS heure (untimed_used_h au prorata AM/PM/Soir)
- /roster/assign-job : garde 409 si le ticket F est déjà à un autre vrai tech (force pour écraser) ; SPA = dialog confirm
- mirrorAssign : start_time depuis F due_time (am/pm) + fix due_date (cur.→r.) + backfill fill-only dans watchLegacy (97% des tickets F = 'day' → le spread capacité est le fix porteur)
- pushAssignments : garde anti-clobber (conflits F skippés, force=1) + compteur conflicts
- ticket-collab : courriels de notification → deep link OPS /#/tickets?open= (fini le desk) ; TicketsPage lit ?open= ; Dashboard ouvre le ticket précis
- DetailModal + SupplierInvoices : lien desk gaté can('manage_settings')
- NOUVEAU JobMediaModule (photos tech géo-vérifiées + journal terrain) monté dans IssueDetail onsite + volet Planif ; hub /roster/job-media + /field/photo-file signé ; JobThread poll silencieux 45s
- legacy-sync emailList : strip caractères invisibles (soft hyphen U+00AD…) — fixait un 417 InvalidEmailAddress en boucle (compte 15988)
Déployé prod 2026-07-18 (hub restart + SPA /opt/ops-app), sondes live OK.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Suite retour Louis : plus de mode soft/hard (toggle du commit précédent retiré). Un
seul concept — un job/ticket interne qui OCCUPE la timeline (formation · réunion ·
projet · entretien) → dé-priorise le tech au dispatch. La PRIORITÉ est le curseur :
Moyenne/Basse = les urgences/réparations peuvent quand même prendre le tech ; Urgent
(high) = bloc dur, non traversé.
Front : dialogue unique « Bloquer du temps » (Intitulé · journée complète · date ·
début · durée · Priorité [défaut Moyenne] · ticket optionnel), info adaptée à la
priorité. Un seul submit `doBlockTime` → /roster/filler-job. Retiré : bascule de mode,
branche réserve, `roster.reserveTech`. Les 2 points d'entrée (bouton volet réglages +
menu de cellule) → même dialogue, relabellés « Bloquer du temps… ».
Hub (dispatch.js suggestSlots) : la traversée urgence passe du flag `job_type ===
'Réservation'` à la PRIORITÉ — `isSoftBlock` = bloc interne (Interne/Réservation) de
priorité ≤ moyenne → traversé en mode urgent ; high résiste ; les vrais jobs client ne
sont JAMAIS traversés (pas de double-booking). Ajout du champ `priority` au fetch.
(/roster/reserve laissé en place, inutilisé — retrait ultérieur.)
Vérifié : test unitaire pierce (medium/low traversé · high résiste · job client jamais)
+ aperçu (dialogue unique, priorité Moyenne, info souple, pas de toggle). Build OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
extEmailIndex(): index nom->emails depuis ERPNext Employee (company/prefered/
personal/user_id) + Authentik /core/users. provisionIdentities l utilise pour:
- CREATE des identites d ex-staff sans courriel legacy (si une source externe en a un)
- ENRICH: tous les courriels decouverts s accumulent en alias_emails (union,
jamais overwrite) -> multi-courriels par personne, resolvable par chaque alias
- skip des comptes systeme (ticket dispatch, Gestion Inventaire, Tech Targo)
Applique en prod: +1 lien, +5 identites enrichies d alias (perso+corpo). Idempotent
(re-run 0 ecriture). Les 41 restants = ex-employes absents de toute source ->
mapping manuel via l onglet Identites (liste unresolved du rapport).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
provisionIdentities({dryRun,scope}) : pour chaque staff du ledger, garantit une
identite canonique portant legacy_staff_id (+ tech_id si Dispatch Technician, donc
les admins restent non-tech). LINK (identite existante par courriel/nom -> remplit
legacy_staff_id) ou CREATE (depuis staff si courriel). Idempotent. Route
GET|POST /dispatch/legacy-sync/tech-history/provision-identities?scope=ledger|active.
Applique en prod: +33 identites liees a leur legacy_staff_id.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Le rapport techHistory groupe desormais par IDENTITE (lib/identity resolveIdentity:
staff_id -> nom), calculee A LA LECTURE -> corriger une identite (onglet Settings
Identites) reclasse le ledger AUSSITOT (pas de valeur figee).
- is_tech = l identite a un tech_id : le staff admin (ex. Megane Liaud, sans tech_id)
n est PLUS compte comme technicien.
- Sortie: par assignee {label,email,is_tech,kind,total,from_pool} + liste unresolved
(noms sans identite, ex. prenom seul) a mapper dynamiquement.
- Detail ?tech= accepte cle identite | courriel | tech_id | staff_id | nom.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reconstruit la timeline complete d assignation des tickets depuis le CHANGE LOG
osTicket (ticket_msg: 'assign... change de X a Y') vers une table plate dans la
PG ERPNext (a cote de tabDispatch Job/tabIssue), incluant les tickets fermes.
- techHistoryBackfill(): scan watermarke borne de ticket_msg (I/O previsible),
parse + mapping nom->staff->TECH-<id>, upsert idempotent. Backfill initial + tick
incremental (un seul chemin) -> capte les nouveaux changements (les notres ET ceux de F).
- techHistory({tech}): rapport par tech (detail) ou recap tous techs.
- Routes: GET /dispatch/legacy-sync/tech-history[?tech=], GET|POST .../tech-history/backfill.
- Tick live planifie dans startSync (LEGACY_TECH_HISTORY, defaut on; LEGACY_TECH_HISTORY_MIN=15).
- Detecte 'de Tech Targo a <tech>' = sortie du pool vers un tech (from_pool).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hub (legacy-dispatch-sync.js):
- publishPreview() + GET /dispatch/legacy-sync/publish-preview: apercu unifie
OPS->F (assignation + fermeture + horaire) + avertissements tech non mappe
= la liste des changements avant du bouton. Lecture seule.
- publishJob() + GET|POST /dispatch/legacy-sync/publish-job: apply par job
(GET=plan dry-run, POST=applique) via ops_reassign.php.
- staleTickets() + GET /dispatch/legacy-sync/stale: tickets ouverts sans
activite >=N j (natif, remplace le courriel stale de F); scope buckets/maxAge en SQL.
- staleNudge() + GET|POST /stale-nudge + digest planifie opt-in
(LEGACY_STALE_NUDGE), anti-spam persiste, nudge via alertWebhook.
Bridge F (ops_reassign.php):
- action schedule: write-back due_date/due_time (horaire OPS->F).
Verifie en prod (lecture seule + dry-run; aucune ecriture F reelle tiree).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Le hub deploye (/opt/targo-hub) avait derive du depot (deploiements scp
directs non commites). Rapproche la branche de la realite deployee AVANT
d empiler le nouveau travail:
- legacy-dispatch-sync.js: liens natifs source_issue/depends_on/parent_incident
+ backfills (deployes, jamais commites)
- ops_reassign.php: action post (note/reponse au fil, deployee)
Aucun secret (creds via ops_secret.php). Fichiers = copie exacte du deploye.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
techOccupancy et getTechsWithLoad récupéraient les Dispatch Technician avec
limit_page_length=50 alors que l'effectif est de 56. Les techs au-delà du 50e
(ordre ERPNext) étaient donc ABSENTS de l'occupation → dans le calendrier mois
de leur horaire (TechScheduleDialog), c.hasShift est piloté UNIQUEMENT par
l'occupation (shiftMap), donc leurs quarts créés en Semaine/Jour n'y
apparaissaient jamais (tout gris) — ex. Philippe Bourdon & co., alors que Simon
Clot-Gagnon (dans les 50) s'affichait bien. Même cap = mêmes techs invisibles
au dispatch/ranking (getTechsWithLoad). Cap porté à 500 (comme les requêtes
jobs/disponibilités voisines).
Confirmé live : roster/technicians = 56 (Philippe présent) vs dispatch/occupancy
= 45 sans Philippe. Les Shift Assignment existaient (fetchés limit 2000) mais ne
nourrissent que le menu de cellule, pas la barre d'occupation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
POST /auth/staff/sync-identities upserts a unified identity per internal
Authentik user, resolving Employee + Dispatch Technician and deriving email
aliases from the company email. Resolve-first: merges into an existing identity's
key and never overwrites a manual label/tech/alias (fixes Louis-Paul, whose
login louis@targo.ca ≠ employee email louispaul@); employee lookup is
alias-aware so louispaul@ finds HR-EMP-4. "Lier les identités" button in the
Staff console.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Frédérique's duplicate (frederique@targointernet.com) had an old last_login but
was already deactivated, so the "never-logged-in only" rule skipped it and
nothing happened. Now a non-keeper duplicate is deleted when it's inactive OR
never logged in; only a genuinely second active+used account is skipped for
manual resolution. Keeper = active first, then most-recent login, then oldest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
POST /auth/staff/dedupe {email}: keeps the used account (has last_login, else
oldest) and deletes ONLY never-logged-in duplicate Authentik records for that
email — safe (no sessions/history, ERPNext untouched since it's one User per
email). Console shows "Nettoyer le doublon" on flagged rows with a confirm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- resolveEmployeeForEmail + /auth/staff now resolve the Employee through the
unified identity's aliases: louis@targo.ca finds HR-EMP-4 (under
louispaul@targointernet.com). Fixes "no employee linked" for Louis-Paul.
- /auth/staff collapses multiple Authentik accounts with the same email into one
row (username `joseph` + `joseph@...` → one), flags dup_accounts for cleanup.
- provision LINKS an existing Employee (match user_id → company_email → name)
instead of creating a duplicate — fixes the Aurélie HR-EMP-107 doublon risk.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Settings → Staff tab: one row per internal Authentik user with provisioning
state (active, groups, System User / Employee / Dispatch Technician / identity),
flagging orphan (active but no System User → not provisioned, e.g. Karim
Takougang), departed (inactive/identity-departed), ok.
Hub /auth/staff (GET reconciliation) + /auth/staff/provision (groups + System
User + Employee + Tech + identity in one click) + /auth/staff/active
(deactivate/reactivate, non-destructive) + /auth/staff/impact + guarded DELETE
(refuses with 409 if tickets/jobs reference the person → offers deactivate).
Writes admin-gated. identity.js gains programmatic upsert/setActive.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One person = one identity { label, primary_email (SSO login), alias_emails[],
tech_id, active, kind }. Every email + tech ID resolves to it.
Hub: lib/identity.js (NEW, SOURCE UNIQUE) — resolver + secured endpoints
(/identity/map|resolve read; upsert/alias/merge/delete admin-only). server.js
mounts /identity. auth.getDisplayNameByEmail consults the identity label first
(any alias → full name everywhere). Seed: Louis-Paul Bourdon, Louis Morneau.
SPA: composables/useIdentity + api/identity. IssueDetail dedupes assignment by
identity (m'ajouter + search = same person → no double chip), shows full names,
and hides departed people (active:false) from the picker. IdentityManager.vue
(Settings → Identités tab): edit label/aliases/tech link, merge duplicates,
block/reactivate (departed = non-destructive: hidden from pickers, history kept).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add "Bloquer / job générique…" to the timeline cell menu (opens on empty-cell
click or right-click). Opens a small dialog prefilled with the tech + day:
editable title, full-day toggle (= that cell's shift length) or custom
start/duration, standard priority (default Moyenne), type, and a "generate a
linked ticket" toggle.
New hub POST /roster/filler-job creates a standard-priority assigned Dispatch
Job (+ optional ERPNext Issue via source_issue, best-effort so a ticket failure
never blocks the job). Since timed jobs now count toward occupancy, this removes
the tech from regular dispatch — unlike /reserve which stays soft for urgencies.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>