Compare commits

..

35 Commits

Author SHA1 Message Date
louispaulb
815146b70b Planification grille : retrait de l'axe de temps + la bande n'intercepte plus le clic/drag de la cellule
- Axe de temps (hdr-ruler 0..24) retiré des en-têtes de jour (inutile dans la grille ; encombrant).
- La bande d'occupation `.tl` ne stoppe plus la propagation (plus de @mousedown.stop/@click.stop) → le
  clic/drag sur la cellule remonte à `<td>` → onCellClick (menu d'horaire) + onDown/onEnter (sélection) refonctionnent.
- L'éditeur de journée s'ouvre désormais en cliquant un BLOC de job (.tl-blk, @click.stop) — pas toute la
  bande. Donc : clic sur un bloc coloré = éditer la tournée ; clic ailleurs dans la cellule = modifier l'horaire.
  Le tooltip d'occupation (survol) reste actif. Curseur/hover sur les blocs cliquables.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:02:39 -04:00
louispaulb
2665a6a2da Planification éditeur journée : déplacements en pointillés + clic pin = détails + clic ligne = recentre carte
- Barre timeline : l'espace entre 2 jobs (le déplacement) est rendu en POINTILLÉS (au lieu du gris vide),
  tooltip « 🚗 déplacement ». dayTravelSegs() = gaps entre packedDay[i].end et [i+1].start.
- Carte : clic sur un pin → POPUP avec détails du job (n°, sujet, heure, client) ; curseur main au survol.
- Liste : clic sur une ligne → recentre la carte (easeTo zoom 14) sur ce job, en plus d'ouvrir le détail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:50:52 -04:00
louispaulb
307fb4cea5 Planification : carte de journée INTERACTIVE (Mapbox GL) — itinéraire routier réel + zoom/déplacement
Remplace la mini-image statique (segments à vol d'oiseau) par une carte Mapbox GL :
- Itinéraire ROUTIER réel via l'API Directions (geometries=geojson) tracé sur la carte (halo + ligne).
- Pins numérotés dans l'ordre de tournée (cercle coloré par compétence + numéro).
- Navigable : zoom molette + boutons NavigationControl (+/-), déplacement (pan), ajustée au territoire (fitBounds).
- Lifecycle : init à l'ouverture du dialogue (après anim + resize), refresh débouncé au réordonnancement
  (re-trace l'itinéraire), destruction à la fermeture (pas de fuite). mapbox-gl chargé en CDN (comme le Dispatch).
- Avertissement « N sans coords » conservé. Validé : Directions OK (géométrie 392 pts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:44:22 -04:00
louispaulb
d56800805e Pont : import basé sur l'ADRESSE DE SERVICE (delivery), pas la facturation (compte)
Problème de fond : quand ticket.delivery_id ne se joignait pas, le pont retombait sur l'adresse de
FACTURATION du compte (résidence) → sujet + géocodage faux. Or les 124 tickets ouverts-3301 ont tous un
delivery côté compte (98 via ticket.delivery_id, 26 via le compte).

- fetchTargoTickets : résolution delivery robuste — COALESCE(ticket.delivery_id, delivery du compte AVEC
  coords [plus récent], delivery du compte [plus récent]) → l'adresse de SERVICE est toujours disponible.
- buildJob préfère déjà svcAddr (delivery) au billAddr → sujet + géocodage utilisent le service.
- Rafraîchit le `subject` des jobs encore au pool (open + non assigné) pour refléter l'adresse de service
  (corrige les anciens sujets basés sur la facturation) ; ne touche pas un job déjà dispatché.

Résultat (re-sync) : delivery 26→37, no_coords 6→1, 0 erreur. Jobs ouverts affichent l'adresse de service
(« Camping Sandysun · 32 Bellevue », « Lac des pins · 25 Érable », « Franklin · 35 rue Hilltop »…).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:36:17 -04:00
louispaulb
f804c2b49d Pont : géoloc camping (fixe) sur les Dispatch Jobs — l'adresse de service ≠ résidence du client
Symptôme : un job de camping (« Lac des pins | Anton Rimerov ») pointait sur la RÉSIDENCE du client
(428 Rue George, Lasalle = 45.58,-73.73) au lieu du camping. Le pont géocodait l'adresse de compte.

- buildJob : détection camping en PRIORITÉ MAX via le registre camping_registry — signal = sujet (label
  explicite, prioritaire) puis ville/adresse de delivery. Garde-fou : le texte doit contenir « camping » OU
  un mot-clé de LIEU spécifique (évite les faux positifs de patronyme, ex. « Daniel Dauphinais »). coord_src='camping'.
  La branche update fait écraser les coords existantes par le camping (comme delivery). 20 jobs ouverts re-coordonnés.
- camping_dispatch_backfill.sql : corrige les jobs DÉJÀ dispatchés (que le sync ne re-traite plus car le ticket
  legacy a quitté le pool ouvert-3301) → 4 Lac des Pins + 2 SandySun. Anton Rimerov/Germaine Thibert → 45.0624,-73.9113 ✓.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:30:11 -04:00
louispaulb
2b9a863d39 Conformité : repli « centre du code postal / ville » pour les unmatched restants (statut 'area')
Dernier recours quand l'adresse exacte est introuvable : placer le Service Location au CENTROÏDE
(rqa_addresses) de son code postal (préféré) sinon de sa ville → le job apparaît dans le bon secteur.
- Hub : applyAreaFallback() (CTE centroïdes CP/ville, index-friendly) + POST /address/conformity/apply-area.
  Statut 'area', linked_address '≈ centre <CP/ville>'. Hors-QC/junk (absents de rqa_addresses) restent unmatched.
- Ops : carte stat « ≈ Secteur (CP/ville) » + bouton « ≈ Centre CP/ville (reste) » dans la page Conformité.

Exécuté : 317 placés (303 par code postal, 14 par ville) → unmatched 365 → 48 (Toronto/boîtes postales/junk).
État final : validated 16 257 · review 489 · area 317 · unmatched 48 → 99,7 % des services ont une position.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:14:41 -04:00
louispaulb
ec6a317933 Campings : gestion du registre + réapplication self-service depuis Ops
Rend le mécanisme réutilisable (« faire de même pour tous les campings ») :
- Hub (address-conformity.js) : GET /address/conformity/campings (registre + nb lots par camping),
  POST /campings (upsert {keyword,name,address,lat,lon} → applique direct), POST /campings/apply (réappliquer).
  applyCampings() = UPDATE des lots (match ville normalisée) → géoloc fixe du camping.
- Ops (page Conformité adresses) : section « Campings — géoloc de remplacement fixe » : table du registre
  (nom, adresse principale, GPS→Google Maps, nb lots) + formulaire d'ajout (nom/mot-clé/adresse/lat/lon)
  qui ajoute ET applique, + bouton « réappliquer ». api/address.js : campingsList/Upsert/Apply.

→ Pour un nouveau camping : on saisit son adresse principale + GPS, tous ses lots pointent dessus (le tech
navigue au camping). Registre seedé : Lac des Pins, Dauphinais, SandySun, Frontière, Ensoleillé.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:07:15 -04:00
louispaulb
d6453757d1 Campings : registre + géoloc de remplacement FIXE sur tous les lots (1405 corrigés)
Problème : pour un lot de camping, l'adresse du Service Location = souvent la RÉSIDENCE du client, et la
rue interne (ex. « 2 rue Canard, Lac des Pins ») n'est pas dans le RQA → géoloc fausse. Solution data-driven :
table `camping_registry` (mot-clé ville → nom + adresse principale + GPS fixe), coords relevées dans le
legacy delivery. Application : force lat/long du camping sur tous les lots (match ville normalisée), garde
address_line (n° de terrain visible), linked_address = le camping, statut validated.

Appliqué : Lac des Pins 1242 · Dauphinais 134 · SandySun 28 · Frontière 1 (+ Ensoleillé en registre).
Idempotent + ré-applicable. scripts/migration/camping_registry.sql.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:04:03 -04:00
louispaulb
50d877b49f Planification : minimap du territoire (pins+tracé) dans l'éditeur de journée + retrait du sélecteur priorité
- Minimap Mapbox Static ajoutée sous la timeline : pins numérotés dans l'ordre de tournée + tracé reliant
  les arrêts, ajustée auto au territoire des jobs → on VÉRIFIE d'un coup d'œil que chaque arrêt tombe à la
  bonne adresse (un pin mal placé = coord à corriger via Conformité adresses). Clic → carte interactive (Dispatch).
  Indique « N sans coords (absent de la carte) » le cas échéant. Helper encodePolyline (precision 5) pour le tracé.
- Sélecteur de priorité retiré de chaque ligne (défaut « Moyenne » conservé en donnée, géré au Dispatch) → gain de place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:54:53 -04:00
louispaulb
48c2f53d18 Phase 1 (hygiène) : utils partagés + logique pure testable + observabilité erp + 1ers tests
Modularisation / dé-duplication :
- lib/util/text.js : `norm` canonique partagé (remplace 2 ré-implémentations : address-db, legacy-dispatch-sync).
- lib/util/legacy-parse.js : parseurs/mapping PURS du pont (DEPT_JOBTYPE, DUR, jobType, prio, tzDate,
  startTime, coord) extraits hors I/O → testables en isolation, sans pg/mysql/erp.
- legacy-dispatch-sync + address-db importent ces utils (pont vérifié en prod : preview OK, 0 erreur).

Observabilité (sûr, additif, 1 seul point) :
- erp.js create/update/remove : log de l'échec à la SOURCE quand HTTP≥400 → toutes les écritures ERPNext
  silencieuses des 50+ appelants sont désormais tracées, SANS changer aucun flux de contrôle.

Tests (fondation) :
- vitest + npm test ; test/util.test.js : 19 tests verts sur norm + coord(bornes QC)/prio/startTime/jobType/tzDate.
  Tournent sans installer les deps lourdes du hub (modules purs).

Aligné docs/architecture/VISION.md (P0 hygiène). Suite : audit r.ok des appelants financiers (payments/contracts)
en revue supervisée ; CI/CD minimal (Gitea Actions lint+test) ; décomposition des god-files (Phase 2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:36:41 -04:00
louispaulb
f33f7a6309 Optimisation (consolidation helpers address) + doc Vision/modularisation
Optimisation sûre (vérifiée, 0 régression) :
- helpers.js : `cors()` partagé (en-têtes CORS génériques) au lieu de 2 copies locales.
- address-conformity.js : réutilise `pool` (address-db) + `cors` (helpers) au lieu de redéfinir un Pool +
  cors → 1 seul client pg local partagé pour rqa_addresses/fiber.
- address-validate.js : utilise helpers.cors.

docs/architecture/VISION.md (NOUVEAU) — vision + plan de modularisation + roadmap d'optimisation, fondé sur
un audit chiffré (hub 58 modules/23k lignes, Ops 45k lignes, god-files identifiés). Découpe en 9 domaines
(bounded contexts), principe « source de vérité + validation à la saisie + lien stable » (modèle Adresses
généralisé à Client/Device/Service), optimisations P0/P1/P2, métriques de succès. Complète les docs
architecture existants + ENGINEERING_PRACTICES.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:28:54 -04:00
louispaulb
27bbcf43d0 Page Ops « Conformité des adresses » — source de vérité unique pour résoudre le backlog
Plus besoin de re-chercher avec un processus complexe : une page liste les adresses de service non
conformes (review/unmatched) avec leur proposition AQ canonique, et permet de RÉSOUDRE une fois (persisté) :
- Approuver : la proposition AQ devient officielle (validated, coords RQA).
- Corriger : recherche AQ locale (rqa_addresses + fibre) → lier la bonne adresse.
- GPS : saisir/coller lat,long (relevé sur map.targointernet.com qui a la géoloc des unités de camping)
  + lien direct « voir sur la carte » par ligne.
- Rejeter : pas d'adresse civique (boîte postale/hors-QC) → 'no_address'.

Tri par type (camping / civique à corriger / à confirmer / non-adresse) + stats + recherche + pagination.

Backend : lib/address-conformity.js (GET stats|list|candidates, POST resolve) sur le Postgres LOCAL,
routé /address/conformity/* (server.js). Front : api/address.js + pages/AddressConformityPage.vue + route
/conformite-adresses + entrée nav « Conformité adresses » (icône MapPinned, requires view_settings).

État courant : validated 15 195 · review 1 366 · unmatched 550 (camping 540 / civique 333 / non-adresse 93).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:06:55 -04:00
louispaulb
0edf2fe3df SL normalisation passe 2 : récupération GPS par numéro+ville (sans contrainte postale)
scripts/migration/normalize_service_locations_pass2.sql : pour les SL restées review/unmatched après la
passe 1 (code postal+numéro), rematch par NUMÉRO + VILLE (trigram, normalisation St→Saint) + meilleure rue
par similarité ≥0.30 (rue seule, pas ville-incluse pour éviter le gonflement) → upgrade en validated avec
coords RQA réelles. Récupère les SL au code postal erroné/manquant mais ville valide (ex. Athelstan/Ch Ridge).

Résultat : +264 validated (15 195), unmatched 766→550. GPS sur 17 036/17 111 services (99,6%),
dont 15 195 (89%) rooftop AQ. Les 75 sans GPS = boîtes postales/hors-QC/placeholders/sobriquets camping
(pas de position de service réelle). Idempotent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:19:37 -04:00
louispaulb
912359f38b Normalisation AQ des 17k Service Locations (lien RQA + statut, adresse originale préservée)
scripts/migration/normalize_service_locations.sql : rattache chaque Service Location à l'adresse
canonique Adresses Québec (table locale rqa_addresses) par CODE POSTAL + NUMÉRO (clé sélective indexée)
+ meilleure rue par similarité désaccentuée (unaccent + pg_trgm). Les 2 tables étant colocalisées dans
la db ERPNext, tout se fait en SQL (~26 s pour 16 345 lignes, pas d'aller-retour applicatif).

Remplit (sans toucher address_line/city/postal_code = origine préservée → table de translation) :
- aq_address_id = rqa_addresses.id (clé de translation locale ; ⚠ id LOCAL, pas l'uuid AQ officiel absent
  de la table locale)
- linked_address = adresse canonique conforme (address_full)
- address_validation_status = validated (sim≥0.20) | review (sim<0.20, ex. surnoms de camping) | unmatched
- latitude/longitude raffinées seulement si validated

Résultat : validated 14 931 (87%) · review 1 414 (8%) · unmatched 766 (4,5%) → 16 345 liés (95,5%).
Idempotent (ne traite que les lignes 'pending'). Couvre la tâche « backfill normalisation 17k SL ».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:59:21 -04:00
louispaulb
a510ac3848 Recherche d'adresses : base LOCALE (Postgres ERPNext) au lieu du Supabase cloud externe
Le hub n'appelle plus rddrjzptzhypltuzmere.supabase.co. La base RQA + dispo fibre est DÉJÀ locale
dans le Postgres ERPNext (rqa_addresses 5,24M + fiber_availability 21,6k jointes par address_id),
le hub y accède (réseau erpnext_erpnext + module pg).

- NOUVEAU lib/address-db.js : recherche locale. Phase 1 (civique présent) = filtre numero btree +
  mots de rue → ~20-150 ms ; Phase 2 (sans civique) = word_similarity (`<%` indexable GIN) au lieu de
  similarity() plein (24-76 s sur 5,24M !) → ~700 ms, dans une txn SET LOCAL (seuil 0.6 + statement_timeout 4s).
  Renvoie 2 formes : searchLocal (mappée, compat historique) + searchRaw (colonnes brutes de la fonction).
- address-search.js : searchAddresses + searchAddressesRpc délèguent à address-db (plus aucun appel Supabase).
  → onboarding (/address/validate), checkout (/api/address-search) ET le pont (géocodage) passent en LOCAL.
- address-validate.js : endpoints PUBLICS pour le site web (CORS) — POST /rpc/search_addresses (compat
  Supabase RPC, tableau direct) + GET /address/search — servis depuis le PG local (fiber_available inclus).
- server.js : route /rpc/ → address-validate.

Résultat pont (vérifié) : couverture 112/125 (vs 109 via Supabase), rqa_geocode 8→25 (table locale plus
complète + search_text désaccentué), Mapbox 37→23, no_coords 16→13, 0 erreur. Le local est meilleur.
Env hub : ADDR_DB_* dans /opt/targo-hub/.env (défauts erpnext-db-1/_eb65bdc0c4b1b2d6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:46:47 -04:00
louispaulb
b6831a1e48 Pont legacy : géocodage RQA via la recherche TRIGRAM (RPC search_addresses) + garde-fou anti-faux-positif
- address-search.js : expose searchAddressesRpc() → RPC Postgres `search_addresses` (pg_trgm), la MÊME
  recherche que l'autocomplete de disponibilité fibre. Trouve les rues que l'ilike manquait (générique géré
  par la colonne odonyme_recompose_long + phase 2 trigram), priorise les CP J0L/J0S (territoire).
- geocodeRQA() (bridge) bascule de l'ilike vers la RPC. Garde-fou : la phase 2 trigram dérive quand le
  civique est absent du RQA (« 2245 René-Vinet » → « Rue Grenet, Montréal »). On n'accepte un résultat que si
  le civique concorde + un token de nom de rue correspond + (territoire J0L/J0S OU CP/ville legacy concordants).
  Vérifié sur les données réelles : accepte 494 Av Curry / 3055 Routhier / 228 Principale / 61 Jean-François ;
  rejette René-Vinet→Grenet/Panet, chemin Ridge→Ferme, rue West→Perras (bons faux positifs écartés).
- Le faible compte RQA (8) = haute précision (l'ilike comptait 17 dont des faux positifs). Mapbox couvre le
  reste (rues neuves/civiques absents) ; ~109/125 (87 %) coordonnés ; les « aucune » = campings/villes mal écrites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:05:14 -04:00
louispaulb
2c3d7e9814 Pont legacy : coords GPS fiables (delivery→SL→RQA→Mapbox) + routage routier réel (Mapbox Matrix)
Pont (legacy-dispatch-sync.js) :
- Import des coordonnées par job via cascade : table legacy `delivery` (point de service exact,
  JOIN ticket.delivery_id) > Service Location ERPNext > géocodage RQA > géocodage Mapbox.
  Validation bornes Québec (coord()). Couverture 153/172 (89%).
- Géocodage RQA corrigé : retrait du générique de voie (Rue/Rang/Chemin absent de
  odonyme_recompose_normal) + code postal non accolé au terme (sinon ilike ne matche jamais).
- Repli Mapbox geocoding pour rues trop récentes pour le RQA (MAPBOX_TOKEN).
- Backfill + UPGRADE : coords delivery écrasent des coords SL moins précises (jamais l'inverse).
- Comptabilité honnête : vérifie r.ok sur create/update (erp ne throw pas) → errors + error_samples.
- Verrou de sérialisation sync() : tick + runs manuels ne se chevauchent plus (frappe_pg).
- Subject tronqué à 140 (champ Data) → corrige CharacterLengthExceededError sur jobs sans SL.
- Observabilité : coord_src tally + error_samples dans le résumé.

Ops Planification (éditeur de journée) :
- travelBetween() consulte une matrice Mapbox Matrix chargée à l'ouverture (loadDayRoute) →
  temps de trajet ROUTIERS RÉELS ; réordonnancement instantané sans nouvelle requête.
  Repli haversine si Mapbox indispo. Indicateur 🚗 réel vs 📏 vol d'oiseau.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:43:34 -04:00
louispaulb
0298f414ed feat(planif): éditeur journée = planificateur de tournée (heures recalculées, pas d'overlap, RDV verrouillables, détails)
- FIX overlap/ordre : packedDay recalcule les heures depuis la SÉQUENCE (ordre liste) + durées + transport ;
  le timeline et les heures affichées en découlent → réordonner/allonger repousse les suivants, plus d'overlap
- RDV à heure FIXE : verrou par job (🔒, défaut = booking_status 'Confirmé') → garde son heure ; flexibles = enchaînés
- clic sur un job → détails (legacy_detail : dates + description) pour juger urgence/durée ; + tooltip au survol
- save persiste start_time recalculé (+ route_order/priority/duration_h) via reorder-jobs
- hub occupancy renvoie booking_status/legacy_detail/legacy_id ; reorder-jobs accepte start_time
- fix collision fmtH → fmtHM (HH:MM padded)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:56:26 -04:00
louispaulb
bae6771b34 feat(planif): éditeur journée — réordonnancement fiable (flèches+drop) + durée éditable (min) + temps de transport
- 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>
2026-06-06 13:41:28 -04:00
louispaulb
c9fbbdbe9e feat(planif): éditeur de JOURNÉE contextuel au clic sur le progressbar (drag-drop réordonner + retirer)
- 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>
2026-06-06 13:29:55 -04:00
louispaulb
455a66aeb9 refactor(planif): clic progressbar → timeline éditable Dispatch (tech+jour) au lieu du popup maison
- réutilisation max + cohérence : le clic sur le progressbar ouvre le tableau Dispatch focalisé sur
  le tech + le jour cliqué (gotoDispatch(t, d.iso)) = LE timeline éditable (drag-drop réordonner, supprimer/désaffecter)
- retire le popup cellJobsMenu (réordonner/priorité) → règle aussi le chevauchement avec l'infobulle mouseover
- (endpoint /roster/reorder-jobs conservé, réutilisable ; le réordonnancement se fait désormais côté Dispatch)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:19:59 -04:00
louispaulb
b7b7da783b feat(dispatch): date d'ouverture + MàJ en tête du détail ticket (mouseover) pour juger la fermeture
- pont : SELECT date_create+last_update ; legacy_detail préfixé « 🗓 Ouvert <date> · MàJ <date> » puis description.
  Backfill = réécrit legacy_detail si différent (idempotent). Visible dans le mouseover du panneau roster + le détail Dispatch.
  Ex. révélé : LEG-111325 ouvert 2021 jamais touché → candidat fermeture évident.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:07:15 -04:00
louispaulb
c8377a208a fix(dispatch): pont lisait un réplica figé (avril) + auto-fermeture des DJ dont le ticket legacy est closed
- ROOT CAUSE : LEGACY_DB_HOST='legacy-db' = réplica figé au 2026-04-07 → tickets fermés/réassignés depuis
  paraissaient encore ouverts (ex. 244659). Fix infra : .env LEGACY_DB_HOST=10.100.80.100 (DB live, SELECT-only).
- closeResolved() : tout DJ issu du pont (open/assigned/On Hold) dont le ticket legacy est 'closed' → status 'Completed'.
  Appelé à chaque sync + route POST /dispatch/legacy-sync/close-resolved. Résultat 1er run live : 125 ouverts réels,
  102 créés, 44 fermés (dont LEG-244659). NB In Progress non touché.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:03:10 -04:00
louispaulb
a7a428f261 feat(planif): tri du panneau flottant « Jobs à assigner » (groupe/compétence/date/ville/priorité)
- assignSort + assignGroups regroupe/trie selon le mode (défaut = groupe parent-enfant) ;
  ajout du tri par COMPÉTENCE (demandé) + date/ville/priorité (jobCity = dernier segment adresse ou « Ville | » du sujet)
- barre de tri dans le panneau (hors zone de drag) + en-tête de groupe par label ; indentation enfant seulement en mode groupe

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:48:44 -04:00
louispaulb
368e22d57e feat(planif): blocs cellule colorés par compétence + menu réordonner/re-prioriser au clic sur le progressbar
- 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>
2026-06-06 12:44:22 -04:00
louispaulb
5e57b72a8f feat(dispatch): couleur ticket = couleur skill + fil complet du ticket + tri pool (date/ville/priorité)
- 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>
2026-06-06 12:37:45 -04:00
louispaulb
6f709dd8e1 feat(dispatch): réconciliation + heartbeat + détails ticket dans Ops + couleurs panneau roster
#1 « ne rien échapper » :
- /dispatch/legacy-sync/reconcile : compare legacy(3301,open) ↔ Dispatch Jobs → missing/orphan (70↔70, 0/0)
- /dispatch/legacy-sync/status : heartbeat (last_sync + stale) pour Uptime-Kuma

Détails ticket dans Ops (bug signalé) :
- pont extrait le 1er message du fil legacy (stripHtml) → champ legacy_detail (backfill 70)
- store legacyDetail ; RightPanel : section « Détails du ticket » ; tooltip dans le panneau roster

Couleurs panneau roster (bug signalé) :
- /roster/unassigned-jobs renvoie job_type/legacy_dept/priority/legacy_* ; PlanificationPage colore
  chaque carte par type (legacyDeptColor partagé) — bordure gauche

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:50:21 -04:00
louispaulb
8b23367939 docs: cadre best-practices « ne rien échapper » + automatisation closed-loop (tailored stack TARGO)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:34:10 -04:00
louispaulb
4b377eb887 feat(dispatch): bouton « Activer STB (Ministra) » — lien d'activation TV extrait du ticket legacy (Phase 1)
- pont : extrait le lien connect_ministra.php du ticket_msg (déjà posté par le wizard legacy à la vente)
  → champ legacy_activation_url sur le Dispatch Job (backfill des 10 tickets TV). Read-only legacy, aucun 2e
  chemin d'écriture → zéro risque de double-facturation (cf. analyse processus).
- store _mapJob : expose legacyActivationUrl ; RightPanel : bouton violet « 📺 Activer STB (Ministra) »
  (MÊME lien que le tech reçoit, ouvre connect_ministra.php avec tid/did/sid/cr/m intacts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:20:09 -04:00
louispaulb
15976342e4 feat(dispatch): bouton « Répondre dans legacy » (lien reply_ticket.php du tech)
- useHelpers.legacyReplyUrl(job, staffId?) → https://store.targo.ca/targo/reply_ticket.php?ticket=<legacy_ticket_id>&staff=<3301 par défaut = Tech Targo>
- RightPanel : n° ticket legacy cliquable + bouton d'action « 📝 Répondre dans legacy »
- permet au tech d'écrire dans le ticket legacy depuis ERPNext (lecture seule de notre côté)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:30:32 -04:00
louispaulb
67395cd35e feat(dispatch): pastille couleur par type + badge « en retard » dans le pool & le détail
- BottomPanel : pastille couleur (jobColor → type legacy) par ligne + badge  EN RETARD
  sur les groupes de date passée (le pool est déjà groupé/trié par date)
- RightPanel : badge « en retard » près de la date planifiée (hors Completed)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:19:03 -04:00
louispaulb
dadda9fd49 feat(dispatch): coloriage cartes par type legacy + n° ticket dans le détail
- pont : stocke legacy_dept (dept osTicket) sur le Dispatch Job + backfill des 70 existants
- useHelpers.jobColor/legacyDeptColor : palette comme legacy (Installation vert, Réparation or,
  Télé rose, Téléphonie vert pâle, Désinstallation rouge foncé) ; tech en pause = rouge vif prioritaire
- store _mapJob : expose legacyDept + legacyTicketId
- RightPanel : champ « Ticket legacy » (#id · dept) — le client est déjà un lien cliquable vers la fiche
- doc mise à jour

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:05:45 -04:00
louispaulb
70bf25ea84 feat(dispatch): pont legacy(osTicket)→Dispatch Job pour les tickets « Tech Targo »
Tire régulièrement les tickets ouverts assignés au compte « Tech Targo » (staff 3301)
de la DB legacy MariaDB et crée/maj un Dispatch Job ERPNext (pool à répartir).

- lib/legacy-dispatch-sync.js : fetch (status=open AND assign_to=3301) + mapping
  customer (legacy_account_id) / Service Location (coords) / job_type (dept) /
  scheduled_date (epoch→America/Toronto) / start_time (am|pm|HH:MM) / priority
- Idempotent via Custom Field Dispatch Job.legacy_ticket_id (lookup avant create) ;
  ne clobbe pas le travail du répartiteur (maj date seulement si encore open+non assigné)
- SÉQUENTIEL (frappe_pg) ; endpoints GET preview (dry-run) + POST run
- Récurrence opt-in : startSync() au boot, LEGACY_DISPATCH_SYNC=on + _MIN=15
- server.js : route /dispatch/legacy-sync + startSync()
- doc docs/features/legacy-dispatch-bridge.md + index

Mise en service : 70 tickets importés (0 erreur), récurrence 15 min active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 09:33:53 -04:00
louispaulb
c4de33d448 roster(planif): chip compteur « N hors quart » dans la barre (signal hebdo des jobs assignés sans quart publié)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 09:15:16 -04:00
louispaulb
bc5bb06794 roster(planif/dispatch): On-Hold bloqué, alerte hors-quart, deep-link Dispatch, aviser client (#58)
- On Hold : onCellDrop REFUSE d'assigner un job en attente d'un prérequis (notify), reste au panneau (≠ 🔒 visuel)
- Hors quart publié : marqueur ⚠ dans la cellule libre (offShiftJobs/rawCellJobs lit occByTechDay brut) +
  badge « hors quart » dans la timeline ressource — surface les jobs assignés un jour sans quart
- Deep-link : Planif gotoDispatch(tech) → /dispatch?tech=&date= ; DispatchPage lit route.query
  (goToDay(date+T12:00:00) anti-décalage tz + selectTechOnBoard)
- #58 : bouton « Désaffecter + aviser le client » dans le dialogue d'unassign Dispatch →
  roster.notifyReschedule (désassigne serveur + SMS lien /book au mobile du Customer)
- Doc docs/features/roster.md mise à jour (Fait récemment / TODO)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 09:13:17 -04:00
35 changed files with 2252 additions and 109 deletions

View File

@ -0,0 +1,29 @@
/**
* API Conformité des adresses appelle targo-hub /address/conformity/*.
* Source de vérité : Service Location (lien AQ local rqa_addresses). Voir services/targo-hub/lib/address-conformity.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('Address 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('Address API ' + r.status)
return r.json()
}
export const conformityStats = () => jget('/address/conformity/stats')
export const conformityList = (p) => jget('/address/conformity/list?' + new URLSearchParams(p).toString())
export const conformityCandidates = (q) => jget('/address/conformity/candidates?q=' + encodeURIComponent(q))
// action: approve | correct | gps | reject (+ aq_address_id/linked_address/latitude/longitude selon l'action)
export const conformityResolve = (body) => jpost('/address/conformity/resolve', body)
// ── Registre des campings (géoloc de remplacement fixe par camping) ──
export const campingsList = () => jget('/address/conformity/campings')
export const campingsUpsert = (body) => jpost('/address/conformity/campings', body) // {keyword,name,address,latitude,longitude}
export const campingsApply = () => jpost('/address/conformity/campings/apply', {})
// Dernier repli : placer les unmatched restants au centre du code postal (sinon ville) → statut 'area'
export const conformityApplyArea = () => jpost('/address/conformity/apply-area', {})

View File

@ -93,5 +93,11 @@ export const redistributePlan = (plan) => jpost('/roster/skill-impact/redistribu
export const unassignedJobs = () => jget('/roster/unassigned-jobs')
// Assigner un job à un tech (date = case déposée)
export const assignJob = (job, tech, date) => jpost('/roster/assign-job', { job, tech, date })
// Fil complet d'un ticket legacy (description + commentaires/réponses des collaborateurs) — read-only
export const legacyTicketThread = (id) => jget('/dispatch/legacy-sync/ticket-thread?id=' + encodeURIComponent(id))
// Réordonner / re-prioriser les jobs d'un tech×jour : updates = [{ job, route_order, priority? }]
export const reorderJobs = (updates) => jpost('/roster/reorder-jobs', { updates })
// Retirer un job d'un tech (retour au pool non assigné)
export const unassignJobRoster = (job) => jpost('/roster/unassign-job', { job })
// Aviser le client d'un report : désassigne + SMS lien /book — { job, phone?, message? }
export const notifyReschedule = (body) => jpost('/roster/job/notify-reschedule', body)

View File

@ -7,32 +7,42 @@ export function useBottomPanel (store, todayStr, unscheduledJobs, { pushUndo, sm
const bottomPanelH = ref(parseInt(localStorage.getItem('sbv2-bottomH')) || 220)
watch(bottomPanelOpen, v => localStorage.setItem('sbv2-bottomPanel', v ? 'true' : 'false'))
// ── Grouped by date ──────────────────────────────────────────────────────────
// ── Tri / regroupement (date · ville · priorité) ─────────────────────────────
const bottomSort = ref(localStorage.getItem('sbv2-bottomSort') || 'date')
watch(bottomSort, v => localStorage.setItem('sbv2-bottomSort', v))
const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 }
// Ville : 2e segment de l'adresse libre, sinon 1er token du sujet avant « | » (tickets legacy), sinon vide.
function cityOf (job) {
const a = String(job.address || '')
const parts = a.split(',').map(s => s.trim()).filter(Boolean)
if (parts.length >= 2) return parts[1]
const subj = String(job.subject || ''); if (subj.includes('|')) return subj.split('|')[0].trim()
return parts[0] || ''
}
const unassignedGrouped = computed(() => {
const today = todayStr
const sort = bottomSort.value
const jobs = unscheduledJobs.value.slice()
const byDate = (a, b) => { // ordre secondaire : date (aujourd'hui d'abord)
const da = a.scheduledDate || '9999-99-99'; const db = b.scheduledDate || '9999-99-99'
const at = da === today ? 0 : 1; const bt = db === today ? 0 : 1
return at !== bt ? at - bt : da.localeCompare(db)
}
jobs.sort((a, b) => {
const da = a.scheduledDate || '9999-99-99'
const db = b.scheduledDate || '9999-99-99'
const aToday = da === today ? 0 : 1
const bToday = db === today ? 0 : 1
if (aToday !== bToday) return aToday - bToday
if (da !== db) return da.localeCompare(db)
const prio = { high: 0, medium: 1, low: 2 }
return (prio[a.priority] ?? 2) - (prio[b.priority] ?? 2)
if (sort === 'priority') return (PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3) || byDate(a, b)
if (sort === 'city') return cityOf(a).localeCompare(cityOf(b)) || byDate(a, b)
return byDate(a, b) || ((PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3))
})
const groups = []
let currentDate = null
const keyOf = job => sort === 'priority' ? (job.priority || 'low') : sort === 'city' ? (cityOf(job) || 'Sans ville') : (job.scheduledDate || null)
const labelOf = key => {
if (sort === 'priority') return ({ urgent: '🔴 Urgent', high: '🟠 Élevée', medium: '🔵 Moyenne', low: '⚪ Basse' })[key] || key
if (sort === 'city') return key
return key === today ? "Aujourd'hui" : (key ? fmtDate(new Date(key + 'T00:00:00')) : 'Sans date')
}
const groups = []; let cur = Symbol('init')
jobs.forEach(job => {
const d = job.scheduledDate || null
if (d !== currentDate) {
currentDate = d
let label = d === today ? "Aujourd'hui" : d ? d : 'Sans date'
if (d && d !== today) {
label = fmtDate(new Date(d + 'T00:00:00'))
}
groups.push({ date: d, label, jobs: [] })
}
const k = keyOf(job)
if (k !== cur) { cur = k; groups.push({ key: String(k), date: sort === 'date' ? k : null, label: labelOf(k), jobs: [] }) }
groups.at(-1).jobs.push(job)
})
return groups
@ -111,7 +121,7 @@ export function useBottomPanel (store, todayStr, unscheduledJobs, { pushUndo, sm
}
return {
bottomPanelOpen, bottomPanelH, unassignedGrouped, startBottomResize,
bottomPanelOpen, bottomPanelH, unassignedGrouped, bottomSort, startBottomResize,
bottomSelected, toggleBottomSelect, selectAllBottom, clearBottomSelect, batchAssignBottom,
dispatchCriteria, dispatchCriteriaModal, saveDispatchCriteria, moveCriterion,
btColWidths, btColW, startColResize,

View File

@ -81,12 +81,38 @@ export function jobSvcCode (job) {
return 'WO'
}
// Couleur par TYPE de ticket, calquée sur le board legacy (osTicket). Téléphonie = vert
// PLUS PÂLE que l'installation. Ordre des tests important (téléph avant télé ; télé avant install).
export function legacyDeptColor (dept) {
if (!dept) return null
const d = String(dept).toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
if (d.includes('teleph')) return '#8fce93' // Téléphonie → vert pâle
if (d.includes('tele') || d.includes('televis')) return '#ec5fb0' // Télé (install/réparation) → rose
if (d.includes('desinstall') || d.includes('retrait')) return '#c0392b' // Désinstallation → rouge foncé
if (d.includes('repar')) return '#f1c84b' // Réparation (Fibre) → jaune/or
if (d.includes('installation') || d.includes('monteur') || d.includes('fusionneur')) return '#46992f' // Installation (Fibre) → vert
return null
}
// Lien « Répondre au ticket » du serveur legacy (osTicket custom) — celui que les techs reçoivent
// pour écrire dans le ticket. Format observé : reply_ticket.php?ticket=<id>&staff=<staffId>.
// staff par défaut = 3301 (compte « Tech Targo » sous lequel le ticket est assigné dans legacy).
export const LEGACY_REPLY_BASE = 'https://store.targo.ca/targo/reply_ticket.php'
export function legacyReplyUrl (job, staffId) {
const id = job && job.legacyTicketId
if (!id) return null
return `${LEGACY_REPLY_BASE}?ticket=${encodeURIComponent(id)}&staff=${staffId || 3301}`
}
export function jobColor (job, techColors, store) {
// Tech en pause/absent (statut interne 'off') → ses jobs en ROUGE (à réassigner)
// Tech en pause/absent (statut interne 'off') → ses jobs en ROUGE vif (à réassigner) — priorité opérationnelle
if (job.assignedTech && store) {
const at = store.technicians.find(x => x.id === job.assignedTech)
if (at && at.status === 'off') return '#e53935'
}
// Type legacy (pont osTicket) → couleur « comme legacy »
const ld = legacyDeptColor(job.legacyDept)
if (ld) return ld
if (SVC_COLORS[job.service_type]) return SVC_COLORS[job.service_type]
const s = (job.subject||'').toLowerCase()
if (s.includes('internet')) return '#3b82f6'

View File

@ -11,6 +11,7 @@ export const navItems = [
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users' },
{ path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' },
{ path: '/campaigns', icon: 'Gift', label: 'Campagnes', requires: 'manage_users' },
{ path: '/conformite-adresses', icon: 'MapPinned', label: 'Conformité adresses', requires: 'view_settings' },
{ path: '/email-queue', icon: 'Mail', label: 'File courriels', requires: 'view_settings' },
{ path: '/settings', icon: 'Settings', label: 'Paramètres', requires: 'view_settings' },
]

View File

@ -124,13 +124,13 @@ import { navItems as allNavItems } from 'src/config/nav'
import {
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail,
CalendarRange, CalendarClock, Sparkles,
CalendarRange, CalendarClock, Sparkles, MapPinned,
} from 'lucide-vue-next'
import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
import { useConversations } from 'src/composables/useConversations'
import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue'
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles }
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned }
const { panelOpen, activeCount: convCount } = useConversations()
function toggleConvPanel () { panelOpen.value = !panelOpen.value }

View File

@ -9,10 +9,11 @@ const props = defineProps({
unscheduledCount: Number,
selected: Object, // Set
dropActive: Boolean,
sort: { type: String, default: 'date' }, // tri du pool : date | city | priority
})
const emit = defineEmits([
'update:open', 'update:height', 'resize-start',
'update:open', 'update:height', 'resize-start', 'update:sort',
'toggle-select', 'select-all', 'clear-select', 'batch-assign',
'auto-distribute', 'open-criteria',
'row-click', 'row-dblclick', 'row-dragstart',
@ -25,6 +26,9 @@ const jobColor = inject('jobColor')
const btColW = inject('btColW')
const startColResize = inject('startColResize')
// Aujourd'hui (fuseau Québec) un groupe de date PASSÉE = tickets « en retard » (à traiter/fermer).
const todayISO = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
// Lasso selection
const btLasso = ref(null)
const btScrollRef = ref(null)
@ -103,6 +107,11 @@ function btLassoEnd () {
</span>
<button v-if="unscheduledCount" class="sbf-auto-btn" @click="emit('auto-distribute')" title="Répartir automatiquement"> Répartir auto</button>
<button class="sbf-auto-btn" style="border-color:rgba(255,255,255,0.12)" @click="emit('open-criteria')" title="Critères de dispatch"> Critères</button>
<label style="display:inline-flex;align-items:center;gap:4px;font-size:0.72rem;opacity:.85" title="Trier le pool">
<select :value="sort" @change="emit('update:sort', $event.target.value)" style="background:rgba(255,255,255,0.06);color:inherit;border:1px solid rgba(255,255,255,0.12);border-radius:5px;padding:1px 4px;font-size:0.72rem">
<option value="date">Date</option><option value="city">Ville</option><option value="priority">Priorité</option>
</select>
</label>
<!-- Batch assign bar -->
<template v-if="selected.size">
<span class="sb-bottom-sel-count">{{ selected.size }} sélectionnée{{ selected.size>1?'s':'' }}</span>
@ -138,10 +147,11 @@ function btLassoEnd () {
</thead>
</table>
<div class="sb-bottom-scroll" ref="btScrollRef" @mousedown="btLassoStart" style="position:relative">
<template v-for="group in groups" :key="group.date||'nodate'">
<template v-for="group in groups" :key="group.key || group.date || 'nodate'">
<div class="sb-bottom-date-sep">
<span class="sb-bottom-date-label">{{ group.label }}</span>
<span class="sb-bottom-date-count">{{ group.jobs.length }}</span>
<span v-if="group.date && group.date < todayISO" :style="{ marginLeft:'8px', color:'#e53935', fontWeight:700, fontSize:'10px', letterSpacing:'.3px' }" title="Date dépassée — à replanifier ou fermer"> EN RETARD</span>
</div>
<table class="sb-bottom-table">
<tbody>
@ -159,6 +169,7 @@ function btLassoEnd () {
<span class="sb-bt-prio-dot" :style="'background:'+prioColor(job.priority)" :title="prioLabel(job.priority)"></span>
</td>
<td class="sb-bt-name" :style="'width:'+btColW('name',200)">
<span :style="{ display:'inline-block', width:'9px', height:'9px', borderRadius:'2px', marginRight:'6px', verticalAlign:'middle', flex:'0 0 auto', background: jobColor(job) }" :title="job.legacyDept || job.jobType || ''"></span>
<span class="sb-bt-name-text">{{ job.subject }}</span>
</td>
<td class="sb-bt-addr" :style="'width:'+btColW('addr',180)">{{ shortAddr(job.address) || '—' }}</td>

View File

@ -1,12 +1,25 @@
<script setup>
import { inject } from 'vue'
import { fmtDur, prioLabel, prioClass, ICON } from 'src/composables/useHelpers'
import { inject, ref, watch } from 'vue'
import { fmtDur, prioLabel, prioClass, ICON, legacyReplyUrl } from 'src/composables/useHelpers'
import { legacyTicketThread } from 'src/api/roster'
import TagEditor from 'src/components/shared/TagEditor.vue'
const props = defineProps({
panel: Object, // { mode, data: { job, tech } } or null
})
// Fil legacy (description + commentaires/réponses) chargé À LA DEMANDE au clic.
const thread = ref(null); const threadLoading = ref(false); const threadOpen = ref(false)
async function toggleThread () {
const id = props.panel?.data?.job?.legacyTicketId; if (!id) return
threadOpen.value = !threadOpen.value
if (!threadOpen.value || thread.value) return
threadLoading.value = true
try { thread.value = await legacyTicketThread(id) } catch (e) { thread.value = { error: String(e.message || e) } } finally { threadLoading.value = false }
}
function fmtThreadDate (iso) { if (!iso) return ''; const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleString('fr-CA', { dateStyle: 'short', timeStyle: 'short' }) }
watch(() => props.panel?.data?.job?.legacyTicketId, () => { thread.value = null; threadOpen.value = false }) // reset quand on change de job
const emit = defineEmits([
'close', 'edit', 'move', 'geofix', 'unassign',
'set-end-date', 'remove-assistant', 'assign-pending',
@ -16,6 +29,7 @@ const emit = defineEmits([
const store = inject('store')
const TECH_COLORS = inject('TECH_COLORS')
const jobColor = inject('jobColor')
const todayISO = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) // pour le badge « en retard »
const getTagColor = inject('getTagColor')
const onCreateTag = inject('onCreateTag')
const onUpdateTag = inject('onUpdateTag')
@ -72,6 +86,8 @@ const onDeleteTag = inject('onDeleteTag')
<div class="sb-rp-field"><span class="sb-rp-lbl">Date planifiée</span>
{{ panel.data?.job?.scheduledDate || '—' }}
<span v-if="panel.data?.job?.endDate"> {{ panel.data.job.endDate }}</span>
<span v-if="panel.data?.job?.scheduledDate && panel.data.job.scheduledDate < todayISO && panel.data?.job?.status !== 'Completed'"
:style="{ marginLeft:'6px', color:'#fff', background:'#e53935', borderRadius:'4px', padding:'0 5px', fontSize:'10px', fontWeight:700 }"> en retard</span>
</div>
<div v-if="panel.data?.job?.assignedTech" class="sb-rp-field">
<span class="sb-rp-lbl">Date de fin</span>
@ -79,6 +95,34 @@ const onDeleteTag = inject('onDeleteTag')
@change="emit('set-end-date', panel.data.job, $event.target.value)" style="margin-top:2px" />
</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Statut</span>{{ panel.data?.job?.status }}</div>
<div v-if="panel.data?.job?.legacyTicketId" class="sb-rp-field">
<span class="sb-rp-lbl">Ticket legacy</span>
<a class="sb-rp-link" :href="legacyReplyUrl(panel.data.job)" target="_blank" rel="noopener"
:title="'Répondre / écrire dans le ticket #' + panel.data.job.legacyTicketId + ' (serveur legacy)'">
#{{ panel.data.job.legacyTicketId }}<span v-if="panel.data?.job?.legacyDept"> · {{ panel.data.job.legacyDept }}</span>
<span class="sb-rp-link-icon"></span>
</a>
</div>
<div v-if="panel.data?.job?.legacyDetail" class="sb-rp-field">
<span class="sb-rp-lbl">Détails du ticket</span>
<div style="white-space:pre-wrap;max-height:200px;overflow:auto;font-size:0.78rem;line-height:1.4;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);border-radius:6px;padding:6px 8px;margin-top:2px">{{ panel.data.job.legacyDetail }}</div>
</div>
<!-- Fil complet du ticket legacy : commentaires / réponses des collaborateurs (chargé au clic) -->
<div v-if="panel.data?.job?.legacyTicketId" class="sb-rp-field">
<button class="sb-rp-btn" style="width:100%;text-align:left" @click="toggleThread">
💬 {{ threadOpen ? 'Masquer' : 'Voir' }} le fil du ticket / commentaires
<span v-if="thread && thread.count != null" style="opacity:.7">({{ thread.count }})</span>
</button>
<div v-if="threadOpen" style="margin-top:6px;max-height:300px;overflow:auto">
<div v-if="threadLoading" style="font-size:.78rem;opacity:.7;padding:4px">Chargement</div>
<div v-else-if="thread && thread.error" style="font-size:.78rem;color:#ef4444;padding:4px">Erreur : {{ thread.error }}</div>
<div v-else-if="thread && !thread.messages?.length" style="font-size:.78rem;opacity:.7;padding:4px">Aucun message.</div>
<div v-for="(m, i) in (thread?.messages || [])" :key="i" style="border-left:2px solid rgba(255,255,255,0.12);padding:3px 8px;margin-bottom:6px">
<div style="font-size:.68rem;opacity:.7"><b>{{ m.author }}</b> · {{ fmtThreadDate(m.at) }}</div>
<div style="white-space:pre-wrap;font-size:.76rem;line-height:1.35">{{ m.text }}</div>
</div>
</div>
</div>
<div v-if="panel.data?.job?.note" class="sb-rp-field"><span class="sb-rp-lbl">Note</span>{{ panel.data.job.note }}</div>
<div class="sb-rp-field">
<span class="sb-rp-lbl">Tags</span>
@ -104,6 +148,8 @@ const onDeleteTag = inject('onDeleteTag')
<button class="sb-rp-primary" @click="emit('edit', panel.data.job)"> Modifier</button>
<button class="sb-rp-btn" @click="emit('move', panel.data.job, panel.data.tech?.id)"> Déplacer / Réassigner</button>
<button class="sb-rp-btn" @click="emit('geofix', panel.data.job)">📍 Géofixer sur la carte</button>
<a v-if="legacyReplyUrl(panel.data?.job)" class="sb-rp-btn" :href="legacyReplyUrl(panel.data.job)" target="_blank" rel="noopener" style="text-decoration:none;text-align:center">📝 Répondre dans legacy</a>
<a v-if="panel.data?.job?.legacyActivationUrl" class="sb-rp-btn" :href="panel.data.job.legacyActivationUrl" target="_blank" rel="noopener" style="text-decoration:none;text-align:center;background:#7e3ff2;color:#fff;border-color:#7e3ff2" title="Connecter / activer le(s) STB sur Ministra (lien legacy du ticket)">📺 Activer STB (Ministra)</a>
<button v-if="panel.data?.job?.assignedTech" class="sb-rp-btn sb-ctx-warn" @click="emit('unassign', panel.data.job)"> Désaffecter</button>
</div>
</template>

View File

@ -0,0 +1,271 @@
<template>
<q-page class="q-pa-md ops-page">
<div class="row items-center q-mb-sm">
<div class="text-h6 text-weight-bold">Conformité des adresses</div>
<q-space />
<q-btn flat dense no-caps size="sm" icon="my_location" label="≈ Centre CP/ville (reste)" color="blue-grey" :loading="areaSaving" @click="applyArea"><q-tooltip>Placer les adresses non matchées restantes au centre de leur code postal (sinon ville) dernier repli</q-tooltip></q-btn>
<q-btn flat dense round icon="refresh" @click="reload" :loading="loading"><q-tooltip>Rafraîchir</q-tooltip></q-btn>
</div>
<div class="text-caption text-grey-7 q-mb-md">
Source de vérité : adresse canonique Adresses Québec (RQA locale) liée à chaque emplacement de service.
Résous ici les adresses non conformes la décision est persistée (plus de re-recherche).
</div>
<!-- Statistiques -->
<div class="row q-col-gutter-sm q-mb-md">
<div v-for="s in statusCards" :key="s.key" class="col-6 col-sm-3">
<q-card flat bordered class="text-center q-pa-sm">
<div class="text-h6 text-weight-bold" :class="s.cls">{{ s.n }}</div>
<div class="text-caption text-grey-7">{{ s.label }}</div>
</q-card>
</div>
</div>
<!-- Campings : géoloc de remplacement fixe (le lot pointe vers l'adresse principale du camping) -->
<q-expansion-item icon="cottage" label="Campings — géoloc de remplacement fixe" class="q-mb-sm" header-class="text-weight-medium">
<q-card flat bordered class="q-pa-sm">
<div class="text-caption text-grey-7 q-mb-sm">Pour un lot de camping, l'adresse est souvent la <b>résidence</b> du client, pas le terrain. On force la position du <b>camping</b> (le tech y navigue, puis trouve le terrain). Ajoute un camping appliqué à tous ses lots (match sur la ville).</div>
<q-markup-table flat dense wrap-cells class="q-mb-sm">
<thead><tr><th class="text-left">Camping (mot-clé)</th><th class="text-left">Adresse principale</th><th>GPS</th><th>Lots</th></tr></thead>
<tbody>
<tr v-for="c in campings" :key="c.id">
<td class="text-left">{{ c.name }} <span class="text-grey-5">({{ c.keyword }})</span></td>
<td class="text-left">{{ c.address }}</td>
<td class="text-center"><a :href="'https://www.google.com/maps?q=' + c.latitude + ',' + c.longitude" target="_blank">{{ (+c.latitude).toFixed(4) }}, {{ (+c.longitude).toFixed(4) }}</a></td>
<td class="text-center">{{ campingLots[c.name] || 0 }}</td>
</tr>
</tbody>
</q-markup-table>
<div class="row q-col-gutter-xs items-center">
<q-input class="col-3" dense outlined v-model="newCamp.name" label="Nom" />
<q-input class="col-2" dense outlined v-model="newCamp.keyword" label="Mot-clé (ville)" />
<q-input class="col-3" dense outlined v-model="newCamp.address" label="Adresse principale" />
<q-input class="col" dense outlined v-model.number="newCamp.latitude" label="Lat" />
<q-input class="col" dense outlined v-model.number="newCamp.longitude" label="Lon" />
<q-btn dense unelevated color="primary" icon="add" :disable="!campValid" :loading="campSaving" @click="addCamping"><q-tooltip>Ajouter + appliquer</q-tooltip></q-btn>
<q-btn dense flat color="indigo" icon="sync" :loading="campSaving" @click="applyCampings"><q-tooltip>Réappliquer tous les campings</q-tooltip></q-btn>
</div>
</q-card>
</q-expansion-item>
<!-- Filtres par type -->
<div class="row items-center q-gutter-sm q-mb-sm">
<q-chip v-for="t in typeChips" :key="t.key" clickable :selected="filter.type === t.key"
:color="filter.type === t.key ? 'primary' : 'grey-3'" :text-color="filter.type === t.key ? 'white' : 'grey-9'"
@click="setType(t.key)">{{ t.label }} <q-badge v-if="t.n != null" color="white" text-color="primary" class="q-ml-xs">{{ t.n }}</q-badge></q-chip>
<q-space />
<q-input dense outlined v-model="filter.q" debounce="400" placeholder="Rechercher adresse / ville / client" style="min-width:260px" @update:model-value="reload">
<template #prepend><q-icon name="search" /></template>
</q-input>
</div>
<!-- Liste -->
<q-table flat bordered :rows="rows" :columns="columns" row-key="name" :loading="loading"
v-model:pagination="pagination" :rows-per-page-options="[25,50,100]" @request="onRequest" :rows-number="total" binary-state-sort>
<template #body-cell-original="props">
<q-td :props="props">
<div class="text-weight-medium">{{ props.row.address_line }}</div>
<div class="text-caption text-grey-7">{{ props.row.city }} {{ props.row.postal_code }} · <span class="text-grey-6">{{ props.row.customer }}</span></div>
</q-td>
</template>
<template #body-cell-type="props">
<q-td :props="props"><q-chip dense square :color="typeColor(props.row.type)" text-color="white" class="text-caption">{{ typeLabel(props.row.type) }}</q-chip></q-td>
</template>
<template #body-cell-proposition="props">
<q-td :props="props">
<div v-if="props.row.linked_address">{{ props.row.linked_address }}
<q-badge v-if="hasCoord(props.row)" color="green-6" class="q-ml-xs">GPS</q-badge>
</div>
<span v-else class="text-grey-5"> (aucune ; utiliser Corriger)</span>
</q-td>
</template>
<template #body-cell-actions="props">
<q-td :props="props" class="text-right" style="white-space:nowrap">
<q-btn v-if="props.row.linked_address" dense flat round color="positive" icon="check" @click="resolve(props.row,'approve')"><q-tooltip>Approuver la proposition</q-tooltip></q-btn>
<q-btn dense flat round color="primary" icon="edit_location_alt" @click="openCorrect(props.row)"><q-tooltip>Corriger (chercher la bonne adresse AQ)</q-tooltip></q-btn>
<q-btn dense flat round color="teal" icon="my_location" @click="openGps(props.row)"><q-tooltip>Saisir/relever le GPS</q-tooltip></q-btn>
<q-btn dense flat round color="grey-7" icon="map" type="a" :href="mapUrl(props.row)" target="_blank"><q-tooltip>Voir sur map.targointernet.com (géoloc des unités)</q-tooltip></q-btn>
<q-btn dense flat round color="negative" icon="block" @click="resolve(props.row,'reject')"><q-tooltip>Rejeter (pas d'adresse civique)</q-tooltip></q-btn>
</q-td>
</template>
<template #no-data><div class="full-width text-center q-pa-md text-grey-6">Aucune adresse à traiter dans ce filtre 🎉</div></template>
</q-table>
<!-- Dialogue Corriger : recherche AQ locale -->
<q-dialog v-model="correct.open">
<q-card style="min-width:420px;max-width:560px">
<q-card-section class="q-pb-none">
<div class="text-subtitle1 text-weight-bold">Corriger l'adresse</div>
<div class="text-caption text-grey-7">Originale : {{ correct.row && correct.row.address_line }}, {{ correct.row && correct.row.city }}</div>
</q-card-section>
<q-card-section>
<q-input dense outlined autofocus v-model="correct.q" debounce="350" placeholder="Chercher l'adresse Adresses Québec…" @update:model-value="searchCandidates" :loading="correct.loading">
<template #prepend><q-icon name="search" /></template>
</q-input>
<q-list separator class="q-mt-sm" style="max-height:300px;overflow:auto">
<q-item v-for="c in correct.candidates" :key="c.id" clickable v-ripple @click="applyCorrect(c)">
<q-item-section>
<q-item-label>{{ c.address_full }}</q-item-label>
<q-item-label caption>{{ Math.round((c.similarity_score||0)*100) }}% · {{ c.latitude && (+c.latitude).toFixed(5) }}, {{ c.longitude && (+c.longitude).toFixed(5) }}</q-item-label>
</q-item-section>
<q-item-section side><q-badge v-if="c.fiber_available" color="green-6">FIBRE</q-badge></q-item-section>
</q-item>
<q-item v-if="correct.q && !correct.loading && !correct.candidates.length"><q-item-section class="text-grey-6">Aucun résultat affine la recherche.</q-item-section></q-item>
</q-list>
</q-card-section>
<q-card-actions align="right"><q-btn flat label="Fermer" v-close-popup /></q-card-actions>
</q-card>
</q-dialog>
<!-- Dialogue GPS manuel -->
<q-dialog v-model="gps.open">
<q-card style="min-width:360px">
<q-card-section class="q-pb-none">
<div class="text-subtitle1 text-weight-bold">Position GPS</div>
<div class="text-caption text-grey-7">{{ gps.row && gps.row.address_line }}, {{ gps.row && gps.row.city }}</div>
</q-card-section>
<q-card-section class="q-gutter-sm">
<q-banner dense class="bg-grey-2 text-caption">Astuce : relève la coordonnée de l'unité sur
<a :href="gps.row && mapUrl(gps.row)" target="_blank">map.targointernet.com</a> puis colle « lat, long » ci-dessous.</q-banner>
<q-input dense outlined v-model="gps.paste" label="Coller « lat, long »" @update:model-value="parsePaste" />
<div class="row q-col-gutter-sm">
<q-input class="col" dense outlined type="number" step="any" v-model.number="gps.lat" label="Latitude" />
<q-input class="col" dense outlined type="number" step="any" v-model.number="gps.lon" label="Longitude" />
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn unelevated color="teal" label="Enregistrer" :disable="!gpsValid" @click="saveGps" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import * as addr from 'src/api/address'
const $q = useQuasar()
const loading = ref(false)
const rows = ref([])
const total = ref(0)
const stats = reactive({ by_status: [], by_type: [] })
const filter = reactive({ type: '', q: '' })
const pagination = ref({ page: 1, rowsPerPage: 50, rowsNumber: 0 })
const columns = [
{ name: 'original', label: 'Adresse (originale)', field: 'address_line', align: 'left' },
{ name: 'type', label: 'Type', field: 'type', align: 'left' },
{ name: 'proposition', label: 'Proposition AQ (canonique)', field: 'linked_address', align: 'left' },
{ name: 'status', label: 'Statut', field: 'status', align: 'left' },
{ name: 'actions', label: '', field: 'actions', align: 'right' },
]
const TYPES = { camping: { label: 'Camping', color: 'deep-orange-5' }, civique: { label: 'Civique à corriger', color: 'blue-6' }, review: { label: 'À confirmer', color: 'amber-7' }, non_adresse: { label: 'Non-adresse', color: 'grey-6' } }
const typeLabel = (t) => (TYPES[t] || {}).label || t
const typeColor = (t) => (TYPES[t] || {}).color || 'grey-6'
const hasCoord = (r) => r.latitude != null && Math.abs(parseFloat(r.latitude)) > 0.0001
const statN = (k) => { const x = stats.by_status.find(s => s.s === k); return x ? Number(x.n) : 0 }
const typeN = (k) => { const x = stats.by_type.find(s => s.t === k); return x ? Number(x.n) : 0 }
const statusCards = computed(() => [
{ key: 'validated', label: 'Validées (conformes)', n: statN('validated'), cls: 'text-positive' },
{ key: 'review', label: 'À confirmer', n: statN('review'), cls: 'text-amber-8' },
{ key: 'area', label: '≈ Secteur (CP/ville)', n: statN('area'), cls: 'text-blue-grey-7' },
{ key: 'unmatched', label: 'Non matchées', n: statN('unmatched'), cls: 'text-deep-orange' },
{ key: 'no_address', label: 'Rejetées (sans adresse)', n: statN('no_address'), cls: 'text-grey-7' },
])
const typeChips = computed(() => [
{ key: '', label: 'Tout', n: statN('review') + statN('unmatched') },
{ key: 'civique', label: 'Civique à corriger', n: typeN('civique') },
{ key: 'camping', label: 'Camping', n: typeN('camping') },
{ key: 'review', label: 'À confirmer', n: typeN('review') },
{ key: 'non_adresse', label: 'Non-adresse', n: typeN('non_adresse') },
])
function mapUrl (row) {
const q = encodeURIComponent([row.address_line, row.city].filter(Boolean).join(', '))
return 'https://map.targointernet.com/infrastructure/map.php?q=' + q
}
async function loadStats () { try { const r = await addr.conformityStats(); stats.by_status = r.by_status || []; stats.by_type = r.by_type || [] } catch (e) {} }
async function loadList () {
loading.value = true
try {
const offset = (pagination.value.page - 1) * pagination.value.rowsPerPage
const r = await addr.conformityList({ type: filter.type, q: filter.q, limit: pagination.value.rowsPerPage, offset })
rows.value = r.rows || []; total.value = r.total || 0; pagination.value.rowsNumber = r.total || 0
} catch (e) { $q.notify({ type: 'negative', message: 'Chargement échoué: ' + e.message }) } finally { loading.value = false }
}
function onRequest (props) { pagination.value.page = props.pagination.page; pagination.value.rowsPerPage = props.pagination.rowsPerPage; loadList() }
function setType (t) { filter.type = t; pagination.value.page = 1; loadList() }
function reload () { pagination.value.page = 1; loadStats(); loadList() }
async function resolve (row, action, extra) {
try {
await addr.conformityResolve({ name: row.name, action, ...(extra || {}) })
rows.value = rows.value.filter(r => r.name !== row.name); total.value = Math.max(0, total.value - 1)
loadStats()
$q.notify({ type: 'positive', message: action === 'reject' ? 'Marquée sans adresse' : 'Adresse validée ✓', timeout: 1200 })
} catch (e) { $q.notify({ type: 'negative', message: 'Échec: ' + e.message }) }
}
// Corriger
const correct = reactive({ open: false, row: null, q: '', candidates: [], loading: false })
function openCorrect (row) { correct.row = row; correct.q = [row.address_line, row.city].filter(Boolean).join(' '); correct.candidates = []; correct.open = true; searchCandidates() }
async function searchCandidates () {
if (!correct.q || correct.q.length < 3) { correct.candidates = []; return }
correct.loading = true
try { const r = await addr.conformityCandidates(correct.q); correct.candidates = r.results || [] } catch (e) {} finally { correct.loading = false }
}
function applyCorrect (c) {
const row = correct.row
resolve(row, 'correct', { aq_address_id: String(c.id), linked_address: c.address_full, latitude: c.latitude, longitude: c.longitude })
correct.open = false
}
// GPS manuel
const gps = reactive({ open: false, row: null, lat: null, lon: null, paste: '' })
const gpsValid = computed(() => isFinite(gps.lat) && isFinite(gps.lon) && Math.abs(gps.lat) > 0.0001 && Math.abs(gps.lon) > 0.0001)
function openGps (row) { gps.row = row; gps.lat = row.latitude ? +row.latitude : null; gps.lon = row.longitude ? +row.longitude : null; gps.paste = ''; gps.open = true }
function parsePaste () { const m = String(gps.paste).match(/(-?\d+\.\d+)[ ,]+(-?\d+\.\d+)/); if (m) { gps.lat = +m[1]; gps.lon = +m[2] } }
function saveGps () { if (!gpsValid.value) return; resolve(gps.row, 'gps', { latitude: gps.lat, longitude: gps.lon }); gps.open = false }
// Campings (géoloc de remplacement fixe)
const campings = ref([])
const campingLots = ref({})
const campSaving = ref(false)
const newCamp = reactive({ name: '', keyword: '', address: '', latitude: null, longitude: null })
const campValid = computed(() => !!newCamp.name && !!newCamp.keyword && isFinite(newCamp.latitude) && isFinite(newCamp.longitude))
async function loadCampings () { try { const r = await addr.campingsList(); campings.value = r.campings || []; campingLots.value = r.lots || {} } catch (e) {} }
function campApplied (r) { return (r.applied || []).reduce((s, x) => s + (x.n || 0), 0) }
async function addCamping () {
if (!campValid.value) return
campSaving.value = true
try {
const r = await addr.campingsUpsert({ name: newCamp.name, keyword: newCamp.keyword, address: newCamp.address, latitude: newCamp.latitude, longitude: newCamp.longitude })
$q.notify({ type: 'positive', message: `Camping enregistré + appliqué (${campApplied(r)} lots)`, timeout: 2200 })
Object.assign(newCamp, { name: '', keyword: '', address: '', latitude: null, longitude: null })
loadCampings(); reload()
} catch (e) { $q.notify({ type: 'negative', message: 'Échec: ' + e.message }) } finally { campSaving.value = false }
}
async function applyCampings () {
campSaving.value = true
try { const r = await addr.campingsApply(); $q.notify({ type: 'positive', message: `Appliqué : ${campApplied(r)} lots`, timeout: 2200 }); loadCampings(); reload() }
catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { campSaving.value = false }
}
// Dernier repli : centre du code postal / ville pour les unmatched restants
const areaSaving = ref(false)
async function applyArea () {
areaSaving.value = true
try {
const r = await addr.conformityApplyArea()
$q.notify({ type: 'positive', message: `${r.total || 0} placés au secteur (${r.by_postal || 0} par code postal, ${r.by_city || 0} par ville)`, timeout: 2600 })
reload()
} catch (e) { $q.notify({ type: 'negative', message: 'Échec: ' + e.message }) } finally { areaSaving.value = false }
}
onMounted(() => { loadStats(); loadList(); loadCampings() })
</script>

View File

@ -1,6 +1,8 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick, provide } from 'vue'
import { useRoute } from 'vue-router'
import { Notify } from 'quasar'
import * as roster from 'src/api/roster'
import { useDispatchStore } from 'src/stores/dispatch'
import { useAuthStore } from 'src/stores/auth'
import { TECH_COLORS, MAPBOX_TOKEN, BASE_URL } from 'src/config/erpnext'
@ -50,6 +52,7 @@ import CreateOfferModal from 'src/modules/dispatch/components/CreateOfferModal.v
import RecurrenceSelector from 'src/components/shared/RecurrenceSelector.vue'
const store = useDispatchStore()
const route = useRoute()
const auth = useAuthStore()
const erpUrl = BASE_URL || window.location.origin
@ -277,6 +280,28 @@ function cancelUnassign () {
confirmUnassignDialog.value = false
}
// #58 Désaffecter + AVISER LE CLIENT : le hub /roster/job/notify-reschedule désassigne côté serveur
// (booking_status « À reporter », vide le créneau), retrouve le mobile du Customer et envoie un SMS avec
// un lien /book pour rechoisir un créneau. On reflète ensuite le désassignement dans la vue locale.
const notifyingClient = ref(false)
async function unassignAndNotify (job) {
if (!job || notifyingClient.value) return
notifyingClient.value = true
let r
try { r = await roster.notifyReschedule({ job: job.name || job.id }) }
catch (e) { Notify.create({ type: 'negative', message: 'Échec de la notification : ' + (e.message || e) }); notifyingClient.value = false; return }
store.fullUnassign(job.id) // met à jour la vue (le serveur a déjà désassigné réécriture idempotente)
const j = store.jobs.find(x => x.id === job.id); if (j) { j.scheduledDate = null; j.startTime = null }
if (selectedJob.value?.job?.id === job.id) selectedJob.value = null
invalidateRoutes()
Notify.create({ type: r && r.sms ? 'positive' : 'warning', timeout: 5000,
message: r && r.sms ? ('Client avisé par SMS' + (r.phone ? ' (' + r.phone + ')' : '') + ' · job remis au pool « À reporter »')
: ('Désaffecté · ' + ((r && (r.note || r.error)) || 'SMS non envoyé (aucun mobile au dossier)')) })
notifyingClient.value = false
pendingUnassignJob.value = null
confirmUnassignDialog.value = false
}
const {
ctxMenu, techCtx, assistCtx, assistNoteModal,
openCtxMenu, closeCtxMenu, openTechCtx, openAssistCtx,
@ -285,7 +310,7 @@ const {
} = useContextMenus({ store, rightPanel, invalidateRoutes, fullUnassign, openMoveModal, openEditModal })
const {
bottomPanelOpen, bottomPanelH, unassignedGrouped, startBottomResize,
bottomPanelOpen, bottomPanelH, unassignedGrouped, bottomSort, startBottomResize,
bottomSelected, toggleBottomSelect, selectAllBottom, clearBottomSelect, batchAssignBottom,
dispatchCriteria, dispatchCriteriaModal, saveDispatchCriteria, moveCriterion,
btColWidths, btColW, startColResize,
@ -1210,6 +1235,15 @@ onMounted(async () => {
nextTick(() => scrollToCenter())
if (boardScroll.value) boardScroll.value.addEventListener('scroll', onBoardScroll, { passive: true })
window.addEventListener('resize', measureCalColW)
// Deep-link depuis la Planification : ?tech=<id>&date=<YYYY-MM-DD> focus jour + ressource.
// 'T12:00:00' = midi local pour éviter le décalage de jour (new Date('YYYY-MM-DD') = UTC).
nextTick(() => {
try {
const q = route.query
if (q.date) goToDay(String(q.date) + 'T12:00:00')
if (q.tech) { const tk = store.technicians.find(t => t.id === String(q.tech)); if (tk && selectedTechId.value !== tk.id) selectTechOnBoard(tk) }
} catch (_) {}
})
})
// Re-measure column widths when switching views (dayweek changes periodDays 17)
@ -1600,6 +1634,7 @@ onUnmounted(() => {
<BottomPanel :open="bottomPanelOpen" :height="bottomPanelH"
:groups="unassignedGrouped" :unscheduled-count="unscheduledJobs.length"
:sort="bottomSort" @update:sort="v => bottomSort = v"
:selected="bottomSelected" :drop-active="unassignDropActive"
@update:open="v => bottomPanelOpen = v" @resize-start="startBottomResize"
@toggle-select="toggleBottomSelect" @select-all="selectAllBottom" @clear-select="clearBottomSelect"
@ -2106,6 +2141,7 @@ onUnmounted(() => {
</div>
<div class="sb-confirm-actions">
<button class="sb-rp-btn" @click="cancelUnassign">Annuler</button>
<button class="sb-rp-btn" :disabled="notifyingClient" @click="unassignAndNotify(pendingUnassignJob)" title="Désaffecter + envoyer au client un SMS avec un lien pour choisir un nouveau créneau">{{ notifyingClient ? 'Envoi…' : '📩 Désaffecter + aviser le client' }}</button>
<button class="sb-rp-btn sb-confirm-danger" @click="confirmUnassign">Désaffecter</button>
</div>
</div>

View File

@ -4,6 +4,7 @@
<div class="row items-center q-mb-sm q-gutter-xs">
<div class="text-h6 text-weight-bold">Planification</div>
<q-chip v-if="dirty" dense size="sm" color="orange" text-color="white" icon="circle">{{ dirtyCount }} non publié(s)</q-chip>
<q-chip v-if="offShiftWeekCount" dense size="sm" color="orange-8" text-color="white" icon="warning">{{ offShiftWeekCount }} hors quart<q-tooltip class="bg-grey-9">{{ offShiftWeekCount }} job(s) assigné(s) cette période un jour la ressource n'a AUCUN quart publié. Repère le dans la grille publier un quart ou réassigner.</q-tooltip></q-chip>
<q-space />
<q-btn-group flat>
<q-btn dense flat icon="chevron_left" @click="navWeek(-1)"><q-tooltip>Semaine précédente</q-tooltip></q-btn>
@ -153,7 +154,6 @@
<div class="dow">{{ d.dow }}</div><div class="dnum">{{ d.dnum }}</div>
<q-badge v-if="gapByDay[d.iso]" color="red" floating style="top:2px;right:2px">{{ gapByDay[d.iso] }}</q-badge>
<div class="hol-toggle" :class="{ on: isHoliday(d.iso) }" @click.stop="toggleHoliday(d.iso)"><q-tooltip>Marquer férié</q-tooltip>F</div>
<div class="hdr-ruler"><span v-for="tk in axisTicks" :key="tk.h" class="tick" :style="{ left: tk.left }">{{ tk.h }}</span></div>
</th>
</tr>
</thead>
@ -184,7 +184,7 @@
<template v-else-if="hasReg(t.id, d.iso) || onGarde(t.id, d.iso)">
<div class="tl">
<div v-for="(b, bi) in cellBands(t.id, d.iso)" :key="'b' + bi" class="tl-shift" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }"></div>
<div v-for="(b, bi) in cellBlocks(t.id, d.iso)" :key="'j' + bi" class="tl-blk" :style="blockStyle(b, cellPct(t.id, d.iso))"></div>
<div v-for="(b, bi) in cellBlocks(t.id, d.iso)" :key="'j' + bi" class="tl-blk tl-blk-click" :style="blockStyle(b, cellPct(t.id, d.iso))" @click.stop="openDayEditor(t, d)" @mousedown.stop><q-tooltip class="bg-grey-9" :delay="400" style="font-size:11px">Éditer la tournée du jour</q-tooltip></div>
<!-- Aperçu d'occupation projetée pendant le drag : barre fantôme + delta -->
<div v-if="isDropTarget(t.id, d.iso) && projPct(t.id, d.iso) != null" class="tl-proj" :style="{ width: Math.min(100, projPct(t.id, d.iso)) + '%', background: occColor(projPct(t.id, d.iso)) }"></div>
<q-tooltip class="bg-grey-9" :offset="[0, 6]" max-width="320px">
@ -201,6 +201,7 @@
</q-tooltip>
</div>
</template>
<span v-else-if="offShiftJobs(t.id, d.iso).length" class="offshift-warn" @click.stop="openTimeline(t)"><q-icon name="warning" size="13px" color="orange-8" />{{ offShiftJobs(t.id, d.iso).length }}<q-tooltip class="bg-grey-9">{{ offShiftJobs(t.id, d.iso).length }} job(s) assigné(s) ce jour SANS quart publié publier un quart ou réassigner. Clic timeline.</q-tooltip></span>
<span v-else class="free">·</span>
<div v-if="isDropTarget(t.id, d.iso)" class="drop-badge" :class="{ over: projPct(t.id, d.iso) >= 100 }">+{{ dropPreview.addH }}h<template v-if="projPct(t.id, d.iso) != null"> {{ projPct(t.id, d.iso) }}%</template></div>
</td>
@ -537,17 +538,28 @@
<q-btn flat dense round size="sm" icon="refresh" color="white" :loading="assignPanel.loading" @click="openAssignPanel" />
<q-btn flat dense round size="sm" icon="close" color="white" @click="assignPanel.open = false" />
</div>
<div class="assign-sortbar" @mousedown.stop>
<span>Trier :</span>
<select v-model="assignSort" @mousedown.stop>
<option value="group">Groupe (parent-enfant)</option>
<option value="skill">Compétence</option>
<option value="date">Date</option>
<option value="city">Ville</option>
<option value="priority">Priorité</option>
</select>
</div>
<div class="assign-body">
<div v-if="assignPanel.loading" class="text-grey-6 q-pa-md text-center">Chargement</div>
<div v-else-if="!assignPanel.jobs.length" class="text-grey-6 q-pa-md text-center">Aucun job à assigner 🎉</div>
<div v-for="grp in assignGroups" :key="grp.key" class="assign-grp" :class="{ 'grp-hl': groupSelected(grp) }">
<div v-if="grp.jobs.length > 1" class="assign-grp-hdr" @click="toggleGroupSel(grp)"><q-icon name="account_tree" size="12px" /> Groupe ({{ grp.jobs.length }}) tout sélectionner (terrain)</div>
<div v-for="(j, idx) in grp.jobs" :key="j.name" class="assign-job" :class="{ blocked: j.status === 'On Hold', child: grp.jobs.length > 1 && idx > 0, sel: !!selectedJobs[j.name], dragging: draggingSet.has(j.name) }" draggable="true" @dragstart="onJobDragStart($event, j)" @dragend="onJobDragEnd">
<div v-if="grp.label" class="assign-grp-lbl">{{ grp.label }} <span style="opacity:.6">({{ grp.jobs.length }})</span></div>
<div v-if="assignSort === 'group' && grp.jobs.length > 1" class="assign-grp-hdr" @click="toggleGroupSel(grp)"><q-icon name="account_tree" size="12px" /> Groupe ({{ grp.jobs.length }}) tout sélectionner (terrain)</div>
<div v-for="(j, idx) in grp.jobs" :key="j.name" class="assign-job" :class="{ blocked: j.status === 'On Hold', child: assignSort === 'group' && grp.jobs.length > 1 && idx > 0, sel: !!selectedJobs[j.name], dragging: draggingSet.has(j.name) }" :style="{ borderLeft: '5px solid ' + panelJobColor(j) }" draggable="true" @dragstart="onJobDragStart($event, j)" @dragend="onJobDragEnd">
<div class="row items-center no-wrap">
<q-checkbox dense size="xs" :model-value="!!selectedJobs[j.name]" @update:model-value="selectedJobs[j.name] = $event" @click.stop @mousedown.stop class="q-mr-xs" />
<q-icon :name="jobIsOnsite(j) ? 'home_repair_service' : 'cloud'" size="13px" :color="jobIsOnsite(j) ? 'teal' : 'grey-5'" class="q-mr-xs"><q-tooltip>{{ jobIsOnsite(j) ? 'Sur site (terrain)' : 'À distance / netadmin — pas pour un tech terrain' }}</q-tooltip></q-icon>
<q-badge v-if="j.step_order" color="indigo" class="q-mr-xs">{{ j.step_order }}</q-badge>
<span class="ellipsis text-weight-medium">{{ j.subject || j.service_type || j.name }}</span>
<span class="ellipsis text-weight-medium">{{ j.subject || j.service_type || j.name }}<q-tooltip v-if="j.legacy_detail" max-width="380px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.legacy_detail }}</q-tooltip></span>
<q-space />
<q-icon v-if="j.status === 'On Hold'" name="lock" size="13px" color="orange"><q-tooltip>En attente de {{ j.depends_on || 'la tâche précédente' }}</q-tooltip></q-icon>
</div>
@ -571,18 +583,20 @@
<q-icon name="timeline" color="indigo" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Timeline {{ timelineDlg.tech && timelineDlg.tech.name }}</div>
<q-space />
<q-btn flat dense no-caps size="sm" icon="open_in_new" label="Dispatch" color="indigo" @click="$router.push('/dispatch')" />
<q-btn flat dense no-caps size="sm" icon="open_in_new" label="Dispatch" color="indigo" @click="gotoDispatch(timelineDlg.tech)"><q-tooltip>Ouvrir le tableau Dispatch sur cette ressource</q-tooltip></q-btn>
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-pt-none" style="max-height:70vh;overflow:auto">
<div v-if="!timelineDays.length" class="text-grey-6 q-pa-md text-center">Aucun job planifié cette semaine pour cette ressource.</div>
<div v-for="day in timelineDays" :key="day.iso" class="tldlg-day">
<div class="row items-center q-mb-xs">
<div class="text-weight-medium" :class="{ 'text-deep-orange-7': day.weekend }">{{ day.label }}</div><q-space />
<div class="text-weight-medium" :class="{ 'text-deep-orange-7': day.weekend }">{{ day.label }}</div>
<q-badge v-if="day.offShift" color="orange-8" class="q-ml-sm"><q-icon name="warning" size="11px" class="q-mr-xs" />hors quart publié</q-badge>
<q-space />
<q-badge v-if="day.pct != null" text-color="white" :style="{ background: occColor(day.pct) }">{{ day.usedH }}h · {{ day.pct }}%</q-badge>
<q-badge v-else color="grey-5" class="q-ml-xs">{{ day.usedH }}h</q-badge>
</div>
<div class="tldlg-bar">
<div v-if="!day.offShift" class="tldlg-bar">
<div v-for="(b, bi) in day.bands" :key="'b' + bi" class="tl-shift" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }"></div>
<div v-for="(b, bi) in day.blocks" :key="'k' + bi" class="tl-blk" :style="blockStyle(b, day.pct)"></div>
<span v-for="tk in axisTicks" :key="'t' + tk.h" class="tldlg-tick" :style="{ left: tk.left }">{{ tk.h }}</span>
@ -643,6 +657,68 @@
</div>
</q-list>
</q-menu>
<!-- Éditeur de JOURNÉE (clic sur le progressbar) : timeline + réordonner par drag-drop + retirer un job -->
<q-dialog v-model="dayEditor.open">
<q-card style="min-width:560px;max-width:680px">
<q-card-section class="row items-center q-pb-sm">
<q-icon name="view_timeline" color="indigo" size="22px" class="q-mr-sm" />
<div>
<div class="text-subtitle1 text-weight-bold">{{ dayEditor.tech && dayEditor.tech.name }} {{ dayEditor.day && (dayEditor.day.dow + ' ' + dayEditor.day.dnum) }}</div>
<div class="text-caption text-grey-7" v-if="dayOcc()">{{ dayOcc().usedH }}h occupé / {{ dayOcc().bookableH }}h · {{ dayOcc().pct }}%</div>
</div>
<q-space />
<q-btn flat dense no-caps size="sm" icon="open_in_new" label="Dispatch" color="indigo" @click="gotoDispatch(dayEditor.tech, dayEditor.day && dayEditor.day.iso)"><q-tooltip>Ouvrir le tableau Dispatch complet</q-tooltip></q-btn>
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-pt-none">
<!-- timeline visuelle (réutilise les blocs colorés par compétence) -->
<div class="tldlg-bar" style="height:20px">
<div v-for="(b, bi) in dayBands()" :key="'b' + bi" class="tl-shift" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }"></div>
<div v-for="(g, gi) in dayTravelSegs()" :key="'tr' + gi" class="tl-travel" :style="pos(g.s, Math.min(g.e, 24))"><q-tooltip class="bg-grey-9" style="font-size:11px">🚗 déplacement</q-tooltip></div>
<div v-for="(b, bi) in dayBlocks()" :key="'k' + bi" class="tl-blk" :style="blockStyle(b, dayOcc() && dayOcc().pct)"></div>
<span v-for="tk in axisTicks" :key="'t' + tk.h" class="tldlg-tick" :style="{ left: tk.left }">{{ tk.h }}</span>
</div>
<!-- carte interactive : itinéraire ROUTIER réel + pins numérotés, navigable (zoom molette/boutons, déplacement) -->
<div v-show="dayEditor.list.length" class="de-map-wrap">
<div ref="dayMapEl" class="de-map-gl"></div>
<div class="de-map-cap">🗺 Itinéraire routier · molette/boutons = zoom · glisser = déplacer<span v-if="dayNoCoord" class="text-deep-orange-7"> · {{ dayNoCoord }} sans coords (absent de la carte)</span></div>
</div>
<div v-if="!dayEditor.list.length" class="text-grey-6 q-pa-md text-center">Aucun job ce jour.</div>
<!-- liste éditable : flèches/glisser pour réordonner · durée en minutes · pour retirer -->
<template v-for="(j, i) in dayEditor.list" :key="j.name">
<!-- temps de transport estimé depuis le job précédent (l'espace entre 2 blocs) -->
<div v-if="i > 0" class="de-travel">
<template v-if="dayLeg(i)">{{ dayLeg(i).real ? '🚗' : '📏' }} {{ dayLeg(i).real ? '' : '~' }}{{ dayLeg(i).min }} min<template v-if="dayLeg(i).km != null"> · {{ dayLeg(i).km }} km</template><q-tooltip class="bg-grey-9" style="font-size:11px">{{ dayLeg(i).real ? 'Temps routier réel (routes Mapbox)' : 'Estimation à vol doiseau (coords approximatives ou Mapbox indisponible)' }}</q-tooltip></template>
<template v-else>🚗 transport ? (adresse/coords manquantes)</template>
</div>
<div class="de-row" :class="{ 'de-drag': dayEditor.dragIdx === i }"
draggable="true" @dragstart="dayDragStart(i, $event)" @dragover.prevent @drop="dayDropOn(i)" @dragend="dayDragEnd">
<div class="column" style="gap:0">
<q-btn flat dense round size="9px" icon="keyboard_arrow_up" :disable="i === 0" @click="moveDayJob(i, -1)" />
<q-btn flat dense round size="9px" icon="keyboard_arrow_down" :disable="i === dayEditor.list.length - 1" @click="moveDayJob(i, 1)" />
</div>
<q-icon name="drag_indicator" size="16px" class="text-grey-5" style="cursor:grab" />
<span class="de-ord">{{ i + 1 }}</span>
<span class="de-dot" :style="{ background: j.skill ? getTagColor(j.skill) : prioColor(j.priority) }"></span>
<div class="col" style="min-width:0;cursor:pointer" @click="j.showDetail = !j.showDetail; focusDayJob(j)"><q-tooltip v-if="j.detail && !j.showDetail" max-width="360px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.detail }}</q-tooltip>
<div class="ellipsis text-weight-medium" style="font-size:13px">{{ j.subject }} <q-icon name="info_outline" size="12px" class="text-grey-5" /></div>
<div class="ellipsis text-grey-6" style="font-size:11px">{{ fmtHM(packedDay[i].startMin) }}{{ fmtHM(packedDay[i].endMin) }}<span v-if="j.locked" class="text-deep-orange-7"> · 🔒 RDV fixe</span><span v-if="j.customer"> · {{ j.customer }}</span></div>
</div>
<div class="de-dur"><input type="number" min="5" step="5" :value="jobMinutes(j)" @change="setJobMinutes(j, $event.target.value)" @click.stop @mousedown.stop /><span>min</span></div>
<!-- sélecteur de priorité retiré (défaut « Moyenne » conservé en donnée) gain de place ; priorité gérée au Dispatch -->
<q-btn flat dense round size="sm" :icon="j.locked ? 'lock' : 'lock_open'" :color="j.locked ? 'deep-orange' : 'grey-5'" @click="j.locked = !j.locked"><q-tooltip>{{ j.locked ? 'Heure FIXE (RDV) — verrouillée, non replanifiée' : 'Heure flexible — replanifiée par la tournée' }}</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="close" color="negative" @click="removeFromDay(j)"><q-tooltip>Retirer du tech (retour au pool)</q-tooltip></q-btn>
</div>
<div v-if="j.showDetail" class="de-detail">{{ j.detail || 'Aucun détail importé pour ce ticket.' }}</div>
</template>
</q-card-section>
<q-card-section v-if="dayEditor.list.length" class="row items-center q-pt-none">
<span class="text-caption text-grey-6">Glisser/flèches = ordre (heures recalculées) · 🔒 = RDV fixe · clic = détails · total <b>{{ dayTotalH() }}h</b></span><q-space />
<q-btn dense unelevated color="primary" :loading="dayEditor.saving" label="Enregistrer" @click="saveDayOrder" />
</q-card-section>
</q-card>
</q-dialog>
</q-page>
</template>
@ -665,17 +741,20 @@
* 10. Chargement & solveur ................. loadBase/loadWeek/loadStats · doGenerate/doPublish
* 11. Helpers date/temps/couleur .......... iso/hToNum/numToTime · occColor/todColor/getTagColor
*/
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
import { ref, computed, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue'
// Icônes de rôle monochromes outline (Material Symbols, style « une couleur » demandé) : échelle = installation.
import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } from '@quasar/extras/material-symbols-outlined'
import { onBeforeRouteLeave } from 'vue-router'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import * as roster from 'src/api/roster'
import { MAPBOX_TOKEN } from 'src/config/erpnext' // routage routier réel (API Mapbox Matrix), déjà utilisé par le Dispatch
import { legacyDeptColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé avec le board Dispatch)
import TechSelect from 'src/components/shared/TechSelect.vue'
import SkillSelect from 'src/components/shared/SkillSelect.vue'
import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags partagé (Dispatch) : condensé, création à la volée, couleurs
const $q = useQuasar()
const router = useRouter()
const DIRTY_MSG = 'Vous avez des modifications non publiées. Les abandonner ?'
const techs = ref([])
@ -865,9 +944,29 @@ const selectedJobs = reactive({}) // jobName → true
const dropPreview = reactive({ key: null, addH: 0 })
const draggingSet = reactive(new Set()); let _dragGhost = null // jobs en cours de glissé (source estompée) + fantôme custom
async function openAssignPanel () { assignPanel.open = true; assignPanel.loading = true; for (const k in selectedJobs) delete selectedJobs[k]; try { assignPanel.jobs = (await roster.unassignedJobs()).jobs || [] } catch (e) { err(e) } finally { assignPanel.loading = false } }
const assignGroups = computed(() => { // regroupe par parent_job (ou nom propre), ordonné par step_order
const g = {}; for (const j of assignPanel.jobs) { const k = j.parent_job || j.name; (g[k] = g[k] || []).push(j) }
return Object.keys(g).map(k => ({ key: k, jobs: g[k].slice().sort((a, b) => (a.step_order || 0) - (b.step_order || 0)) }))
const assignSort = ref('group') // group (parent-enfant) | skill | date | city | priority
const ASSIGN_PRIO = { urgent: 0, high: 1, medium: 2, low: 3 }
function jobCity (j) {
const a = String(j.location_label || j.service_location || '')
const parts = a.split(',').map(s => s.trim()).filter(Boolean)
if (parts.length >= 2) return parts[parts.length - 1] // dernier segment d'adresse = ville
const subj = String(j.subject || ''); if (subj.includes('|')) return subj.split('|')[0].trim() // sujets legacy « Ville | Nom »
return parts[0] || 'Sans ville'
}
const assignGroups = computed(() => {
const jobs = assignPanel.jobs
if (assignSort.value === 'group') { // défaut : groupe parent-enfant (installation avant activation), ordonné par step_order
const g = {}; for (const j of jobs) { const k = j.parent_job || j.name; (g[k] = g[k] || []).push(j) }
return Object.keys(g).map(k => ({ key: k, label: null, jobs: g[k].slice().sort((a, b) => (a.step_order || 0) - (b.step_order || 0)) }))
}
const keyOf = j => assignSort.value === 'skill' ? (j.required_skill || 'Sans compétence')
: assignSort.value === 'city' ? jobCity(j)
: assignSort.value === 'priority' ? (j.priority || 'low')
: (j.scheduled_date || 'Sans date')
const labelOf = k => assignSort.value === 'priority' ? (({ urgent: '🔴 Urgent', high: '🟠 Élevée', medium: '🔵 Moyenne', low: '⚪ Basse' })[k] || k) : k
const g = {}; for (const j of jobs) { const k = keyOf(j); (g[k] = g[k] || []).push(j) }
const keys = Object.keys(g).sort((a, b) => assignSort.value === 'priority' ? (ASSIGN_PRIO[a] ?? 9) - (ASSIGN_PRIO[b] ?? 9) : a.localeCompare(b))
return keys.map(k => ({ key: k, label: labelOf(k), jobs: g[k] }))
})
// Terrain vs à distance : l'activation / config / netadmin ne va PAS à un tech sur site (heuristique skill + type/sujet).
function jobIsOnsite (j) {
@ -900,9 +999,14 @@ async function onCellDrop (ev, t, d) {
dropCell.value = null; dropPreview.key = null
const raw = (ev.dataTransfer && ev.dataTransfer.getData('text/plain')) || draggingJobName.value; draggingJobName.value = null
const names = (raw || '').split(',').filter(Boolean); if (!names.length) return
// Garde-fou : un job « On Hold » attend une tâche précédente on REFUSE de l'assigner ( simple 🔒 visuel).
const statusBy = Object.fromEntries(assignPanel.jobs.map(j => [j.name, j.status]))
const blocked = names.filter(n => statusBy[n] === 'On Hold'); const assignable = names.filter(n => statusBy[n] !== 'On Hold')
if (blocked.length) $q.notify({ type: 'warning', message: blocked.length + ' job(s) en attente d\'une tâche précédente — non assigné(s). Termine d\'abord l\'étape requise.', timeout: 4000 })
if (!assignable.length) return
let ok = 0
for (const jn of names) { try { await roster.assignJob(jn, t.id, d.iso); ok++; delete selectedJobs[jn] } catch (e) { err(e) } } // SÉQUENTIEL
assignPanel.jobs = assignPanel.jobs.filter(j => !names.includes(j.name))
for (const jn of assignable) { try { await roster.assignJob(jn, t.id, d.iso); ok++; delete selectedJobs[jn] } catch (e) { err(e) } } // SÉQUENTIEL (frappe_pg)
assignPanel.jobs = assignPanel.jobs.filter(j => !assignable.includes(j.name)) // les bloqués restent dans le panneau
$q.notify({ type: 'positive', message: ok + ' job(s) assigné(s) à ' + t.name + ' · ' + d.dnum, timeout: 2800 }); await loadWeek()
}
let _panelDrag = null // déplacement du panneau via son en-tête
@ -914,14 +1018,194 @@ function panelUp () { _panelDrag = null; document.removeEventListener('mousemove
// Réutilise les helpers de cellule (cellBands/cellBlocks/cellJobs/cellPct) 0 nouvel appel réseau.
const timelineDlg = reactive({ open: false, tech: null })
function openTimeline (t) { timelineDlg.tech = t; timelineDlg.open = true }
// (Clic sur le progressbar gotoDispatch : on ouvre le timeline ÉDITABLE du tableau Dispatch, drag-drop + suppression,
// plutôt qu'un popup maison réutilisation max + cohérence. Le réordonnancement/priorité se fait là-bas.)
// Deep-link vers le tableau Dispatch focalisé sur la ressource + le jour cliqué (sinon 1er jour de la semaine).
function gotoDispatch (t, dateIso) {
const q = {}
if (t) q.tech = t.id
q.date = dateIso || (timelineDays.value[0] && timelineDays.value[0].iso) || start.value
router.push({ path: '/dispatch', query: q })
}
// Éditeur de JOURNÉE (fenêtre contextuelle ciblée clic sur le progressbar)
// Garde le contexte de la grille derrière. Timeline + réordonnancement DRAG-DROP + retrait d'un job.
const dayEditor = reactive({ open: false, tech: null, day: null, list: [], saving: false, dragIdx: null, travelMap: {}, routeReady: false })
function openDayEditor (t, d) {
dayEditor.tech = t; dayEditor.day = d
// RDV confirmé (ou heure légacy précise) = heure FIXE verrouillé ; sinon flexible (replanifiable par la tournée).
dayEditor.list = cellJobs(t.id, d.iso).map(j => ({ ...j, locked: j.booking_status === 'Confirmé', showDetail: false }))
dayEditor.dragIdx = null; dayEditor.travelMap = {}; dayEditor.routeReady = false; dayEditor.open = true
loadDayRoute() // charge la matrice de temps routiers RÉELS (Mapbox) packedDay les utilise dès l'arrivée (réactif)
}
// Matrice des temps de trajet ROUTIERS RÉELS entre tous les jobs du jour (Mapbox Matrix, 1 requête).
// Indépendante de l'ordre le réordonnancement réutilise la matrice SANS nouvelle requête (recalcul instantané).
// Repli silencieux sur l'haversine si Mapbox indispo ou coords manquantes.
async function loadDayRoute () {
const key = (dayEditor.tech && dayEditor.tech.id) + '|' + (dayEditor.day && dayEditor.day.iso)
const pts = dayEditor.list.filter(j => j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon)).slice(0, 25) // Matrix = 25 coords max
if (pts.length < 2 || !MAPBOX_TOKEN) { dayEditor.travelMap = {}; dayEditor.routeReady = false; return }
const coords = pts.map(j => `${(+j.lon).toFixed(6)},${(+j.lat).toFixed(6)}`).join(';')
const url = `https://api.mapbox.com/directions-matrix/v1/mapbox/driving/${coords}?annotations=duration,distance&access_token=${MAPBOX_TOKEN}`
try {
const r = await fetch(url); if (!r.ok) throw new Error('matrix ' + r.status)
const d = await r.json(); const dur = d.durations || [], dist = d.distances || []
if (key !== ((dayEditor.tech && dayEditor.tech.id) + '|' + (dayEditor.day && dayEditor.day.iso))) return // l'éditeur a changé de cible entre-temps
const map = {}
for (let i = 0; i < pts.length; i++) for (let k = 0; k < pts.length; k++) {
if (i === k) continue
const sec = dur[i] && dur[i][k]; const m = dist[i] && dist[i][k]
if (sec == null) continue
map[pts[i].name + '>' + pts[k].name] = { min: Math.max(2, Math.round(sec / 60)), km: m != null ? Math.round(m / 100) / 10 : null, real: true }
}
dayEditor.travelMap = map; dayEditor.routeReady = true
} catch (e) { dayEditor.travelMap = {}; dayEditor.routeReady = false } // repli haversine
}
const dayOcc = () => (dayEditor.tech && dayEditor.day) ? cellOcc(dayEditor.tech.id, dayEditor.day.iso) : null
const dayBands = () => (dayEditor.tech && dayEditor.day) ? cellBands(dayEditor.tech.id, dayEditor.day.iso) : []
// Blocs RECALCULÉS depuis la SÉQUENCE éditée (packedDay) l'ordre + les durées + le transport se reflètent + plus d'overlap.
const dayBlocks = () => packedDay.value.map(p => ({ s: p.startMin, e: p.endMin, skill: p.skill }))
// Réordonnancement : flèches (fiable) + drag-drop basé sur le DROP (robuste, pas de splice live jittery)
function moveDayJob (i, dir) { const j = i + dir; const l = dayEditor.list; if (j < 0 || j >= l.length) return; const [x] = l.splice(i, 1); l.splice(j, 0, x) }
function dayDragStart (i, ev) { dayEditor.dragIdx = i; try { ev.dataTransfer.effectAllowed = 'move'; ev.dataTransfer.setData('text/plain', String(i)) } catch (e) {} }
function dayDropOn (i) { const from = dayEditor.dragIdx; if (from == null || from === i) { dayEditor.dragIdx = null; return } const l = dayEditor.list; const [x] = l.splice(from, 1); l.splice(i, 0, x); dayEditor.dragIdx = null }
function dayDragEnd () { dayEditor.dragIdx = null }
// Durée éditable en MINUTES (pas de 5) best practice de précision
function jobMinutes (j) { return Math.round((Number(j.dur) || 0) * 60) }
function setJobMinutes (j, min) { const m = Math.max(5, Math.round((Number(min) || 0) / 5) * 5); j.dur = Math.round(m / 60 * 100) / 100 }
// Temps de transport estimé entre 2 jobs (haversine via coords Service Location) provisoire, en attendant la géoloc live (Capacitor)
function haversineKm (la1, lo1, la2, lo2) { if ([la1, lo1, la2, lo2].some(v => v == null)) return null; const R = 6371; const r = x => x * Math.PI / 180; const dLa = r(la2 - la1); const dLo = r(lo2 - lo1); const s = Math.sin(dLa / 2) ** 2 + Math.cos(r(la1)) * Math.cos(r(la2)) * Math.sin(dLo / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(s)) }
function travelBetween (a, b) {
if (!a || !b) return null
const hit = dayEditor.travelMap && dayEditor.travelMap[a.name + '>' + b.name]
if (hit) return hit // temps routier RÉEL (Mapbox Matrix)
const km = haversineKm(a.lat, a.lon, b.lat, b.lon); if (km == null) return null
return { km: Math.round(km * 10) / 10, min: Math.max(5, Math.round(km / 40 * 60) + 5), real: false } // repli : 40 km/h + 5 min tampon (vol d'oiseau)
}
function dayLeg (i) { return i > 0 ? travelBetween(dayEditor.list[i - 1], dayEditor.list[i]) : null } // trajet vers le job i depuis le précédent
const fmtHM = (h) => { if (h == null) return '—'; const m = Math.round(h * 60); const hh = Math.floor(m / 60), mm = m % 60; return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') } // heure décimale HH:MM (padded, pour start_time)
function dayShiftStartH () { const t = dayEditor.tech, d = dayEditor.day; if (!t || !d) return 8; const w = winOf(t.id, d.iso, false); return w ? w.s : 8 }
// PLANIFICATEUR DE TOURNÉE : recalcule les heures depuis l'ordre de la liste + durées + transport.
// Job verrouillé (RDV fixe) garde son heure ; flexible enchaîné après le précédent (+ transport). Plus d'overlap.
const packedDay = computed(() => {
const list = dayEditor.list; const out = []; let cursor = dayShiftStartH()
for (let i = 0; i < list.length; i++) {
const j = list[i]; const dur = Number(j.dur) || 1
const start = (j.locked && j.start_h != null) ? j.start_h : cursor
const end = start + dur
out.push({ ...j, startMin: start, endMin: end })
const trH = (i < list.length - 1 ? (travelBetween(j, list[i + 1]) || {}).min || 0 : 0) / 60
cursor = Math.max(cursor, end) + trH
}
return out
})
const hasLL = (j) => j && j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon)
const dayNoCoord = computed(() => dayEditor.list.filter(j => !hasLL(j)).length)
// Carte INTERACTIVE de la journée (Mapbox GL) : itinéraire ROUTIER réel (API Directions) + pins
// numérotés dans l'ordre de tournée. Navigable : zoom molette + boutons (NavigationControl), déplacement.
const dayMapEl = ref(null)
let _dayMap = null; let _dayMapRO = null; let _dirTimer = null
function ensureMapbox () {
return new Promise((resolve) => {
if (!document.getElementById('mapbox-css')) { const l = document.createElement('link'); l.id = 'mapbox-css'; l.rel = 'stylesheet'; l.href = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'; document.head.appendChild(l) }
if (window.mapboxgl) return resolve(window.mapboxgl)
let s = document.getElementById('mapbox-js')
if (!s) { s = document.createElement('script'); s.id = 'mapbox-js'; s.src = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'; document.head.appendChild(s) }
s.addEventListener('load', () => resolve(window.mapboxgl))
const iv = setInterval(() => { if (window.mapboxgl) { clearInterval(iv); resolve(window.mapboxgl) } }, 150)
})
}
const _esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
function dayStops () { // arrêts géolocalisés, dans l'ordre de tournée (packedDay) + infos pour le popup
return packedDay.value.filter(hasLL).map((j, i) => ({
lon: +j.lon, lat: +j.lat, label: String(i + 1), color: j.skill ? getTagColor(j.skill) : '#1976d2',
subject: j.subject || '', customer: j.customer || '', time: fmtHM(j.startMin) + '' + fmtHM(j.endMin),
}))
}
// Segments de DÉPLACEMENT (pointillés) = l'espace entre 2 jobs dans la barre timeline.
const dayTravelSegs = () => { const p = packedDay.value; const out = []; for (let i = 0; i < p.length - 1; i++) { const s = p[i].endMin, e = p[i + 1].startMin; if (e - s > 0.02) out.push({ s, e }) } return out }
// Centre la carte sur un job (clic sur la ligne de la liste).
function focusDayJob (j) { if (_dayMap && hasLL(j)) _dayMap.easeTo({ center: [+j.lon, +j.lat], zoom: 14, duration: 500 }) }
async function initDayMap () {
if (!MAPBOX_TOKEN || !dayMapEl.value || _dayMap) return
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !dayMapEl.value) return
mapboxgl.accessToken = MAPBOX_TOKEN
_dayMap = new mapboxgl.Map({ container: dayMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.6756, 45.1599], zoom: 9 })
_dayMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right') // boutons zoom +/-
_dayMapRO = new ResizeObserver(() => { if (_dayMap) _dayMap.resize() }); _dayMapRO.observe(dayMapEl.value)
_dayMap.on('load', () => {
_dayMap.resize()
_dayMap.addSource('day-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
_dayMap.addLayer({ id: 'day-route-halo', type: 'line', source: 'day-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#3b5bdb', 'line-width': 9, 'line-opacity': 0.2 } })
_dayMap.addLayer({ id: 'day-route', type: 'line', source: 'day-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#3b5bdb', 'line-width': 4, 'line-opacity': 0.85 } })
_dayMap.addSource('day-stops', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
_dayMap.addLayer({ id: 'day-stops-c', type: 'circle', source: 'day-stops', paint: { 'circle-radius': 13, 'circle-color': ['get', 'color'], 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' } })
_dayMap.addLayer({ id: 'day-stops-l', type: 'symbol', source: 'day-stops', layout: { 'text-field': ['get', 'label'], 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-size': 12, 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } })
// Clic sur un pin popup avec les détails du job ; curseur main au survol.
_dayMap.on('mouseenter', 'day-stops-c', () => { _dayMap.getCanvas().style.cursor = 'pointer' })
_dayMap.on('mouseleave', 'day-stops-c', () => { _dayMap.getCanvas().style.cursor = '' })
_dayMap.on('click', 'day-stops-c', (e) => {
const f = e.features[0]; const p = f.properties
new window.mapboxgl.Popup({ offset: 14 }).setLngLat(f.geometry.coordinates)
.setHTML(`<div style="font-size:12px;line-height:1.45"><b>${_esc(p.label)}. ${_esc(p.subject)}</b><br>🕒 ${_esc(p.time)}${p.customer ? '<br>👤 ' + _esc(p.customer) : ''}</div>`)
.addTo(_dayMap)
})
refreshDayMap()
})
}
function refreshDayMap () {
if (!_dayMap || !_dayMap.isStyleLoaded()) { setTimeout(refreshDayMap, 200); return }
const stops = dayStops()
const sSrc = _dayMap.getSource('day-stops')
if (sSrc) sSrc.setData({ type: 'FeatureCollection', features: stops.map(s => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [s.lon, s.lat] }, properties: { label: s.label, color: s.color } })) })
if (stops.length === 1) _dayMap.easeTo({ center: [stops[0].lon, stops[0].lat], zoom: 13, duration: 400 })
else if (stops.length > 1) {
const b = new window.mapboxgl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat])
stops.forEach(s => b.extend([s.lon, s.lat]))
_dayMap.fitBounds(b, { padding: 45, maxZoom: 14, duration: 400 })
}
fetchDayRouteGeom(stops)
}
async function fetchDayRouteGeom (stops) { // itinéraire ROUTIER réel (Directions) tracé sur la carte
const rSrc = _dayMap && _dayMap.getSource('day-route'); if (!rSrc) return
if (!stops || stops.length < 2) { rSrc.setData({ type: 'FeatureCollection', features: [] }); return }
try {
const pts = stops.slice(0, 25).map(s => `${s.lon.toFixed(6)},${s.lat.toFixed(6)}`).join(';')
const url = `https://api.mapbox.com/directions/v5/mapbox/driving-traffic/${pts}?overview=full&geometries=geojson&access_token=${MAPBOX_TOKEN}`
const r = await fetch(url); if (!r.ok) throw new Error('dir ' + r.status)
const d = await r.json(); const geom = d.routes && d.routes[0] && d.routes[0].geometry
const src = _dayMap && _dayMap.getSource('day-route')
if (geom && src) src.setData({ type: 'Feature', geometry: geom, properties: {} })
} catch (e) { /* repli : pas de tracé routier (les pins restent visibles) */ }
}
function destroyDayMap () {
if (_dirTimer) { clearTimeout(_dirTimer); _dirTimer = null }
if (_dayMapRO) { _dayMapRO.disconnect(); _dayMapRO = null }
if (_dayMap) { try { _dayMap.remove() } catch (e) {} _dayMap = null }
}
// (ré)init à l'ouverture du dialogue (après l'anim) ; refresh débouncé au réordonnancement ; destruction à la fermeture.
watch(() => dayEditor.open, (open) => { if (open) nextTick(() => setTimeout(initDayMap, 250)); else destroyDayMap() })
watch(() => dayEditor.list.map(j => j.name).join(','), () => { if (_dayMap) { clearTimeout(_dirTimer); _dirTimer = setTimeout(refreshDayMap, 500) } })
const dayTotalH = () => Math.round(dayEditor.list.reduce((s, j) => s + (Number(j.dur) || 0), 0) * 10) / 10
async function removeFromDay (j) {
try { await roster.unassignJobRoster(j.name); dayEditor.list = dayEditor.list.filter(x => x.name !== j.name); await loadWeek(); $q.notify({ type: 'info', message: 'Retiré du tech (retour au pool « à assigner »)', timeout: 2200 }) } catch (e) { err(e) }
}
async function saveDayOrder () {
dayEditor.saving = true
const packed = packedDay.value // heures recalculées par la tournée on les persiste (start_time)
const updates = dayEditor.list.map((j, i) => ({ job: j.name, route_order: i + 1, priority: j.priority, duration_h: Number(j.dur) || 1, start_time: fmtHM(packed[i].startMin) }))
try { const r = await roster.reorderJobs(updates); dayEditor.open = false; await loadWeek(); $q.notify({ type: 'positive', message: 'Tournée enregistrée — ordre · heures · durées (' + (r.updated || 0) + ')', timeout: 2400 }) } catch (e) { err(e) } finally { dayEditor.saving = false }
}
const timelineDays = computed(() => {
const t = timelineDlg.tech; if (!t) return []
const out = []
for (const d of dayList.value) {
const jobs = cellJobs(t.id, d.iso); const shift = hasReg(t.id, d.iso) || onGarde(t.id, d.iso)
const shift = hasReg(t.id, d.iso) || onGarde(t.id, d.iso)
const jobs = shift ? cellJobs(t.id, d.iso) : rawCellJobs(t.id, d.iso) // hors quart : jobs bruts
if (!jobs.length && !shift) continue // on saute les jours vides
const o = cellOcc(t.id, d.iso)
out.push({ iso: d.iso, label: d.dow + ' ' + d.dnum, weekend: d.weekend, bands: cellBands(t.id, d.iso), blocks: cellBlocks(t.id, d.iso), jobs, pct: cellPct(t.id, d.iso), usedH: o ? o.usedH : 0 })
const usedH = shift ? (o ? o.usedH : 0) : Math.round(jobs.reduce((s, j) => s + (j.dur || 0), 0) * 10) / 10
out.push({ iso: d.iso, label: d.dow + ' ' + d.dnum, weekend: d.weekend, bands: cellBands(t.id, d.iso), blocks: cellBlocks(t.id, d.iso), jobs, pct: shift ? cellPct(t.id, d.iso) : null, usedH, offShift: !shift && jobs.length > 0 })
}
return out
})
@ -946,6 +1230,9 @@ function hashColor (label) { let h = 0; for (const c of String(label)) h = (h *
const customTags = ref([]) // [{label,color}] créés à la volée (localStorage)
function saveCustomTags () { localStorage.setItem('roster-skill-tags-v1', JSON.stringify(customTags.value)) }
function getTagColor (label) { const ct = customTags.value.find(x => x.label === label); return (ct && ct.color) || hashColor(label) }
// Couleur d'une carte job = COULEUR DE SA COMPÉTENCE (éditable via le gestionnaire de tags cohérent + simple).
// required_skill est renseigné côté hub (skill explicite, sinon déduit du type legacy). Repli : couleur du type.
function panelJobColor (j) { return j.required_skill ? getTagColor(j.required_skill) : (legacyDeptColor(j.legacy_dept) || '#90a4ae') }
const tagCatalog = computed(() => {
const m = new Map()
for (const ct of customTags.value) m.set(ct.label, { name: ct.label, label: ct.label, color: ct.color || hashColor(ct.label), category: 'Custom' })
@ -1129,7 +1416,8 @@ function cellBands (techId, iso) {
}
// Barre de statut OPAQUE selon l'occupation : vert (peu) orange (plein) rouge (surbooké).
function occColor (pct) { if (pct == null) return '#9e9e9e'; if (pct >= 100) return '#e53935'; const t = Math.max(0, Math.min(1, pct / 100)); return 'hsl(' + Math.round(122 - t * 90) + ',68%,44%)' }
function blockStyle (blk, pct) { return { ...pos(blk.s, Math.min(blk.e, 24)), background: occColor(pct) } }
// Bloc = 1 job, coloré par la COULEUR DE SA COMPÉTENCE (palette skills éditable). Repli : couleur d'occupation.
function blockStyle (blk, pct) { return { ...pos(blk.s, Math.min(blk.e, 24)), background: blk && blk.skill ? getTagColor(blk.skill) : occColor(pct) } }
// Fenêtre des shifts (garde=true seulement les quarts de garde ; garde=false réguliers)
function winOf (techId, iso, garde) { let s = Infinity; let e = -Infinity; for (const a of cellsOf(techId, iso)) { const t = tplByName.value[a.shift]; if (!t || (!!t.on_call) !== garde) continue; const st = hToNum(t.start_time); const en = hToNum(t.end_time); if (st != null) s = Math.min(s, st); if (en != null) e = Math.max(e, en) } return isFinite(s) ? { s, e } : null }
const occCells = computed(() => {
@ -1148,6 +1436,9 @@ function hasReg (techId, iso) { return cellsOf(techId, iso).some(a => { const t
function cellBlocks (techId, iso) { const o = cellOcc(techId, iso); return o ? o.blocks : [] }
function cellPct (techId, iso) { const o = cellOcc(techId, iso); return o ? o.pct : null }
function cellJobs (techId, iso) { const o = cellOcc(techId, iso); return o ? (o.jobs || []) : [] } // jobs du jour, déjà triés prioritéheure côté hub
function rawCellJobs (techId, iso) { const o = occByTechDay.value[techId + '|' + iso]; return o ? (o.jobs || []) : [] } // jobs BRUTS (inclut les jours SANS quart publié)
function offShiftJobs (techId, iso) { return (hasReg(techId, iso) || onGarde(techId, iso)) ? [] : rawCellJobs(techId, iso) } // jobs assignés un jour où le tech n'a AUCUN quart publié
const offShiftWeekCount = computed(() => { let n = 0; for (const t of visibleTechs.value) for (const d of dayList.value) n += offShiftJobs(t.id, d.iso).length; return n }) // total jobs hors quart sur la période visible
function prioColor (p) { return p === 'urgent' ? '#ef4444' : p === 'high' ? '#f59e0b' : p === 'medium' ? '#6366f1' : '#9e9e9e' }
// Aperçu en survol de drop : occupation projetée si on dépose la sélection ici.
function isDropTarget (techId, iso) { return dropPreview.key === techId + '|' + iso }
@ -1556,8 +1847,11 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
/* Panneau flottant « jobs à assigner » (déplaçable, glisser-déposer) */
.assign-panel { position: fixed; z-index: 5000; width: 320px; max-height: 72vh; background: #fff; border: 1px solid #cfd8dc; border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,.24); display: flex; flex-direction: column; }
.assign-hdr { display: flex; align-items: center; gap: 5px; padding: 6px 10px; background: #5e35b1; color: #fff; border-radius: 8px 8px 0 0; cursor: move; font-weight: 600; font-size: 13px; user-select: none; }
.assign-sortbar { display: flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: 11px; color: #555; background: #f3f0fa; border-bottom: 1px solid #e0e0e0; }
.assign-sortbar select { font-size: 11px; border: 1px solid #cfc4e8; border-radius: 5px; padding: 1px 4px; background: #fff; color: #333; flex: 1; }
.assign-body { overflow: auto; padding: 5px; }
.assign-grp { margin-bottom: 6px; border-radius: 7px; padding: 2px; }
.assign-grp-lbl { font-size: 11px; font-weight: 700; color: #37474f; padding: 3px 6px 2px; border-bottom: 1px solid #eee; margin-bottom: 2px; position: sticky; top: 0; background: #fff; z-index: 1; }
.assign-grp.grp-hl { background: #ede7f6; box-shadow: inset 0 0 0 1px #b39ddb; } /* groupe lié surligné dès qu'un membre est coché */
.assign-grp-hdr { font-size: 10px; font-weight: 700; color: #5e35b1; padding: 2px 6px; cursor: pointer; display: flex; align-items: center; gap: 3px; }
.assign-grp-hdr:hover { text-decoration: underline; }
@ -1622,14 +1916,35 @@ tr.res-hidden .hide-eye { opacity: 1; }
.cell-dirty-demo { display: inline-block; min-width: 18px; padding: 0 5px; border-radius: 4px; font-weight: 700; font-size: 11px; background: #1976d2; color: #fff; box-shadow: inset 0 0 0 2px #ff9800; }
.ch-h { opacity: .7; font-weight: 400; font-size: 9px; margin-left: 1px; }
.free { color: #ccc; }
.offshift-warn { display: inline-flex; align-items: center; gap: 1px; font-size: 10px; font-weight: 700; color: #ef6c00; cursor: pointer; line-height: 1; } /* job assigné un jour sans quart publié */
.hdr-ruler { position: relative; height: 11px; margin-top: 3px; }
.hdr-ruler .tick { position: absolute; top: 2px; transform: translateX(-50%); font-size: 8px; color: #aab; line-height: 1; font-weight: 400; }
.hdr-ruler .tick::before { content: ''; position: absolute; top: -3px; left: 50%; width: 1px; height: 2px; background: #d0d0d8; }
.tl { position: relative; height: 11px; min-width: 64px; background: #f1f3f5; border-radius: 2px; margin: 2px 0; overflow: hidden; }
.tl-click { cursor: pointer; } /* clic sur le progressbar → menu jobs (détail + réordonner) */
.tl-click:hover { outline: 1px solid #1976d2; outline-offset: 1px; }
/* Éditeur de journée (clic progressbar) — lignes draggables */
.de-row { display: flex; align-items: center; gap: 8px; padding: 5px 4px; border-bottom: 1px solid #eee; background: #fff; cursor: default; }
.de-row.de-drag { opacity: .5; background: #ede7f6; }
.de-row:hover { background: #f7f5fc; }
.de-ord { font-size: 12px; font-weight: 700; color: #607d8b; min-width: 16px; text-align: center; }
.de-dot { width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto; }
/* minimap du jour (territoire des arrêts) */
.de-map-wrap { margin: 8px 0 4px; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0; }
.de-map-gl { width: 100%; height: 240px; }
.de-map-cap { font-size: 10px; color: #777; padding: 3px 6px; background: #fafafa; border-top: 1px solid #eee; }
.de-prio { font-size: 11px; border: 1px solid #ccc; border-left-width: 4px; border-radius: 4px; padding: 2px 4px; background: #fff; }
.de-dur { display: flex; align-items: center; gap: 2px; font-size: 10px; color: #888; }
.de-dur input { width: 46px; font-size: 11px; text-align: right; border: 1px solid #cfc4e8; border-radius: 4px; padding: 2px 3px; }
.de-travel { font-size: 10px; color: #8a6d3b; padding: 1px 0 1px 40px; opacity: .85; } /* espace entre 2 jobs = transport */
.de-detail { font-size: 11px; line-height: 1.4; white-space: pre-wrap; color: #444; background: #f7f5fc; border-left: 3px solid #b39ddb; border-radius: 4px; margin: 0 4px 6px 40px; padding: 6px 8px; max-height: 160px; overflow: auto; }
.tl-shift { position: absolute; top: 0; bottom: 0; background: #ccd2d8; border-radius: 2px; border: 1px solid rgba(55,65,120,.5); box-sizing: border-box; } /* fenêtre dispo (contour foncé pour la distinguer du fond) */
.tl-shift.oncall { background: rgba(255,179,0,.14); border: 1px dashed #f9a825; } /* garde = sur appel hors heures (pointillé ambre) */
.tl-absent { position: absolute; inset: 0; border-radius: 2px; box-sizing: border-box; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg, #cfcfcf 0, #cfcfcf 3px, #f0f0f0 3px, #f0f0f0 6px); } /* absent = hachuré gris */
.tl-blk { position: absolute; top: 0; bottom: 0; border-radius: 1px; } /* occupé = barre de statut opaque */
.tl-travel { position: absolute; top: 0; bottom: 0; background-image: repeating-linear-gradient(90deg, #78909c 0 3px, transparent 3px 7px); background-size: 100% 3px; background-repeat: no-repeat; background-position: 0 center; opacity: .85; } /* déplacement = pointillés */
.tl-blk-click { cursor: pointer; } /* seuls les blocs de job ouvrent l'éditeur ; le reste de la cellule = édition d'horaire */
.tl-blk-click:hover { outline: 1px solid rgba(25,118,210,.7); outline-offset: -1px; filter: brightness(1.08); }
.tod-leg { display: inline-block; width: 46px; height: 9px; border-radius: 2px; vertical-align: middle; background: linear-gradient(to right, hsl(210,45%,91%), hsl(270,45%,83%)); }
.occ-leg { display: inline-block; width: 46px; height: 9px; border-radius: 2px; vertical-align: middle; background: linear-gradient(to right, hsl(122,68%,44%), hsl(32,68%,44%)); }
.leg-absent { display: inline-block; width: 24px; height: 9px; border-radius: 2px; vertical-align: middle; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg,#cfcfcf 0,#cfcfcf 3px,#f0f0f0 3px,#f0f0f0 6px); }

View File

@ -39,6 +39,7 @@ const routes = [
{ 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: 'conformite-adresses', component: () => import('src/pages/AddressConformityPage.vue') },
{ path: 'rdv', component: () => import('src/pages/RendezVousPage.vue') },
{ path: 'copilote', component: () => import('src/pages/CopilotePage.vue') },
{ path: 'agent-flows', component: () => import('src/pages/AgentFlowsPage.vue') },

View File

@ -56,6 +56,10 @@ export const useDispatchStore = defineStore('dispatch', () => {
sourceIssue: j.source_issue || null,
dependsOn: j.depends_on || null,
jobType: j.job_type || null,
legacyDept: j.legacy_dept || null, // département osTicket legacy → coloriage par type
legacyTicketId: j.legacy_ticket_id || null, // n° ticket legacy (affiché dans le panneau détail)
legacyActivationUrl: j.legacy_activation_url || null, // lien connect_ministra (activation STB TV)
legacyDetail: j.legacy_detail || null, // description/contenu du ticket legacy (1er message du fil)
parentJob: j.parent_job || null,
stepOrder: j.step_order || 0,
onOpenWebhook: j.on_open_webhook || null,

View File

@ -0,0 +1,76 @@
# Best practices — « ne rien échapper » + automatiser au maximum
Cadre d'ingénierie pour la plateforme TARGO (ERPNext/PG · targo-hub Node · Ops Quasar · PWA tech ·
portail Lovable · ponts legacy MariaDB/osTicket/Ministra · intégrations Stripe/Twilio/GenieACS/Forti).
Objectif double : **fiabilité (rien ne tombe entre les craques)** + **automatisation closed-loop**.
> Principe directeur : **toute action inter-systèmes doit être (1) idempotente, (2) observable, (3) réconciliée.**
> Toute automatisation doit être **closed-loop** : agir → vérifier le résultat → réessayer/alerter si échec.
---
## A. Intégrité des données — « rien ne tombe »
1. **Une seule source de vérité par domaine.** Facturation = legacy jusqu'au cutover ; dispatch = ERPNext ; provisioning TV = Ministra. Jamais deux plumes sur la même donnée (cf. analyse Ministra : un 2ᵉ chemin = double facturation).
2. **Idempotence partout** : clé naturelle externe sur chaque objet importé/synchronisé (déjà fait : `legacy_ticket_id`). Étendre aux webhooks Stripe (event.id), SMS, provisioning.
3. **Pattern Outbox** pour les écritures « locale + appel externe » : écrire l'intention en base d'abord, un worker la pousse et marque `done`/`failed`. Évite « écrit en local mais l'API a échoué » (le risque exact du wizard Ministra).
4. **Réconciliation périodique** : un job qui compare source ↔ ERPNext et signale la dérive (ex. tickets `assign_to=3301` côté legacy vs Dispatch Jobs créés ; abonnements Ministra vs `service`). Le pont crée — la réconciliation prouve qu'**aucun n'a été échappé**.
5. **Soft-delete + audit trail** : ne jamais perdre une donnée ; tracer qui/quand/quoi (ERPNext le fait nativement ; l'exposer côté hub pour les actions du hub).
## B. Observabilité — « on SAIT quand ça casse »
1. **Logs structurés** (JSON, pino) avec `correlation_id` par requête/job — finis les `console.log`. Un job legacy-sync = une ligne `{job, created, errors, ms}` traçable.
2. **`/healthz` + `/readyz`** sur le hub (DB ok ? ERPNext ok ? legacy ok ?) → branchés sur **Uptime-Kuma** (déjà en place pour le réseau).
3. **Heartbeat sur CHAQUE tâche planifiée** : le legacy-sync, le PPA cron, les pollers doivent « pinger » à chaque tick ; si un tick manque → alerte. (Un cron muet qui meurt = des tickets non importés sans que personne le sache.)
4. **Alerting** : erreurs (Sentry/GlitchTip self-host) + métriques (Prometheus + Grafana, ou au minimum un endpoint `/metrics`). Alertes vers le canal ops existant (Twilio/email).
5. **Bannir le `catch {}` muet** : tout catch logge + incrémente un compteur d'erreurs. (Plusieurs `catch (e) {}` actuels avalent des échecs silencieusement.)
## C. Fiabilité des intégrations
1. **Retry + backoff exponentiel** (généraliser `retryWrite` à tous les appels externes Ministra/Twilio/Stripe/GenieACS), avec **plafond** et **dead-letter** (file des échecs définitifs à rejouer).
2. **Webhooks idempotents + signés + rejouables** (Stripe a déjà event.id ; vérifier la signature, stocker l'event, rejeu manuel possible).
3. **Circuit breaker** sur un service externe en panne (ne pas marteler Ministra/legacy en boucle → respecte aussi fail2ban legacy).
4. **Timeouts explicites** sur tous les appels réseau (déjà fait sur la DB legacy).
5. **Dégradation gracieuse** : si Ministra est down, le job reste « à activer » et alerte — jamais d'échec silencieux.
## D. Tests + CI/CD — « le scp manuel doit disparaître »
1. **Pipeline Gitea Actions** par push : `lint``test``build` → deploy auto (staging puis prod sur tag). Remplace les `scp` à la main (source d'erreurs + pas de gate).
2. **Tests** par couche : unitaires sur la logique pure (mapping pont, `jobColor`, `firstFitStart`, calculs facturation) ; intégration sur les endpoints hub (avec ERPNext/MariaDB de test) ; quelques E2E (Playwright) sur les parcours critiques (dispatch, booking, checkout).
3. **Environnement de staging** iso-prod (« on est en lab » → formaliser un vrai staging séparé de la prod).
4. **Migrations versionnées** (les Custom Fields via script idempotent → bon ; versionner toutes les migrations ERPNext + schéma).
5. **Rollback en 1 commande** (garder les N derniers bundles ; le déploiement actuel écrase — conserver l'ancien pour rollback instantané).
## E. Sécurité & secrets
1. **Gestion centralisée des secrets** (Vault/Infisical/ou au moins des `.env` chiffrés + rotation) — aujourd'hui dispersés (creds Ministra **en dur dans le PHP legacy**, tokens en `.env`).
2. **Moindre privilège** : la DB legacy en SELECT-only (bien) ; appliquer partout. Les liens tech non authentifiés (`reply_ticket.php`, `connect_ministra.php`) → tokens à expiration à terme.
3. **Audit d'accès** + 2FA sur les surfaces admin (Authentik déjà là — généraliser).
4. **Sauvegardes testées + DR** : backup auto ERPNext PG + MariaDB legacy + restauration testée (un backup jamais restauré n'existe pas).
## F. Automatisation closed-loop — « le plus possible »
1. **Événementiel > polling** quand possible : le pont legacy poll 15 min → ajouter un trigger côté legacy (webhook/INSERT trigger → ping hub) pour le quasi-temps-réel. Garder le poll comme filet de sécurité.
2. **Auto-dispatch** : le solveur roster (déjà là) propose ; viser l'assignation auto des jobs simples + suggestion classée pour le reste (proximité quand la géo arrivera).
3. **Réconciliation auto-corrective** (self-healing) : un job qui re-crée/re-aligne ce qui a dérivé, plutôt qu'un simple rapport.
4. **Moteur de flux** : `agent-flows` existe déjà → en faire le squelette des automatisations métier (relance, escalade SLA, activation, avis client) plutôt que du code ad hoc dispersé.
5. **SLA monitors** : alerte si un job reste non-assigné > X h, un ticket TV non-activé > X j, un paiement non réconcilié, un client hors quart (déjà le badge ⏰).
6. **AI ciblée** (déjà : OCR, copilote, outage) → étendre : matching compétence↔type de cas (historique), triage/priorisation des tickets, détection d'anomalies de facturation.
---
## Roadmap priorisée (incrémental, sans tout casser)
**Quick wins (jours) — fiabilité immédiate**
- [ ] `/healthz` + heartbeat du legacy-sync → Uptime-Kuma + alerte si tick manqué.
- [ ] Logs structurés + suppression des `catch {}` muets (au moins compter + logguer).
- [ ] Endpoint `/dispatch/legacy-sync/reconcile` : compte legacy(3301,open) vs Dispatch Jobs → signale les manquants.
- [ ] Conserver le bundle N-1 au déploiement (rollback instantané).
**Court terme (semaines) — arrêter l'érosion**
- [ ] Pipeline Gitea Actions (lint+build+deploy) → fin du scp manuel + staging réel.
- [ ] Premiers tests unitaires sur la logique pure (pont, jobColor, firstFitStart, facturation).
- [ ] Retry+backoff+dead-letter généralisés (Ministra/Twilio/Stripe).
- [ ] Sentry/GlitchTip self-host + alerting.
**Stratégique (mois) — automatiser & sécuriser**
- [ ] Outbox + réconciliation auto-corrective sur les ponts.
- [ ] Événementiel (triggers legacy → hub) + SLA monitors via agent-flows.
- [ ] Gestion centralisée des secrets + rotation + DR testé.
- [ ] Cutover progressif des sources de vérité (legacy → ERPNext), domaine par domaine.
> Règle d'or pour « ne rien échapper » : **si une action peut échouer silencieusement, elle DOIT être (a) réessayée, (b) mise en dead-letter, et (c) visible sur un tableau de bord.** Aucune exception.

129
docs/architecture/VISION.md Normal file
View File

@ -0,0 +1,129 @@
# Vision, modularisation & optimisation — Plateforme TARGO / Gigafibre
> Document stratégique. Complète `overview.md`, `app-design.md`, `data-model.md`, `module-interactions.md`
> et `../ENGINEERING_PRACTICES.md`. Objectif : passer d'une croissance organique (god-files, modules plats)
> à une architecture **modulaire par domaine** avec des **sources de vérité** fiables — pour livrer vite ET sûr.
## 0. Vision en une phrase
Un **hub d'orchestration léger** (Node) au-dessus d'**ERPNext** (ERP/source de données), organisé en
**domaines métier autonomes** (bounded contexts), chacun avec sa **source de vérité canonique**, sa
**validation à la saisie** et ses **liens stables** — pendant qu'on **éteint progressivement le legacy**.
---
## 1. État actuel (audit chiffré, 2026-06-07)
| Composant | Volume | Plus gros fichiers (god-files) |
|---|---|---|
| **targo-hub** (Node) | 58 modules, ~23 000 lignes, 52 routes (lazy-require) | campaigns 2366 · payments 1376 · network-intel 1221 · contracts 1091 · roster 1040 · tech-mobile 961 · dispatch 852 |
| **Ops SPA** (Vue3/Quasar) | ~45 000 lignes | ProjectWizard 2891 · DispatchPage 2162 · PlanificationPage 1863 · NetworkPage 1829 · ClientDetailPage 1715 |
**Forces** : hub mono-process simple (routage `path.startsWith` + `require` paresseux), ERPNext comme socle
données, frappe_pg (PostgreSQL), intégrations riches (legacy MariaDB, Ministra, GenieACS, OLT SNMP, Stripe,
Mailjet, Authentik, Karrio), base d'adresses RQA **locale** (5,24 M) déjà branchée.
**Dettes techniques** (priorisées plus bas) :
1. **God-files** : 5 fichiers front >1700 lignes, 2 back >1300 — mélangent UI/état/règles métier/I/O.
2. **Modules plats sans frontière de domaine** : 58 fichiers `lib/*.js` au même niveau ; couplage implicite.
3. **Helpers réimplémentés** : `norm()` (accents), `cors()`, `haversine()`, `stripHtml()` dupliqués dans
~6 modules (address-*, legacy-dispatch-sync, roster, serviceability, tech-absence-sms). *(cors/pool : consolidés ✅)*
4. **Pas de tests** ni de **CI/CD** (déploiement = `scp` manuel + `docker restart`).
5. **Observabilité inégale** : `catch {}` muets par endroits ; heartbeat/réconciliation présents seulement
sur le pont legacy. `erp.create/update` ne *throw* pas → des erreurs ont déjà été comptées comme succès.
---
## 2. Principe directeur : Domaines + Source de vérité
Deux règles structurent toute la suite :
**(A) Découper par DOMAINE (bounded context), pas par fichier.** Chaque domaine expose une interface
publique étroite (un `index` + des services) ; le reste est interne. Le routage du hub délègue au domaine.
**(B) Chaque entité a UNE source de vérité + validation à la saisie + lien stable.** On ne re-cherche pas
avec un processus complexe à chaque fois : on **résout une fois**, on **persiste le lien canonique**, le
downstream **lit le lien**. Modèle de référence : les **adresses** (cf. `reference/` + page Conformité) —
`rqa_addresses` (RQA local) ← `aq_address_id`/`linked_address`/`address_validation_status` sur Service Location.
---
## 3. Carte des domaines cible
Regroupement des 58 modules hub + pages Ops en **9 domaines**. (Refactor par déplacement progressif, pas big-bang.)
| Domaine | Hub (lib/…) | Ops (pages/modules) | Source de vérité |
|---|---|---|---|
| **Identité & Accès** | auth, (Authentik) | usePermissions, MainLayout | Authentik + Capabilities |
| **CRM / Clients** | store, **address-db/validate/conformity**, address-search | Clients, ClientDetail | Customer (ERPNext) · **Adresse = RQA** |
| **Dispatch & Terrain** | dispatch, roster, tech-mobile, legacy-dispatch-sync, (geo/routing Mapbox) | Dispatch, Planification, RendezVous, module tech | Dispatch Job · Shift/Roster |
| **Réseau & Infra** | network-intel, olt-snmp, devices, outage-monitor, oktopus, (genieacs) | Network | GenieACS (CPE) · OLT · device |
| **Facturation & Paiements** | payments, contracts, acceptance, store/checkout | Rapports, ContratBLB | **Legacy billing (autoritaire jusqu'au cutover)** → ERPNext |
| **Marketing & Campagnes** | campaigns, offers, ai (traduction) | module campaigns | Campaign · Gift (Giftbit) |
| **IA & Agent** | ai, agent, voice-agent, flow-runtime, conversation | Copilote, AgentFlows | Agent flows |
| **Intégrations & Legacy** | erp, legacy DB, Ministra, Karrio, Giftbit | — | Adaptateurs (anti-corruption layer) |
| **Plateforme** | helpers, config, observabilité | composables partagés, components/shared | — |
**Cible d'arborescence hub** (itératif) : `lib/<domaine>/<module>.js` + `lib/<domaine>/index.js` (interface
publique) ; `lib/util/` (norm, cors, geo, html) ; `server.js` route vers `require('./lib/<domaine>')`.
**Cible Ops** : `src/modules/<domaine>/` (pages + composants + composables + api du domaine), comme
`modules/campaigns` et `modules/tech` le font déjà — étendre ce pattern à dispatch, roster, clients, network.
---
## 4. Optimisations priorisées
### P0 — Hygiène (sûr, rapide, vérifiable au build)
- **Extraire les helpers partagés** dans `lib/util/` : `norm` (accents), `cors` ✅, `haversineKm`, `stripHtml`,
`tzDate`. Remplacer les ~6 réimplémentations. *(cors + pool address déjà consolidés.)*
- **Uniformiser le contrat handler** : `erp.create/update` renvoient `{ok}` → tout appelant DOIT vérifier
`r.ok` (déjà corrigé dans le pont ; auditer payments/contracts/store).
- **Supprimer le code mort** + `catch {}` muets → `log()`.
- **Boundary I/O** : un seul client `pg` partagé pour les lectures locales (address-db.pool, réutilisé ✅).
### P1 — Décomposition des god-files (par domaine, sans changer le comportement)
- Front : `ProjectWizard` (2891), `DispatchPage` (2162), `PlanificationPage` (1863), `ClientDetailPage` (1715)
→ extraire des **composables** (`use*`) + **sous-composants** (détail/sections). PlanificationPage : sortir
packedDay/route-planner, le panneau d'assignation, l'éditeur de journée en composables dédiés.
- Back : `campaigns` (2366) et `payments` (1376) → sous-modules de domaine (envoi, matching, suivi / facturation, rapprochement).
### P2 — Fiabilité & vélocité
- **Tests** (vitest) sur les modules à risque : paiements, pont legacy, conformité adresses, roster solver.
- **CI/CD** : remplacer `scp + docker restart` manuel par un pipeline (Gitea Actions) build+déploiement+santé.
- **Observabilité closed-loop** : généraliser le pattern `_lastRun`/`/status` + réconciliation (déjà sur le
pont) aux jobs critiques ; clés Uptime-Kuma.
---
## 5. Source de vérité — généralisation du pattern « Adresses »
| Entité | Source de vérité | Lien stable | État |
|---|---|---|---|
| **Adresse** | `rqa_addresses` (RQA local) + `fiber_availability` | `aq_address_id` + `linked_address` | ✅ FAIT (16 561 liées ; page Conformité) |
| **Client** | ERPNext Customer | `legacy_account_id` | partiel (matching legacy) |
| **Appareil/CPE** | GenieACS (serial/MAC réels) | MAC ↔ Service Equipment | piège connu (TPLG vs serial réel) |
| **Service TV** | Ministra (SID = id ligne `service` legacy) | `legacy_activation_url` | read-only (pas de 2e chemin d'écriture) |
| **Facturation** | **Legacy (autoritaire)** jusqu'au cutover | — | scheduler ERPNext en pause |
Prochaine application directe : finir le matching **Client** (legacy_account_id) et **Device** (MAC↔GenieACS)
sur le même modèle (résoudre une fois → persister → lire le lien).
---
## 6. Roadmap par phases
- **Phase 1 — Hygiène & fondations** : helpers partagés, code mort, `r.ok` partout, CI/CD minimal, tests des
modules critiques. *(Risque faible, gros gain de fiabilité.)*
- **Phase 2 — Modularisation** : déplacer hub `lib/*``lib/<domaine>/` ; décomposer les god-files front/back.
- **Phase 3 — Sources de vérité** : généraliser (Client, Device, Service) + observabilité closed-loop partout.
- **Phase 4 — Vision produit** : cutover facturation legacy→ERPNext ; app terrain Capacitor (GPS live des
unités, remplace le relevé manuel sur la carte) ; portail client self-service (abonnement + RDV + paiement).
---
## 7. Métriques de succès
- 0 fichier > 800 lignes (front et back).
- 0 helper dupliqué (`norm`/`cors`/`geo`/`html` centralisés).
- 100 % des entités clés avec source de vérité + validation à la saisie + lien persisté.
- Déploiement par pipeline (0 `scp` manuel) ; tests verts sur les modules critiques.
- Tout job inter-systèmes : idempotent + observable + réconcilié (cf. ENGINEERING_PRACTICES).

View File

@ -8,6 +8,7 @@ modes. Open the one that matches the feature you're changing.
|---|---|
| [dispatch.md](dispatch.md) | Ops dispatch board: drag-and-drop scheduling, tech assignment with skill tags, travel-time optimization, magic-link SMS issuance, live SSE updates |
| [roster.md](roster.md) | Planification (Roster AI): grille hebdo ressources × jours, garde live, solveur OR-Tools, scoring priorité (maîtrise⊕vitesse⊕coût), panneau « jobs à assigner » (drag-drop + aperçu occupation), timeline ressource, dialogues d'impact, booking roster-aware |
| [legacy-dispatch-bridge.md](legacy-dispatch-bridge.md) | Pont legacy→dispatch: tire régulièrement les tickets osTicket « Tech Targo » (staff 3301) de la MariaDB legacy → Dispatch Job ERPNext (idempotent via `legacy_ticket_id`), mapping client/Service Location/type/date, endpoints preview/run, scheduler opt-in |
| [tech-mobile.md](tech-mobile.md) | Field tech app (three surfaces: SSR `/t/{jwt}`, transitional `apps/field/`, unified `/ops/#/j/*`). Native camera → Gemini scanner, equipment install/remove, JWT auth, offline queue |
| [customer-portal.md](customer-portal.md) | Passwordless customer self-service at `portal.gigafibre.ca`: magic-link email (24h JWT), invoice + ticket view, Stripe-linked payment flows |
| [billing-payments.md](billing-payments.md) | Stripe integration (Checkout, Billing Portal, webhook), subscription lifecycle, invoice generation, payment reconciliation, PPA (Plan de paiement automatique), Klarna BNPL |

View File

@ -0,0 +1,112 @@
# Pont legacy → Dispatch (osTicket → Dispatch Job) — Handoff dev
Tire **régulièrement** les tickets ouverts assignés au compte « Tech Targo » dans la
DB legacy (osTicket/MariaDB `gestionclient`) et crée/maj un **Dispatch Job** ERPNext
pour les répartir (grille Planification / tableau Dispatch).
## Pourquoi « Tech Targo » = staff id 3301
Dans le legacy, le travail terrain à dispatcher est assigné au compte générique
**« Tech Targo »** (`staff.id = 3301`, username `tech`) — c'est le `default_staff` des
départements techniciens (Installation/Réparation/Fibre). Filtre du pont :
`ticket.status='open' AND ticket.assign_to=3301`. (~70 tickets au démarrage.)
Override possible via `LEGACY_TARGO_STAFF_ID`.
## Surfaces
| Quoi | Où |
|---|---|
| Module | `services/targo-hub/lib/legacy-dispatch-sync.js` |
| Routage + scheduler | `services/targo-hub/server.js` (`/dispatch/legacy-sync`, `startSync()` au boot) |
| Champ idempotence | Custom Field `Dispatch Job.legacy_ticket_id` (`dispatch-app/frappe-setup/setup_dispatch_custom_fields.py`) |
| Conso côté UI | Pool « à assigner » du tableau Dispatch + panneau « Jobs à assigner » de la Planification |
## Mapping ticket legacy → Dispatch Job
| Dispatch Job | Source legacy | Notes |
|---|---|---|
| `legacy_ticket_id` | `ticket.id` | **clé d'idempotence** (lookup avant create) ; affiché dans le panneau détail |
| `legacy_dept` | `ticket_dept.name` | département granulaire → **coloriage des cartes « comme legacy »** |
| `ticket_id` | `'LEG-' + ticket.id` | nom lisible du DJ |
| `subject` | `ticket.subject` | + adresse ajoutée si pas de Service Location |
| `customer` | `Customer``legacy_account_id = ticket.account_id` | 61/70 matchés ; sinon laissé vide |
| `service_location` + `latitude`/`longitude` | `Service Location` du customer (ville qui matche) | → pin carte |
| `job_type` | `ticket.dept_id` → {Installation, Réparation, Retrait, Autre} | valeurs valides du Select |
| `scheduled_date` | `ticket.due_date` (epoch) | converti **America/Toronto** (anti-décalage UTC) |
| `start_time` | `ticket.due_time` | `HH:MM` tel quel · `am`→08:00 · `pm`→13:00 · `day`→aucune |
| `priority` | `ticket.priority` (1/2/≥3) | → low / medium / high |
| `duration_h` | défaut par type | Install 2h · Répar 1.5h · autre 1h (le legacy n'en a pas) |
| `status` | — | toujours `open` (pool ; PAS auto-assigné à un tech précis) |
## Comportement
- **Idempotent** : 1 ticket legacy = 1 Dispatch Job (clé `legacy_ticket_id`). Re-run ⇒ 0 doublon.
- **Ne clobbe PAS le répartiteur** : un DJ déjà assigné/déplacé n'est plus touché ; maj de `scheduled_date`
seulement tant qu'il est encore `open` + non assigné.
- **SÉQUENTIEL** (frappe_pg ne supporte pas la concurrence) — pas de `Promise.all`.
## Coloriage des cartes (comme legacy)
`jobColor()` (`apps/ops/src/composables/useHelpers.js` → `legacyDeptColor`) colore par `legacyDept` :
Installation(Fibre)/Monteur/Fusionneur → **vert** `#46992f` · Réparation(Fibre) → **or** `#f1c84b` ·
Télé (install/réparation) → **rose** `#ec5fb0` · Téléphonie → **vert pâle** `#8fce93` ·
Désinstallation → **rouge foncé** `#c0392b`. (Tech en pause → rouge vif, priorité.) Le store
(`_mapJob`) expose `legacyDept`/`legacyTicketId` ; le pool « non-assigné » est déjà trié par `scheduledDate`.
## Endpoints
- `GET /dispatch/legacy-sync/preview`**dry-run, 0 écriture** : ce qui serait créé + matching client/SL + non-matchés.
- `POST /dispatch/legacy-sync/run` — exécute la synchro (création/maj). Retourne `{tickets, created, updated, skipped, errors, unmatched_customer}`.
## Récurrence
`startSync()` (server.js, au boot) — **opt-in** via env :
```
LEGACY_DISPATCH_SYNC=on # active la récurrence (sinon preview/run manuels seulement)
LEGACY_DISPATCH_SYNC_MIN=15 # période en minutes (défaut 15)
```
Posé dans `/opt/targo-hub/.env`. ⚠️ Après modif de `.env`, **recréer** le conteneur
(`cd /opt/targo-hub && docker compose up -d`) — `docker restart` ne relit pas l'env_file.
1er passage différé de 90 s après le boot, puis toutes les `MIN` minutes.
## État (mise en service 2026-06-06)
70 tickets importés (0 erreur, 9 clients non matchés = comptes post-migration + 2 tickets internes
« FORMATION EN HAUTEUR »). Récurrence active (15 min).
## Coordonnées GPS & routage routier réel (2026-06-06 f)
Le pont importe des **coordonnées fiables** par job (pour le routage routier réel dans l'éditeur de
tournée). Cascade de sources, de la plus précise à la plus large :
1. **`delivery` legacy** (point de service exact, via `ticket.delivery_id → delivery.latitude/longitude`)
— JOIN ajouté à `fetchTargoTickets`. Source de référence ; on préfère aussi l'**adresse de service**
(`delivery.address1/city/zip`) à l'adresse de facturation du compte.
2. **Service Location ERPNext** (coords du client matché) — repli.
3. **Géocodage RQA via recherche TRIGRAM** (`address-search.searchAddressesRpc` → RPC Postgres `search_addresses`,
`pg_trgm`) — **la même recherche que l'autocomplete de disponibilité fibre** (phase 1 = numéro civique + mots
de rue sur odonyme normal/court/**long**[avec générique]/municipalité/CP ; phase 2 = trigram complet ; priorise
les CP J0L/J0S = territoire). Bien plus robuste que l'ancien ilike (qui manquait « René-Vinet », générique absent
de `odonyme_recompose_normal`). **Garde-fou anti-faux-positif** (la phase 2 trigram dérive quand le civique est
absent du RQA, ex. « 2245 René-Vinet » → « Rue Grenet, Montréal ») : on n'accepte un résultat que si le **civique
concorde** + au moins **un token de nom de rue** correspond + (**territoire J0L/J0S** OU CP/ville legacy concordants).
4. **Géocodage Mapbox** (`MAPBOX_TOKEN`, clé publique) — couvre ce que le RQA n'a pas (rues neuves, civiques absents).
Contraint au Québec (`country=ca` + proximity + bornes `coord()`).
Validation `coord()` : bornes Québec (lat 44→63, lon 80→57) → rejette 0/0 et placeholders. Backfill
**+ UPGRADE** : sur un job existant, on remplit les coords absentes ET on **écrase** des coords Service
Location moins précises par les coords `delivery` (point exact) — jamais l'inverse. Caches géocodage au
niveau module (1 appel par adresse / vie du hub ; échecs mémorisés). Couverture : **~109/125 tickets (87 %)**
`coord_src` (run courant) : delivery 26 · SL 38 · RQA-trigram 8 · Mapbox 37 · aucune 16. Le faible compte
RQA = **haute précision** (l'ancien ilike comptait 17 mais avec des faux positifs hors-rue/hors-ville) ; les
« aucune » = adresses réellement absentes (campings « Lac des Pins », villes mal orthographiées « Franlkin »).
**Routage routier réel (Ops → Planification → éditeur de journée)** : `loadDayRoute()` appelle l'**API
Mapbox Matrix** une fois à l'ouverture (toutes les durées routières d'un coup) → `travelBetween()` retourne
le temps RÉEL ; le réordonnancement réutilise la matrice **sans nouvelle requête**. Repli haversine
(40 km/h) si Mapbox indispo. Indicateur 🚗 (réel) vs 📏 (vol d'oiseau).
## Robustesse (2026-06-06 f)
- **Comptabilité honnête** : `erp.create/update` ne *throw* pas (renvoient `{ok:false,error}`) → le pont
vérifie `r.ok` (sinon `errors++` + `error_samples` dans le résumé). Avant : creates échoués comptés réussis.
- **Verrou de sérialisation** sur `sync()` : tick récurrent + runs manuels ne se chevauchent JAMAIS
(frappe_pg sans concurrence → sinon « socket hang up » + écritures perdues dans un rollback).
- **Subject tronqué à 140** (champ `Data` Frappe) : les jobs sans Service Location ajoutaient l'adresse au
sujet → `CharacterLengthExceededError`. Détail complet conservé dans `legacy_detail`/coords.
- Env : `MAPBOX_TOKEN` ajouté à `/opt/targo-hub/.env` (clé publique `pk.`) → recréer le conteneur.
## TODO / idées
- Matcher les clients non matchés (créer le Customer / enrichir `legacy_account_id`) → réduit les 15 « aucune coord ».
- Géoloc live du tech (Capacitor `transistorsoft/capacitor-background-geolocation`) → 1er point de la tournée.
- Filtrer les départements non-terrain (ToDo, Support Informatique, Conception…) si bruit.
- Écrire en retour le tech assigné / la date vers le legacy (bidirectionnel) — non fait (lecture seule legacy).

View File

@ -76,11 +76,21 @@ réutilisé par `assign-job` **et** `backfill-start-times`) → `bookingSlots` /
- **Déploiement** : front via `apps/ops/deploy.sh` (`DEPLOY_BASE=/ops/ quasar build` + tar/scp → `/opt/ops-app/`) ;
hub via `scp lib/roster.js → /opt/targo-hub/lib/` + `docker restart targo-hub`. Custom fields via le script frappe-setup.
## Fait récemment
- ✅ **On Hold** : le dépôt d'un job en attente d'un prérequis est REFUSÉ (notify), plus seulement 🔒 visuel (`onCellDrop`).
- ✅ **Alerte hors quart** : un job assigné un jour où le tech n'a aucun quart publié → marqueur ⚠ dans la cellule libre
(`offShiftJobs`) + badge « hors quart publié » dans la timeline. (Le backfill ignore ces jobs : pas de bande à remplir.)
- ✅ **Deep-link Dispatch** : Planification → `gotoDispatch(tech)` ouvre `/dispatch?tech=&date=` ; DispatchPage lit `route.query`
(`goToDay(date+'T12:00:00')` anti-décalage tz + `selectTechOnBoard`).
- ✅ **#58 Aviser le client** : bouton « Désaffecter + aviser le client » dans le dialogue d'unassign Dispatch →
`roster.notifyReschedule` (désassigne serveur + SMS lien /book au mobile du Customer).
## TODO / dette
- Bloquer réellement (≠ 🔒 visuel) le dépôt d'un job « On Hold » avant son prérequis.
- Brancher la **proximité** (lat/lng Service Location ↔ base/secteur du tech) dans `techProximity`.
- Deep-link Dispatch filtré tech/date (DispatchPage ne lit pas encore `route.query`).
- Brancher la **proximité** (lat/lng Service Location ↔ base/secteur du tech) dans `techProximity` (hook neutre en place).
- Apprentissage IA compétence ↔ type de cas (historique des jobs).
- `#57` Hold côté client sur `/book` (Lovable) : bloquer la plage à la sélection.
- Compteur global « N jobs hors quart cette semaine » dans la barre d'outils (complément du marqueur ⚠).
- **Refactor** : `PlanificationPage.vue` (~1,5k lignes) gagnerait à extraire des composables
(`useGarde`, `useOccupancy`, `useAssignPanel`, `useSkillEditor`) — non fait (risque sur fichier prod).

View File

@ -0,0 +1,19 @@
-- camping_dispatch_backfill.sql — applique la géoloc FIXE du camping aux Dispatch Jobs DÉJÀ dispatchés.
-- Le pont (legacy-dispatch-sync) ne re-traite que les tickets encore « ouverts + assign_to=3301 » ; les jobs
-- déjà assignés/fermés gardent leurs vieilles coords (résidence). Ce backfill corrige tous les jobs issus du
-- pont dont le SUJET désigne un camping (mots-clés de LIEU sûrs ; pour 'dauphinais', exige aussi « camping »).
-- Idempotent (ne touche que ceux dont la coord diffère). Match via camping_registry.
\timing on
BEGIN;
WITH applied AS (
UPDATE "tabDispatch Job" dj SET latitude = c.latitude, longitude = c.longitude, modified = NOW()
FROM camping_registry c
WHERE dj.legacy_ticket_id <> '' AND c.active
AND lower(unaccent(coalesce(dj.subject, ''))) LIKE '%' || c.keyword || '%'
AND ( c.keyword IN ('lac des pins','lac de pins','sandysun','sandy sun','frontiere','ensoleill')
OR lower(unaccent(coalesce(dj.subject, ''))) LIKE '%camping%' )
AND (dj.latitude IS NULL OR abs(coalesce(dj.latitude,0) - c.latitude) > 1e-4 OR abs(coalesce(dj.longitude,0) - c.longitude) > 1e-4)
RETURNING c.name AS camping
)
SELECT camping, count(*) AS jobs FROM applied GROUP BY camping ORDER BY 2 DESC;
COMMIT;

View File

@ -0,0 +1,54 @@
-- camping_registry.sql — registre des CAMPINGS + géoloc de remplacement FIXE sur tous leurs lots.
--
-- Problème : pour un lot de camping, l'adresse du Service Location est souvent l'adresse de RÉSIDENCE du
-- client (ex. « 428 Rue … ») et la rue interne du camping (ex. « 2 rue Canard, Lac des Pins ») n'existe pas
-- dans le RQA → géoloc fausse. Solution : une coordonnée FIXE = l'adresse principale du camping (le tech
-- navigue jusqu'au camping, puis trouve le terrain sur place). Data-driven (table) → réutilisable + éditable.
--
-- Idempotent : CREATE IF NOT EXISTS + seed WHERE NOT EXISTS + apply re-jouable. Match par VILLE normalisée.
\timing on
BEGIN;
CREATE TABLE IF NOT EXISTS camping_registry (
id serial PRIMARY KEY,
keyword text NOT NULL UNIQUE, -- motif (minuscule, sans accent) cherché dans la ville du Service Location
name text NOT NULL, -- nom du camping
address text, -- adresse principale (référence affichée)
latitude double precision NOT NULL,
longitude double precision NOT NULL,
active boolean DEFAULT true,
created_at timestamp DEFAULT now()
);
-- Seed (coords = adresse principale, relevées dans le legacy delivery). Plusieurs motifs → même camping/coords.
INSERT INTO camping_registry (keyword, name, address, latitude, longitude)
SELECT * FROM (VALUES
('lac des pins', 'Camping Lac des Pins', '3625 Route 201, Saint-Antoine-Abbé J0S 1N0', 45.062428, -73.911331),
('lac de pins', 'Camping Lac des Pins', '3625 Route 201, Saint-Antoine-Abbé J0S 1N0', 45.062428, -73.911331),
('dauphinais', 'Camping Le Domaine Dauphinais', '199 Route 219 Sud, Hemmingford J0L 1H0', 45.023808, -73.608696),
('sandysun', 'Camping du Lac SandySun', '1935A Chemin Grimshaw, Franklin J0S 1E0', 45.048569, -73.916870),
('sandy sun', 'Camping du Lac SandySun', '1935A Chemin Grimshaw, Franklin J0S 1E0', 45.048569, -73.916870),
('frontiere', 'Camping Frontière', '474 Chemin Covey Hill, Havelock J0S 2C0', 45.018734, -73.761551),
('ensoleill', 'Camping Domaine Ensoleillé', '524 Rang St-Paul, Saint-Rémi J0L 2L0', 45.267612, -73.600517)
) v(keyword,name,address,latitude,longitude)
WHERE NOT EXISTS (SELECT 1 FROM camping_registry c WHERE c.keyword = v.keyword);
-- Application : force la géoloc du camping sur tous les lots (match ville normalisée). Conserve address_line
-- (le n° de terrain/rue interne reste visible) ; linked_address = le camping ; statut = validated (coord de service OK).
WITH applied AS (
UPDATE "tabService Location" sl SET
latitude = c.latitude,
longitude = c.longitude,
linked_address = c.name || '' || COALESCE(c.address, ''),
aq_address_id = NULL,
address_validation_status = 'validated',
address_validated_at = NOW(),
modified = NOW()
FROM camping_registry c
WHERE c.active
AND lower(unaccent(coalesce(sl.city, ''))) LIKE '%' || c.keyword || '%'
RETURNING c.name AS camping
)
SELECT camping, count(*) AS lots_corriges FROM applied GROUP BY camping ORDER BY 2 DESC;
COMMIT;

View File

@ -0,0 +1,51 @@
-- normalize_service_locations.sql — « rendre conformes » les adresses de service ERPNext via la base
-- LOCALE rqa_addresses (Adresses Québec), sans détruire l'adresse originale (table de translation embarquée).
--
-- Pour chaque Service Location : on cherche la correspondance RQA par CODE POSTAL + NUMÉRO (clé sélective,
-- indexée), on choisit la meilleure rue par similarité désaccentuée, puis on remplit :
-- aq_address_id = rqa_addresses.id (clé de translation locale) [⚠ id LOCAL, pas l'uuid AQ officiel]
-- linked_address = adresse canonique conforme (address_full)
-- address_validation_status = validated (sim≥0.20) | review (0<sim<0.20) | unmatched (aucun match)
-- latitude/longitude RAFFINÉES seulement si validated ; address_line/city/postal_code INCHANGÉS (origine préservée)
-- Idempotent : ne traite que les lignes encore 'pending' (re-run sûr). Les 2 tables sont colocalisées
-- dans la db ERPNext _eb65bdc0c4b1b2d6 → tout se fait en SQL (pas d'aller-retour applicatif).
\timing on
BEGIN;
-- Passe 1 : CODE POSTAL + NUMÉRO (forte) — couvre ~97 %.
UPDATE "tabService Location" sl SET
aq_address_id = m.id,
linked_address = m.address_full,
latitude = CASE WHEN m.sim >= 0.20 THEN m.lat ELSE sl.latitude END,
longitude = CASE WHEN m.sim >= 0.20 THEN m.lon ELSE sl.longitude END,
address_validation_status = CASE WHEN m.sim >= 0.20 THEN 'validated' ELSE 'review' END,
address_validated_at = NOW(),
modified = NOW()
FROM (
SELECT s.name, m.id, m.address_full, m.lat, m.lon, m.sim
FROM "tabService Location" s
CROSS JOIN LATERAL (
SELECT a.id::text AS id, a.address_full, a.latitude AS lat, a.longitude AS lon,
similarity(a.search_text, lower(unaccent(s.address_line))) AS sim
FROM rqa_addresses a
WHERE a.code_postal = replace(upper(coalesce(s.postal_code,'')), ' ', '')
AND a.numero = (regexp_match(s.address_line, '^\s*(\d+)'))[1]
ORDER BY similarity(a.search_text, lower(unaccent(s.address_line))) DESC
LIMIT 1
) m
WHERE s.address_validation_status = 'pending'
AND s.address_line NOT IN ('', 'N/A', 'xxx')
) m
WHERE m.name = sl.name;
-- Passe 2 : reste non résolu → 'unmatched' (pas de correspondance code postal+numéro).
UPDATE "tabService Location"
SET address_validation_status = 'unmatched', address_validated_at = NOW(), modified = NOW()
WHERE address_validation_status = 'pending';
-- Rapport
SELECT address_validation_status AS statut, count(*) AS n,
count(NULLIF(aq_address_id,'')) AS avec_lien_aq
FROM "tabService Location" GROUP BY 1 ORDER BY 2 DESC;
COMMIT;

View File

@ -0,0 +1,43 @@
-- normalize_service_locations_pass2.sql — RÉCUPÉRATION par NUMÉRO + VILLE (sans contrainte de code postal),
-- pour les Service Locations restées 'review'/'unmatched' après la passe 1 (postal+numéro). Beaucoup ont un
-- code postal erroné/manquant mais une ville valide → on retrouve la rue par similarité.
--
-- N'UPGRADE qu'avec une similarité ≥0.30 (barre + stricte qu'en passe 1 car pas de contrainte postale) →
-- met aq_address_id + linked_address + coords RQA réelles + statut 'validated'. Laisse le reste tel quel
-- (boîtes postales C.P./PO Box, adresses hors-QC, surnoms de camping). address_line/city/postal INCHANGÉS.
\timing on
BEGIN;
UPDATE "tabService Location" sl SET
aq_address_id = m.id,
linked_address = m.address_full,
latitude = m.lat,
longitude = m.lon,
address_validation_status = 'validated',
address_validated_at = NOW(),
modified = NOW()
FROM (
SELECT s.name, m.id, m.address_full, m.lat, m.lon, m.sim
FROM "tabService Location" s
CROSS JOIN LATERAL (
SELECT a.id::text AS id, a.address_full, a.latitude AS lat, a.longitude AS lon,
similarity(a.search_text, lower(unaccent(s.address_line))) AS sim
FROM rqa_addresses a
WHERE a.numero = (regexp_match(s.address_line, '^\s*(\d+)'))[1]
AND a.ville % regexp_replace(regexp_replace(lower(unaccent(coalesce(s.city,''))), '^ste[ .-]', 'sainte-'), '^st[ .-]', 'saint-')
ORDER BY similarity(a.search_text, lower(unaccent(s.address_line))) DESC
LIMIT 1
) m
WHERE s.address_validation_status IN ('review', 'unmatched')
AND s.address_line ~ '^[0-9]'
AND s.city NOT IN ('', 'N/A', 'Ville', 'x')
AND m.sim >= 0.30
) m
WHERE m.name = sl.name;
SELECT address_validation_status AS statut, count(*) AS n,
count(NULLIF(aq_address_id,'')) AS avec_lien,
count(*) FILTER (WHERE latitude IS NOT NULL AND latitude<>0) AS avec_gps
FROM "tabService Location" GROUP BY 1 ORDER BY 2 DESC;
COMMIT;

View File

@ -0,0 +1,179 @@
'use strict'
/**
* address-conformity.js back-end de la page Ops « Conformité des adresses ».
*
* Source de vérité : Service Location ERPNext (tabService Location) avec son lien AQ
* (aq_address_id rqa_addresses, linked_address canonique, address_validation_status). Cette page
* permet de RÉSOUDRE une fois pour toutes les adresses non conformes (review/unmatched) :
* - Approuver : la proposition AQ devient officielle (statut validated, coords RQA).
* - Corriger : chercher la bonne adresse AQ (recherche locale) et la lier.
* - GPS manuel : poser lat/long (ex. relevé sur map.targointernet.com qui a la géoloc des unités).
* - Rejeter : pas d'adresse AQ (boîte postale, hors-QC…) → statut 'no_address'.
*
* Tout est LOCAL (Postgres ERPNext). Routes sous /address/conformity/* (cf. server.js).
*/
const { json, parseBody, log, cors } = require('./helpers')
const { searchRaw, pool } = require('./address-db') // réutilise le pool LOCAL partagé (rqa_addresses + fiber)
const { norm } = require('./util/text')
// Application de la géoloc de remplacement des campings sur leurs lots (match ville normalisée). Réutilisée
// par /campings/apply ET appelée après l'ajout/édition d'un camping. Retourne le décompte par camping.
async function applyCampings (p) {
const r = await p.query(`WITH applied AS (
UPDATE "tabService Location" sl SET latitude=c.latitude, longitude=c.longitude,
linked_address=c.name||' — '||COALESCE(c.address,''), aq_address_id=NULL,
address_validation_status='validated', address_validated_at=NOW(), modified=NOW()
FROM camping_registry c
WHERE c.active AND lower(unaccent(coalesce(sl.city,''))) LIKE '%'||c.keyword||'%'
RETURNING c.name AS camping)
SELECT camping, count(*)::int n FROM applied GROUP BY camping ORDER BY 2 DESC`)
return r.rows
}
// Dernier repli : pour les 'unmatched' qu'on n'a pas pu géocoder, placer au CENTRE (centroïde rqa_addresses)
// du code postal (préféré, plus précis) sinon de la ville → le job apparaît au moins dans le bon secteur.
// Statut 'area' (approximatif). Les hors-QC/junk (pas dans rqa_addresses) restent 'unmatched'.
async function applyAreaFallback (p) {
const r = await p.query(`
WITH um AS (
SELECT name, replace(upper(coalesce(postal_code,'')),' ','') cp, lower(unaccent(coalesce(city,''))) city,
postal_code cp_raw, city city_raw
FROM "tabService Location" WHERE address_validation_status='unmatched'
),
pc AS (SELECT replace(upper(code_postal),' ','') cp, avg(latitude) lat, avg(longitude) lon FROM rqa_addresses
WHERE replace(upper(code_postal),' ','') IN (SELECT cp FROM um WHERE cp ~ '^[A-Z][0-9][A-Z][0-9]') GROUP BY 1),
cc AS (SELECT lower(unaccent(ville)) city, avg(latitude) lat, avg(longitude) lon FROM rqa_addresses
WHERE lower(unaccent(ville)) IN (SELECT city FROM um WHERE city NOT IN ('','n/a','ville','x','-')) GROUP BY 1),
upd AS (
UPDATE "tabService Location" sl SET
latitude=COALESCE(pc.lat,cc.lat), longitude=COALESCE(pc.lon,cc.lon),
linked_address='≈ centre ' || COALESCE(NULLIF(CASE WHEN pc.lat IS NOT NULL THEN um.cp_raw END,''), um.city_raw),
address_validation_status='area', address_validated_at=NOW(), modified=NOW()
FROM um LEFT JOIN pc ON pc.cp=um.cp LEFT JOIN cc ON cc.city=um.city
WHERE sl.name=um.name AND (pc.lat IS NOT NULL OR cc.lat IS NOT NULL)
RETURNING (pc.lat IS NOT NULL) AS by_postal)
SELECT count(*)::int total, count(*) FILTER (WHERE by_postal)::int by_postal,
count(*) FILTER (WHERE NOT by_postal)::int by_city FROM upd`)
return r.rows[0]
}
// Type d'adresse (pour trier la file) : camping (sobriquet de lot), civique à corriger, non-adresse, review standard.
const TYPE_SQL = `CASE
WHEN sl.city ILIKE '%camping%' OR sl.address_line ILIKE '%camping%' THEN 'camping'
WHEN sl.address_validation_status='unmatched' AND sl.address_line ~ '^[0-9]' AND sl.city NOT IN ('','N/A','Ville','x','-') THEN 'civique'
WHEN sl.address_validation_status='unmatched' THEN 'non_adresse'
ELSE 'review' END`
async function handle (req, res, method, path) {
try {
if (method === 'OPTIONS') { cors(res); res.writeHead(204); res.end(); return }
// Compteurs par statut + par type (file de travail).
if (path === '/address/conformity/stats' && method === 'GET') {
const p = pool()
const byStatus = (await p.query('SELECT address_validation_status s, count(*) n FROM "tabService Location" GROUP BY 1 ORDER BY 2 DESC')).rows
const byType = (await p.query(`SELECT ${TYPE_SQL} t, count(*) n FROM "tabService Location" sl WHERE address_validation_status IN ('review','unmatched') GROUP BY 1 ORDER BY 2 DESC`)).rows
cors(res); return json(res, 200, { ok: true, by_status: byStatus, by_type: byType })
}
// Liste paginée des adresses à traiter (review/unmatched), filtrable par type + recherche texte.
if (path === '/address/conformity/list' && method === 'GET') {
const u = new URL(req.url, 'http://localhost')
const type = u.searchParams.get('type') || ''
const q = (u.searchParams.get('q') || '').trim()
const limit = Math.min(parseInt(u.searchParams.get('limit')) || 50, 200)
const offset = Math.max(parseInt(u.searchParams.get('offset')) || 0, 0)
const where = [`sl.address_validation_status IN ('review','unmatched')`]
const params = []
if (type) { params.push(type); where.push(`${TYPE_SQL} = $${params.length}`) }
if (q) { params.push('%' + q + '%'); where.push(`(sl.address_line ILIKE $${params.length} OR sl.city ILIKE $${params.length} OR sl.customer ILIKE $${params.length} OR sl.name ILIKE $${params.length})`) }
const whereSql = where.join(' AND ')
const p = pool()
const total = (await p.query(`SELECT count(*) n FROM "tabService Location" sl WHERE ${whereSql}`, params)).rows[0].n
params.push(limit); params.push(offset)
const rows = (await p.query(
`SELECT sl.name, sl.customer, sl.address_line, sl.city, sl.postal_code,
sl.address_validation_status AS status, sl.aq_address_id, sl.linked_address,
sl.latitude, sl.longitude, ${TYPE_SQL} AS type
FROM "tabService Location" sl WHERE ${whereSql}
ORDER BY ${TYPE_SQL}, sl.city, sl.address_line
LIMIT $${params.length - 1} OFFSET $${params.length}`, params)).rows
cors(res); return json(res, 200, { ok: true, total: Number(total), limit, offset, rows })
}
// Candidats AQ pour l'action « Corriger » (recherche locale rqa_addresses + fibre).
if (path === '/address/conformity/candidates' && method === 'GET') {
const u = new URL(req.url, 'http://localhost')
const q = (u.searchParams.get('q') || '').trim()
let results = []
try { results = await searchRaw(q, 8) } catch (e) { log('conformity/candidates:', e.message) }
cors(res); return json(res, 200, { ok: true, results })
}
// Résoudre une adresse : approve | correct | gps | reject. Écrit le lien/coords/statut (source de vérité).
if (path === '/address/conformity/resolve' && method === 'POST') {
const b = await parseBody(req)
const name = b.name
if (!name) { cors(res); return json(res, 400, { ok: false, error: 'name requis' }) }
const p = pool()
const set = ['address_validated_at = NOW()', 'modified = NOW()']
const params = []
const add = (frag, val) => { params.push(val); set.push(`${frag} = $${params.length}`) }
if (b.action === 'approve') {
// garde la proposition existante (aq_address_id/linked_address) + coords déjà raffinées → validated
set.push(`address_validation_status = 'validated'`)
} else if (b.action === 'correct') {
if (b.aq_address_id != null) add('aq_address_id', String(b.aq_address_id))
if (b.linked_address != null) add('linked_address', b.linked_address)
if (b.latitude != null) add('latitude', Number(b.latitude))
if (b.longitude != null) add('longitude', Number(b.longitude))
set.push(`address_validation_status = 'validated'`)
} else if (b.action === 'gps') {
if (b.latitude == null || b.longitude == null) { cors(res); return json(res, 400, { ok: false, error: 'latitude/longitude requis' }) }
add('latitude', Number(b.latitude)); add('longitude', Number(b.longitude))
set.push(`address_validation_status = 'validated'`) // position confirmée manuellement
} else if (b.action === 'reject') {
set.push(`address_validation_status = 'no_address'`) // pas d'adresse civique (boîte postale, hors-QC…)
} else { cors(res); return json(res, 400, { ok: false, error: 'action inconnue' }) }
params.push(name)
const r = await p.query(`UPDATE "tabService Location" SET ${set.join(', ')} WHERE name = $${params.length}`, params)
cors(res); return json(res, 200, { ok: true, updated: r.rowCount, name, action: b.action })
}
// ── Registre des campings (géoloc de remplacement fixe) ──
if (path === '/address/conformity/campings' && method === 'GET') {
const p = pool()
const campings = (await p.query('SELECT id, keyword, name, address, latitude, longitude, active FROM camping_registry ORDER BY name, keyword')).rows
const counts = (await p.query(`SELECT c.name, count(sl.name)::int n FROM camping_registry c
LEFT JOIN "tabService Location" sl ON c.active AND lower(unaccent(coalesce(sl.city,''))) LIKE '%'||c.keyword||'%'
GROUP BY c.name`)).rows
const lots = {}; for (const x of counts) lots[x.name] = x.n
cors(res); return json(res, 200, { ok: true, campings, lots })
}
if (path === '/address/conformity/campings' && method === 'POST') {
const b = await parseBody(req)
if (!b.keyword || !b.name || b.latitude == null || b.longitude == null) { cors(res); return json(res, 400, { ok: false, error: 'keyword/name/latitude/longitude requis' }) }
const p = pool()
await p.query(`INSERT INTO camping_registry (keyword,name,address,latitude,longitude,active)
VALUES ($1,$2,$3,$4,$5,true)
ON CONFLICT (keyword) DO UPDATE SET name=$2, address=$3, latitude=$4, longitude=$5, active=true`,
[norm(b.keyword), b.name, b.address || '', Number(b.latitude), Number(b.longitude)])
const applied = await applyCampings(p) // applique direct le nouveau/maj camping
cors(res); return json(res, 200, { ok: true, applied })
}
if (path === '/address/conformity/campings/apply' && method === 'POST') {
cors(res); return json(res, 200, { ok: true, applied: await applyCampings(pool()) })
}
// Repli centroïde (centre du CP/ville) pour les unmatched restants
if (path === '/address/conformity/apply-area' && method === 'POST') {
cors(res); return json(res, 200, { ok: true, ...(await applyAreaFallback(pool())) })
}
cors(res); return json(res, 404, { ok: false, error: 'route conformité inconnue' })
} catch (e) {
log('address-conformity error:', e.message)
cors(res); return json(res, 500, { ok: false, error: String(e.message || e) })
}
}
module.exports = { handle }

View File

@ -0,0 +1,124 @@
'use strict'
/**
* address-db.js recherche d'adresses LOCALE (Postgres ERPNext), remplace le Supabase cloud externe.
*
* Source : `rqa_addresses` (5,24M adresses du Québec, search_text désaccentué, index trigram GIN)
* LEFT JOIN `fiber_availability` (couverture fibre Targo, joint par address_id) toutes deux DÉJÀ
* locales dans la db ERPNext `_eb65bdc0c4b1b2d6`. Le hub est sur le réseau erpnext_erpnext + a `pg`.
*
* Deux phases (comme l'autocomplete de dispo) :
* Phase 1 civique présent : filtre `numero` (btree) + mots de rue (LIKE), tri fibreJ0L/J0Ssimilarité. ~20 ms.
* Phase 2 sans civique / phase 1 vide : `word_similarity` (`<%`, indexable GIN), tri idem. ~700 ms.
* (NB : le `%`/similarity() plein sur 5,24M = 24-76 s on utilise `<%` qui est sélectif et indexé.)
*
* Retourne une forme compatible avec l'ancien Supabase (adresse_formatee, numero_municipal,
* odonyme_recompose_normal, nom_municipalite, latitude, longitude, fiber_available, zone_tarifaire,
* max_speed, similarity_score) consommateurs hub inchangés (pont, onboarding, checkout, site).
*/
const { Pool } = require('pg')
const { log } = require('./helpers')
const { norm } = require('./util/text') // helper texte partagé (Phase 1 : dé-duplication)
let _pool
function pool () {
if (!_pool) {
_pool = new Pool({
host: process.env.ADDR_DB_HOST || 'erpnext-db-1',
port: +(process.env.ADDR_DB_PORT || 5432),
user: process.env.ADDR_DB_USER || 'postgres',
password: process.env.ADDR_DB_PASS || '123',
database: process.env.ADDR_DB_NAME || '_eb65bdc0c4b1b2d6',
max: 4, idleTimeoutMillis: 30000, connectionTimeoutMillis: 6000,
})
_pool.on('error', (e) => log('address-db pool error:', e.message))
}
return _pool
}
const COLS = `a.id, a.address_full, a.numero, a.rue, a.ville, a.code_postal, a.longitude, a.latitude,
(f.id IS NOT NULL) AS fiber_available, COALESCE(f.zone_tarifaire,0) AS zone_tarifaire, COALESCE(f.max_speed,0) AS max_speed`
const ORDER = `(f.id IS NOT NULL) DESC, (a.code_postal LIKE 'J0L%' OR a.code_postal LIKE 'J0S%') DESC, sim DESC`
function mapRow (r) {
return {
identifiant_unique_adresse: String(r.id),
adresse_formatee: r.address_full || [r.numero, r.rue].filter(Boolean).join(' ') + (r.ville ? ', ' + r.ville : '') + (r.code_postal ? ' ' + r.code_postal : ''),
numero_municipal: r.numero,
numero_unite: null,
code_postal: r.code_postal,
odonyme_recompose_normal: r.rue,
nom_municipalite: r.ville,
latitude: r.latitude,
longitude: r.longitude,
fiber_available: !!r.fiber_available,
zone_tarifaire: r.zone_tarifaire,
max_speed: r.max_speed,
similarity_score: r.sim != null ? +r.sim : null,
}
}
// Forme BRUTE (colonnes de la fonction search_addresses : id, address_full, numero, rue, ville,
// code_postal, longitude, latitude, fiber_available, zone_tarifaire, max_speed, similarity_score)
// → consommée telle quelle par l'autocomplete du site web (endpoint /rpc/search_addresses).
function toRaw (r) {
return {
id: r.id, address_full: r.address_full, numero: r.numero, rue: r.rue, ville: r.ville,
code_postal: r.code_postal, longitude: r.longitude, latitude: r.latitude,
fiber_available: !!r.fiber_available, zone_tarifaire: r.zone_tarifaire, max_speed: r.max_speed,
similarity_score: r.sim != null ? +r.sim : null,
}
}
// Recherche principale (lignes brutes DB). term = texte tapé ("2338 rue rené-vinet" ou "rené-vinet ...").
async function searchRows (term, limit = 8) {
const clean = norm(term)
if (clean.length < 3) return []
const lim = Math.min(Math.max(parseInt(limit) || 8, 1), 25)
const civic = (clean.match(/^\s*(\d+)/) || [])[1] || null
const streetPart = clean.replace(/^\s*\d+\s*/, '').trim()
const words = streetPart ? streetPart.split(' ').filter(w => w.length >= 2) : []
const p = pool()
// ── Phase 1 : civique présent (rapide, btree numero) ──
if (civic) {
const sql = `SELECT ${COLS}, similarity(a.search_text, $3) AS sim
FROM rqa_addresses a LEFT JOIN fiber_availability f ON f.address_id = a.id
WHERE a.numero = $1
AND (array_length($2::text[], 1) IS NULL OR NOT EXISTS (
SELECT 1 FROM unnest($2::text[]) w WHERE a.search_text NOT LIKE '%' || w || '%'))
ORDER BY ${ORDER}, a.numero
LIMIT $4`
const r = await p.query(sql, [civic, words, clean, lim])
if (r.rows.length) return r.rows
}
// ── Phase 2 : word_similarity (sans civique, ou phase 1 vide) ──
// `<%` (indexable GIN) au lieu de `%`/similarity() plein (24-76 s sur 5,24M). Seuil + statement_timeout
// posés en SET LOCAL dans une transaction (déterministe + auto-reset au COMMIT ; borne le pire cas).
const sql2 = `SELECT ${COLS}, word_similarity($1, a.search_text) AS sim
FROM rqa_addresses a LEFT JOIN fiber_availability f ON f.address_id = a.id
WHERE $1 <% a.search_text
ORDER BY ${ORDER}
LIMIT $2`
const client = await p.connect()
try {
await client.query('BEGIN')
await client.query('SET LOCAL pg_trgm.word_similarity_threshold = 0.6') // ~700 ms ; 0.5 plus large/lent
await client.query('SET LOCAL statement_timeout = 4000') // garde-fou anti-hang (sécurité)
const r2 = await client.query(sql2, [clean, lim])
await client.query('COMMIT')
return r2.rows
} catch (e) {
try { await client.query('ROLLBACK') } catch (_) {}
log('searchRows phase2:', e.message)
return []
} finally { client.release() }
}
// Forme MAPPÉE (compat Supabase historique) → consommée par les services hub (pont, onboarding, checkout).
async function searchLocal (term, limit = 8) { return (await searchRows(term, limit)).map(mapRow) }
// Forme BRUTE (colonnes de la fonction) → consommée par l'autocomplete du site web.
async function searchRaw (term, limit = 8) { return (await searchRows(term, limit)).map(toRaw) }
module.exports = { searchLocal, searchRaw, pool, norm }

View File

@ -1,51 +1,24 @@
'use strict'
const { httpRequest } = require('./helpers')
const SUPABASE_URL = 'https://rddrjzptzhypltuzmere.supabase.co'
const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InJkZHJqenB0emh5cGx0dXptZXJlIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA4MTY4NTYsImV4cCI6MjA4NjM5Mjg1Nn0.EluFlKBze8BYM6AFx88G7kt21EvR18EI3uw1zgCXVzs'
function wordsToIlike (str) {
const words = str.split(/\s+/).filter(w => w.length >= 2)
if (!words.length) return ''
return '*' + words.map(w => encodeURIComponent(w)).join('*') + '*'
}
/**
* address-search.js façade de recherche d'adresses.
*
* Depuis 2026-06 la source est 100% LOCALE (Postgres ERPNext : rqa_addresses + fiber_availability,
* via ./address-db) PLUS aucune dépendance au Supabase cloud externe. Les noms d'export restent
* stables pour les consommateurs existants : address-validate (/address/*), checkout (/api/address-search),
* legacy-dispatch-sync (géocodage du pont).
*/
const { searchLocal } = require('./address-db')
// Recherche d'adresses — forme historique (adresse_formatee, numero_municipal, numero_unite,
// odonyme_recompose_normal, nom_municipalite, code_postal, latitude, longitude,
// identifiant_unique_adresse, fiber_available, zone_tarifaire, max_speed, similarity_score).
async function searchAddresses (term, limit = 8) {
const clean = term.trim()
if (clean.length < 3) return []
const numMatch = clean.match(/^\s*(\d+)\s*(.*)/)
const headers = { apikey: SUPABASE_KEY, Authorization: 'Bearer ' + SUPABASE_KEY }
const select = 'adresse_formatee,numero_municipal,numero_unite,code_postal,odonyme_recompose_normal,nom_municipalite,latitude,longitude,identifiant_unique_adresse'
const base = `${SUPABASE_URL}/rest/v1/addresses?select=${select}&limit=${limit}`
let results = []
if (numMatch) {
const num = numMatch[1]
const street = numMatch[2].trim()
let url = `${base}&numero_municipal=eq.${num}`
if (street) url += `&odonyme_recompose_normal=ilike.${wordsToIlike(street)}`
url += '&order=nom_municipalite'
const res = await httpRequest(url, '', { headers })
results = Array.isArray(res.data) ? res.data : []
if (!results.length && num.length >= 2) {
let url2 = `${base}&numero_municipal=like.${num}*`
if (street) url2 += `&odonyme_recompose_normal=ilike.${wordsToIlike(street)}`
url2 += '&order=nom_municipalite'
const res2 = await httpRequest(url2, '', { headers })
results = Array.isArray(res2.data) ? res2.data : []
}
} else {
const pattern = wordsToIlike(clean)
if (!pattern) return []
const url = `${base}&odonyme_recompose_normal=ilike.${pattern}&order=nom_municipalite`
const res = await httpRequest(url, '', { headers })
results = Array.isArray(res.data) ? res.data : []
}
return results.map(a => ({ ...a, fiber_available: false }))
try { return await searchLocal(term, limit) } catch (e) { return [] }
}
module.exports = { searchAddresses, wordsToIlike }
// Alias historique (anciennement la RPC trigram Supabase) → désormais la même recherche locale.
async function searchAddressesRpc (term, limit = 8) {
return searchLocal(term, limit)
}
module.exports = { searchAddresses, searchAddressesRpc }

View File

@ -17,8 +17,9 @@
// ./address-search.js — already used by the customer onboarding wizard.
// We layer a confidence score + canonical formatting on top.
const { json, parseBody, log } = require('./helpers')
const { json, parseBody, log, cors } = require('./helpers')
const { searchAddresses } = require('./address-search')
const { searchLocal, searchRaw } = require('./address-db')
// Normalize for fuzzy comparison: lowercase, strip diacritics, collapse
// whitespace, drop punctuation. Used to score how close a typed address
@ -65,6 +66,38 @@ function scoreMatch (typed, rqaRow) {
}
async function handle (req, res, method, path) {
// POST /rpc/search_addresses — COMPAT Supabase RPC (forme attendue par l'autocomplete du site web :
// body { search_term, result_limit } → tableau direct de lignes { id, address_full, numero, rue, ville,
// code_postal, longitude, latitude, fiber_available, zone_tarifaire, max_speed, similarity_score }).
// Sert depuis la base LOCALE → on débranche le Supabase cloud (juste basculer VITE_API_BASE côté site).
if (path === '/rpc/search_addresses') {
if (method === 'OPTIONS') { cors(res); res.writeHead(204); res.end(); return }
if (method === 'POST') {
const body = await parseBody(req)
const term = (body.search_term || body.q || '').toString().trim()
const limit = body.result_limit || body.limit || 8
let rows = []
try { rows = await searchRaw(term, limit) } catch (e) { log('rpc/search_addresses:', e.message) }
cors(res)
return json(res, 200, rows)
}
}
// GET /address/search?q=&limit= — autocomplete PUBLIC (site web) : adresses + disponibilité fibre,
// depuis la base LOCALE (rqa_addresses + fiber_availability). Remplace l'appel direct au Supabase.
if (path.startsWith('/address/search')) {
if (method === 'OPTIONS') { cors(res); res.writeHead(204); res.end(); return }
if (method === 'GET') {
const u = new URL(req.url, 'http://localhost')
const q = (u.searchParams.get('q') || '').trim()
const limit = u.searchParams.get('limit') || 8
let results = []
try { results = await searchLocal(q, limit) } catch (e) { log('address/search error:', e.message) }
cors(res)
return json(res, 200, { ok: true, query: q, count: results.length, results })
}
}
// POST /address/validate — score-rank RQA results for a free-text address.
if (path === '/address/validate' && method === 'POST') {
const body = await parseBody(req)

View File

@ -110,7 +110,7 @@ async function create (doctype, body) {
const r = await erpFetch(`/api/resource/${encDocType(doctype)}`, {
method: 'POST', body: JSON.stringify(body),
})
if (r.status >= 400) return { ok: false, error: errorMessage(r), status: r.status }
if (r.status >= 400) { const error = errorMessage(r); log(`erp.create ${doctype} échec [${r.status}]: ${String(error).slice(0, 200)}`); return { ok: false, error, status: r.status } }
return { ok: true, data: r.data?.data, name: r.data?.data?.name }
}
@ -118,13 +118,13 @@ async function update (doctype, name, body) {
const r = await erpFetch(`/api/resource/${encDocType(doctype)}/${encName(name)}`, {
method: 'PUT', body: JSON.stringify(body),
})
if (r.status >= 400) return { ok: false, error: errorMessage(r), status: r.status }
if (r.status >= 400) { const error = errorMessage(r); log(`erp.update ${doctype}/${name} échec [${r.status}]: ${String(error).slice(0, 200)}`); return { ok: false, error, status: r.status } }
return { ok: true, data: r.data?.data }
}
async function remove (doctype, name) {
const r = await erpFetch(`/api/resource/${encDocType(doctype)}/${encName(name)}`, { method: 'DELETE' })
if (r.status >= 400) return { ok: false, error: errorMessage(r), status: r.status }
if (r.status >= 400) { const error = errorMessage(r); log(`erp.remove ${doctype}/${name} échec [${r.status}]: ${String(error).slice(0, 200)}`); return { ok: false, error, status: r.status } }
return { ok: true }
}

View File

@ -133,8 +133,16 @@ function deepGetValue (obj, path) {
return node?._value !== undefined ? node._value : null
}
// CORS partagé pour les endpoints PUBLICS (données publiques, lecture seule + écritures internes).
// Pose les en-têtes via setHeader (fusionnés par writeHead de json()). methods optionnel.
function cors (res, methods = 'GET, POST, OPTIONS') {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', methods)
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
}
module.exports = {
log, json, parseBody, httpRequest,
log, json, parseBody, httpRequest, cors,
erpFetch, erpRequest, lookupCustomerByPhone, createCommunication,
nbiRequest, deepGetValue,
}

View File

@ -0,0 +1,431 @@
'use strict'
/**
* legacy-dispatch-sync.js PONT legacy (osTicket/MariaDB) Dispatch Job (ERPNext).
*
* Tire RÉGULIÈREMENT les tickets ouverts assignés au compte « Tech Targo »
* (staff id 3301 dans la DB legacy `gestionclient`) et crée/maj un Dispatch Job
* dans ERPNext pour les répartir sur la grille Planification / le tableau Dispatch.
*
* Pourquoi 3301 : dans le legacy, le travail terrain à dispatcher est assigné au
* compte générique « Tech Targo » (default_staff des dépts Installation/Réparation/
* Fibre). C'est exactement « les tickets assignés à tech targo ».
*
* IDEMPOTENT : chaque ticket legacy porte un `legacy_ticket_id` sur le Dispatch Job.
* On cherche avant de créer jamais de doublon. On NE clobbe PAS le travail du
* répartiteur : un job déjà assigné/déplacé n'est plus touché (maj de date seulement
* tant qu'il est encore `open` + non assigné).
*
* Routes : GET /dispatch/legacy-sync/preview (dry-run, 0 écriture) · POST /dispatch/legacy-sync/run
* Récurrence : startSync() (setInterval, cf. server.js), désactivable via LEGACY_DISPATCH_SYNC=off.
*
* Pré-requis : champ Custom Field `legacy_ticket_id` sur Dispatch Job
* (dispatch-app/frappe-setup/setup_dispatch_custom_fields.py).
*/
const erp = require('./erp')
const cfg = require('./config')
const { log, json, httpRequest } = require('./helpers')
const { searchAddressesRpc } = require('./address-search') // recherche trigram RQA (RPC pg_trgm) — celle de l'autocomplete de dispo
const addrdb = require('./address-db') // pool PG local (camping_registry)
// Campings : l'adresse de service est un terrain de camping (≠ résidence du client). On force la géoloc
// FIXE du camping (registre camping_registry). Détection robuste : le texte doit contenir « camping » OU
// un mot-clé de LIEU spécifique (évite les faux positifs de patronyme, ex. « Daniel Dauphinais »).
const CAMP_PLACE_KW = ['lac des pins', 'lac de pins', 'sandysun', 'sandy sun', 'frontiere', 'ensoleill']
let _campings = null; let _campingsAt = 0
async function getCampings () {
if (_campings && (Date.now() - _campingsAt) < 600000) return _campings // cache 10 min
try { const r = await addrdb.pool().query('SELECT keyword, name, latitude, longitude FROM camping_registry WHERE active'); _campings = r.rows; _campingsAt = Date.now() } catch (e) { _campings = _campings || [] }
return _campings
}
// fields en ORDRE DE PRIORITÉ (sujet d'abord = label de service explicite, puis ville/adresse de delivery).
// Le 1er champ qui contient un signal camping décide → évite qu'une ville de delivery (résidence) écrase le sujet.
function campingFor (campings, fields) {
for (const f of (Array.isArray(fields) ? fields : [fields])) {
const t = norm(f || '')
if (!(t.includes('camping') || CAMP_PLACE_KW.some(k => t.includes(k)))) continue
for (const c of campings) if (c.keyword && t.includes(c.keyword)) return c
}
return null
}
let mysql
try { mysql = require('mysql2/promise') } catch { /* dépendance optionnelle */ }
const TARGO_TECH_STAFF_ID = Number(process.env.LEGACY_TARGO_STAFF_ID) || 3301 // compte « Tech Targo » (pool de dispatch)
// Parseurs/mapping PURS extraits dans util/legacy-parse (testables en isolation) :
// DEPT_JOBTYPE/DUR + jobType/prio/tzDate/startTime/coord. + norm (util/text). (Phase 1 : logique pure séparée des I/O.)
const { DEPT_JOBTYPE, DUR, jobType, prio, tzDate, startTime, coord } = require('./util/legacy-parse')
const { norm } = require('./util/text')
// Géocodage de repli via RQA (Répertoire des adresses du Québec) — source autoritaire, fiable en
// rural (vs Mapbox qui peut dévier de plusieurs km). Cache au niveau MODULE (persiste entre les ticks)
// → chaque adresse n'est géocodée qu'une fois par cycle de vie du hub ; les échecs sont mémorisés
// (valeur null) pour ne PAS marteler RQA à chaque cycle. N'accepte qu'une correspondance fiable (≥0.7).
// Géocodage RQA via la RECHERCHE TRIGRAM (RPC `search_addresses`, pg_trgm) — celle de l'autocomplete de
// dispo. Trouve les rues que l'ilike manquait (générique géré par la colonne `long` + trigram phase 2).
// GARDE-FOU de zone : le civique doit concorder ET le CP OU la ville doit confirmer la région → rejette
// les faux positifs trigram hors-territoire (ex. « Rue Grenet, Montréal » quand un civique René-Vinet
// absent du RQA déclenche la phase 2). Cache module (1 appel/adresse/vie ; échecs mémorisés).
const _geoCache = new Map()
async function geocodeRQA (addressLine, postalCode, city) {
const key = norm([addressLine, postalCode, city].filter(Boolean).join('|'))
if (!key || !addressLine) return null
if (_geoCache.has(key)) return _geoCache.get(key)
let res = null
try {
const rows = await searchAddressesRpc(addressLine, 8)
if (rows && rows.length) {
const civic = (String(addressLine).match(/^\s*(\d+)/) || [])[1] || null
const fsa = String(postalCode || '').replace(/\s+/g, '').toUpperCase().slice(0, 3)
const cityN = norm(city)
const GEN = ['rue', 'rang', 'chemin', 'ch', 'route', 'rte', 'avenue', 'av', 'ave', 'boul', 'boulevard', 'bd', 'montee', 'cote', 'place', 'pl', 'allee', 'terrasse', 'croissant', 'des', 'de', 'du', 'la', 'le', 'aux']
const streetToks = norm(addressLine).replace(/^\s*\d+\s*/, '').split(/[\s-]+/).filter(w => w.length >= 3 && !GEN.includes(w)) // tokens significatifs du nom de rue
const streetOk = (r) => { if (!streetToks.length) return true; const hay = norm((r.odonyme_recompose_normal || '') + ' ' + (r.adresse_formatee || '')); return streetToks.some(w => hay.includes(w)) }
const pick = rows.find(r => {
if (!coord(r.latitude, r.longitude)) return false
if (civic && String(r.numero_municipal || '') !== civic) return false // mauvais numéro civique → rejet
if (!streetOk(r)) return false // bon civique mais mauvaise rue (faux positif trigram) → rejet
const rFsa = String(r.code_postal || '').replace(/\s+/g, '').toUpperCase().slice(0, 3)
// En TERRITOIRE Targo (J0L/J0S, déjà priorisé par la RPC + filtrage mots-de-rue en phase 1) → on fait
// confiance au classement RPC (= l'autocomplete client). Civique + rue concordent déjà.
if (rFsa === 'J0L' || rFsa === 'J0S') return true
// Hors territoire → exiger une concordance EXPLICITE avec l'enregistrement legacy (CP OU ville),
// sinon rejet (ex. faux positif trigram « Rue Grenet, Montréal H4L »).
const rCity = norm(r.nom_municipalite)
const postalOk = !!(fsa && rFsa && rFsa === fsa)
const cityOk = !!(cityN && rCity && (rCity.includes(cityN) || cityN.includes(rCity) || rCity.split('-')[0] === cityN.split('-')[0]))
return postalOk || cityOk
})
if (pick) res = coord(pick.latitude, pick.longitude)
}
} catch (e) { log('geocodeRQA error:', e.message) } // RQA indispo → pas de coords (échec mémorisé)
_geoCache.set(key, res)
return res
}
// Repli Mapbox (token public déjà utilisé par le Dispatch) pour les rues TROP RÉCENTES pour le RQA
// (nouveaux développements absents du répertoire). Moins précis en rural que le RQA mais « une coord
// vaut mieux que zéro » pour le routage. Contraint au Québec (country=ca + proximity Montérégie +
// bornes coord()). Cache module. Désactivé si MAPBOX_TOKEN absent de l'env.
const MAPBOX_TOKEN = process.env.MAPBOX_TOKEN || ''
const _mbCache = new Map()
async function geocodeMapbox (addressLine, city, postalCode) {
if (!MAPBOX_TOKEN || !addressLine) return null
const key = norm([addressLine, city, postalCode].filter(Boolean).join('|'))
if (_mbCache.has(key)) return _mbCache.get(key)
let res = null
try {
const q = [addressLine, city, 'Québec'].filter(Boolean).join(', ')
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(q)}.json` +
`?country=ca&proximity=-73.5,45.2&limit=1&types=address&language=fr&access_token=${MAPBOX_TOKEN}`
const r = await httpRequest(url, '', { timeout: 12000 })
const f = r && r.data && Array.isArray(r.data.features) && r.data.features[0]
if (f && Array.isArray(f.center) && (f.relevance == null || f.relevance >= 0.6)) {
const c = coord(f.center[1], f.center[0]) // Mapbox = [lon, lat]
if (c) res = c
}
} catch (e) { log('geocodeMapbox error:', e.message) }
_mbCache.set(key, res)
return res
}
let _pool
function pool () {
if (!mysql) return null
if (!_pool) {
_pool = mysql.createPool({
host: cfg.LEGACY_DB_HOST, user: cfg.LEGACY_DB_USER, password: cfg.LEGACY_DB_PASS, database: cfg.LEGACY_DB_NAME,
connectionLimit: 2, waitForConnections: true, connectTimeout: 8000,
})
}
return _pool
}
// Lien d'activation STB/Ministra : DÉJÀ posté dans le fil du ticket par le wizard legacy à la vente.
// On le ré-extrait tel quel (zéro reconstruction). Sous-requête = le ticket_msg le plus récent qui le contient.
const ACTIVATION_RE = /https?:\/\/[^\s"'<>]*connect_ministra\.php[^\s"'<>]*/i
function extractActivationUrl (msg) { if (!msg) return ''; const m = String(msg).match(ACTIVATION_RE); return m ? m[0] : '' }
// Détail du ticket = 1er message du fil legacy (HTML osTicket) → texte lisible, tronqué, pour l'afficher dans Ops.
function stripHtml (html, max = 1500) {
if (!html) return ''
let s = String(html)
.replace(/<\s*br\s*\/?\s*>/gi, '\n').replace(/<\/\s*(p|div|li|tr)\s*>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, ' ').replace(/&amp;/gi, '&').replace(/&lt;/gi, '<').replace(/&gt;/gi, '>')
.replace(/&#0*39;|&#x27;|&apos;/gi, "'").replace(/&quot;/gi, '"')
.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim()
if (s.length > max) s = s.slice(0, max) + '…'
return s
}
async function fetchTargoTickets () {
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
const [rows] = await p.query(
`SELECT t.id, t.subject, t.dept_id, dd.name AS dept, t.due_date, t.due_time, t.priority, t.bon_id, t.account_id, t.delivery_id,
t.date_create, t.last_update,
a.first_name, a.last_name, a.company, a.address1, a.address2, a.city, a.state, a.zip,
dv.latitude AS dv_lat, dv.longitude AS dv_lon, dv.address1 AS dv_addr, dv.city AS dv_city, dv.zip AS dv_zip,
(SELECT mm.msg FROM ticket_msg mm
WHERE mm.ticket_id = t.id AND mm.msg LIKE '%connect_ministra%'
ORDER BY mm.id DESC LIMIT 1) AS activation_msg,
(SELECT mm3.msg FROM ticket_msg mm3
WHERE mm3.ticket_id = t.id ORDER BY mm3.id ASC LIMIT 1) AS first_msg
FROM ticket t
LEFT JOIN ticket_dept dd ON dd.id = t.dept_id
LEFT JOIN account a ON a.id = t.account_id
LEFT JOIN delivery dv ON dv.id = COALESCE(
NULLIF(t.delivery_id, 0),
(SELECT d2.id FROM delivery d2 WHERE d2.account_id = t.account_id AND d2.latitude IS NOT NULL AND d2.latitude <> 0 AND ABS(d2.latitude) > 1 ORDER BY d2.id DESC LIMIT 1),
(SELECT d3.id FROM delivery d3 WHERE d3.account_id = t.account_id ORDER BY d3.id DESC LIMIT 1)
)
WHERE t.status = 'open' AND t.assign_to = ?
ORDER BY t.due_date DESC`,
[TARGO_TECH_STAFF_ID],
)
return rows || []
}
// caches par run (vidés à chaque cycle) pour éviter les requêtes répétées
let _custCache = new Map()
let _slCache = new Map()
function resetCaches () { _custCache = new Map(); _slCache = new Map() }
async function resolveCustomer (accountId) {
if (!accountId) return null
const k = String(accountId)
if (_custCache.has(k)) return _custCache.get(k)
const r = await erp.list('Customer', { filters: [['legacy_account_id', '=', k]], fields: ['name', 'customer_name'], limit: 1 })
const c = (r && r[0]) || null
_custCache.set(k, c)
return c
}
async function resolveServiceLocation (custName, city) {
if (!custName) return null
let list = _slCache.get(custName)
if (!list) {
list = (await erp.list('Service Location', { filters: [['customer', '=', custName]], fields: ['name', 'address_line', 'city', 'latitude', 'longitude'], limit: 10 })) || []
_slCache.set(custName, list)
}
if (!list.length) return null
if (city) { const hit = list.find(l => norm(l.city) === norm(city)); if (hit) return hit } // préfère la ville qui matche
return list[0]
}
// Construit le payload Dispatch Job à partir d'un ticket legacy (+ infos de matching).
async function buildJob (t) {
const cust = await resolveCustomer(t.account_id)
const sl = cust ? await resolveServiceLocation(cust.name, t.city) : null
const jt = jobType(t.dept_id)
const cname = cust ? cust.customer_name : ([t.first_name, t.last_name].filter(Boolean).join(' ') || t.company || '')
// Coords : la table legacy `delivery` (point de service réel, via ticket.delivery_id) est la
// source la plus fiable (lat/long par adresse). On préfère donc l'adresse de service à l'adresse
// de facturation du compte, et les coords delivery aux coords Service Location ERPNext (placeholders).
const dc = coord(t.dv_lat, t.dv_lon)
const svcAddr = [t.dv_addr, t.dv_city, t.dv_zip].filter(Boolean).join(', ')
const billAddr = [t.address1, t.address2, t.city, t.state, t.zip].filter(Boolean).join(', ')
const addr = svcAddr || billAddr
let subject = (t.subject || '').trim() || ([t.dept, cname].filter(Boolean).join(' — '))
if (!sl && addr) subject = (subject + ' · ' + addr) // pas de Service Location → on garde l'adresse visible dans le sujet
subject = subject.slice(0, 140) // Subject = champ Data Frappe (max 140 car.) ; le détail complet est dans legacy_detail/coords
const payload = {
ticket_id: 'LEG-' + t.id,
subject,
job_type: jt,
duration_h: DUR[jt] || 1,
priority: prio(t.priority),
status: 'open',
order_source: 'Manual',
legacy_ticket_id: String(t.id),
legacy_dept: t.dept || '', // département legacy granulaire → coloriage « comme legacy » (Installation Fibre / Réparation Fibre / Télé / Téléphonie…)
}
const actUrl = extractActivationUrl(t.activation_msg); if (actUrl) payload.legacy_activation_url = actUrl // lien connect_ministra (déjà dans le fil)
// En-tête de dates (ouverture + dernière MàJ) pour juger l'ancienneté → décider de fermer ; puis la description.
const dateHdr = '🗓 Ouvert ' + (tzDate(t.date_create) || '?') + (t.last_update ? ' · MàJ ' + tzDate(t.last_update) : '')
const detail = [dateHdr, stripHtml(t.first_msg)].filter(Boolean).join('\n\n')
if (detail) payload.legacy_detail = detail // description + dates → visible dans Ops (mouseover panneau + détail Dispatch)
const sd = tzDate(t.due_date); if (sd) payload.scheduled_date = sd
const st = startTime(t.due_time); if (st) payload.start_time = st
if (cust) payload.customer = cust.name
let coordSrc = null
// CAMPING (priorité max) : l'adresse de service est un terrain de camping → géoloc FIXE du camping,
// pas la résidence du client. Signal = sujet/ville/adresse de service du ticket.
const camp = campingFor(await getCampings(), [t.subject, t.dv_city, t.dv_addr])
if (camp) { payload.latitude = camp.latitude; payload.longitude = camp.longitude; coordSrc = 'camping' }
if (!coordSrc && dc) { payload.latitude = dc.lat; payload.longitude = dc.lon; coordSrc = 'delivery' } // point de service legacy
if (sl) {
payload.service_location = sl.name
if (!coordSrc) { const sc = coord(sl.latitude, sl.longitude); if (sc) { payload.latitude = sc.lat; payload.longitude = sc.lon; coordSrc = 'service_location' } } // repli si pas de delivery
}
if (!coordSrc && addr) { // replis géocodage sur l'adresse de service (sinon facturation) : RQA (autoritaire) puis Mapbox (couverture)
const useSvc = !!svcAddr
const line = useSvc ? t.dv_addr : t.address1
const zip = useSvc ? t.dv_zip : t.zip
const ci = useSvc ? t.dv_city : t.city
const g = await geocodeRQA(line, zip, ci)
if (g) { payload.latitude = g.lat; payload.longitude = g.lon; coordSrc = 'rqa_geocode' }
else { const mb = await geocodeMapbox(line, ci, zip); if (mb) { payload.latitude = mb.lat; payload.longitude = mb.lon; coordSrc = 'mapbox_geocode' } }
}
return { legacy_id: String(t.id), payload, matched: { customer: !!cust, service_location: !!sl, customer_name: cname, coords: !!coordSrc, coord_src: coordSrc, delivery_id: t.delivery_id || null }, dept: t.dept, addr }
}
async function findExisting (legacyId) {
const r = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'status', 'assigned_tech', 'scheduled_date', 'subject', 'legacy_dept', 'legacy_activation_url', 'legacy_detail', 'latitude', 'longitude', 'service_location'], limit: 1 })
return (r && r[0]) || null
}
// VERROU de sérialisation : frappe_pg ne supporte pas la concurrence. Le tick récurrent ET les runs
// manuels (preview/run) passent tous par `sync()` → on les met en FILE pour qu'ils ne se chevauchent
// JAMAIS (sinon « socket hang up » + écritures perdues dans un rollback). Chaque appel attend le précédent.
let _syncLock = Promise.resolve()
function sync (opts = {}) {
const run = _syncLock.then(() => syncImpl(opts), () => syncImpl(opts))
_syncLock = run.then(() => {}, () => {}) // le suivant attend, quel que soit le résultat
return run
}
// Cœur : parcourt les tickets, crée/maj les Dispatch Jobs. SÉQUENTIEL (frappe_pg ne supporte pas la concurrence).
async function syncImpl ({ dryRun = false } = {}) {
resetCaches()
const tickets = await fetchTargoTickets()
let created = 0, updated = 0, skipped = 0, errors = 0, unmatched = 0, coordsFilled = 0, noCoords = 0
const coordTally = {} // observabilité : répartition des sources de coords (delivery/service_location/rqa_geocode/none)
const errSamples = [] // observabilité : échantillon des erreurs create/update (« ne rien échapper »)
const details = []
for (const t of tickets) {
try {
const b = await buildJob(t)
if (!b.matched.customer) unmatched++
coordTally[b.matched.coord_src || 'none'] = (coordTally[b.matched.coord_src || 'none'] || 0) + 1
if (!b.matched.coords) noCoords++ // ni delivery ni Service Location ni RQA → routage indisponible (à diagnostiquer)
const ex = await findExisting(b.legacy_id)
if (ex) {
// Déjà importé. Backfill du département (métadonnée couleur, sans risque) + maj date SEULEMENT
// s'il est encore au pool (open + non assigné) → on ne clobbe jamais le travail du répartiteur.
const patch = {}
if (!ex.legacy_dept && b.payload.legacy_dept) patch.legacy_dept = b.payload.legacy_dept
if (!ex.legacy_activation_url && b.payload.legacy_activation_url) patch.legacy_activation_url = b.payload.legacy_activation_url // backfill lien activation (sans risque)
if (b.payload.legacy_detail && ex.legacy_detail !== b.payload.legacy_detail) patch.legacy_detail = b.payload.legacy_detail // (re)backfill description + dates (idempotent : ne réécrit que si différent)
// Coords (localisation, sans risque pour l'ordonnancement) : on remplit si absentes/0 côté ERPNext,
// ET on UPGRADE vers les coords `delivery` (point de service exact) si elles diffèrent des coords
// existantes (souvent issues du Service Location, moins précises). delivery écrase ; SL/RQA non.
const hasCoord = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 0.0001
const exHas = hasCoord(ex.latitude) && hasCoord(ex.longitude)
// delivery (point exact) ET camping (géoloc fixe du camping vs résidence) ÉCRASENT des coords existantes différentes ; SL/RQA/Mapbox non.
const isUpgrade = (b.matched.coord_src === 'delivery' || b.matched.coord_src === 'camping') && exHas &&
(Math.abs(parseFloat(ex.latitude) - b.payload.latitude) > 1e-5 || Math.abs(parseFloat(ex.longitude) - b.payload.longitude) > 1e-5)
if (b.payload.latitude != null && (!exHas || isUpgrade)) { patch.latitude = b.payload.latitude; patch.longitude = b.payload.longitude; coordsFilled++ }
if (!ex.service_location && b.payload.service_location) patch.service_location = b.payload.service_location // backfill lien Service Location
if (ex.status === 'open' && !ex.assigned_tech && b.payload.scheduled_date && b.payload.scheduled_date !== ex.scheduled_date) patch.scheduled_date = b.payload.scheduled_date
// Rafraîchit le sujet (qui inclut l'adresse de SERVICE) pour les jobs encore au pool (open + non assigné),
// sans surprendre un tech sur un job déjà dispatché. Corrige les sujets anciens basés sur la facturation.
if (ex.status === 'open' && !ex.assigned_tech && b.payload.subject && ex.subject !== b.payload.subject) patch.subject = b.payload.subject
if (!dryRun && Object.keys(patch).length) {
const r = await erp.update('Dispatch Job', ex.name, patch)
if (r && r.ok) { updated++; details.push({ legacy_id: b.legacy_id, action: 'update', job: ex.name, patch }) }
else { errors++; const msg = (r && r.error) || 'update failed'; errSamples.push({ legacy_id: b.legacy_id, action: 'update', error: String(msg).slice(0, 200) }); details.push({ legacy_id: b.legacy_id, action: 'update-failed', job: ex.name, error: msg }) }
} else skipped++
} else if (dryRun) {
created++; details.push({ legacy_id: b.legacy_id, action: 'would-create', subject: b.payload.subject, job_type: b.payload.job_type, dept: b.dept, scheduled_date: b.payload.scheduled_date || null, start_time: b.payload.start_time || null, customer: b.matched.customer_name, customer_matched: b.matched.customer, sl_matched: b.matched.service_location, coords: b.matched.coords, coord_src: b.matched.coord_src, delivery_id: b.matched.delivery_id, addr: b.addr })
} else {
const r = await erp.create('Dispatch Job', b.payload)
if (r && r.ok) { created++; details.push({ legacy_id: b.legacy_id, action: 'created', job: r.name, subject: b.payload.subject, customer_matched: b.matched.customer }) }
else { errors++; const msg = (r && r.error) || 'create failed'; errSamples.push({ legacy_id: b.legacy_id, action: 'create', error: String(msg).slice(0, 200) }); details.push({ legacy_id: b.legacy_id, action: 'create-failed', error: msg }) }
}
} catch (e) {
errors++; details.push({ legacy_id: String(t.id), error: String((e && e.message) || e) })
}
}
let closedResolved = 0
if (!dryRun) { try { const cr = await closeResolved(); closedResolved = cr.closed } catch (e) { log('closeResolved error:', e.message) } } // retire les DJ dont le ticket legacy est fermé
const summary = { ok: true, dryRun, tech_staff_id: TARGO_TECH_STAFF_ID, tickets: tickets.length, created, updated, skipped, errors, unmatched_customer: unmatched, coords_filled: coordsFilled, no_coords: noCoords, coord_src: coordTally, error_samples: errSamples.slice(0, 6), closed: closedResolved }
if (!dryRun) { _lastRun = { at: new Date().toISOString(), ...summary }; log(`legacy-dispatch-sync: ${JSON.stringify(summary)}`) } // heartbeat
return { ...summary, details }
}
// Réconciliation : prouve qu'AUCUN ticket n'est échappé. Compare legacy(assign_to=3301, open) ↔ Dispatch Jobs (legacy_ticket_id).
async function reconcile () {
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
const [rows] = await p.query('SELECT id FROM ticket WHERE status = ? AND assign_to = ?', ['open', TARGO_TECH_STAFF_ID])
const legacyIds = new Set((rows || []).map(r => String(r.id)))
// Dispatch Jobs issus du pont (legacy_ticket_id renseigné)
const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', '']], fields: ['name', 'legacy_ticket_id', 'status', 'assigned_tech'], limit: 5000 })
const erpIds = new Set((djs || []).map(j => String(j.legacy_ticket_id)))
const missing = [...legacyIds].filter(id => !erpIds.has(id)) // legacy ouvert mais PAS dans ERPNext = échappé → à corriger
// orphelins = DJ encore "open"/non assigné dont le ticket legacy n'est plus ouvert(3301) (fermé/réassigné côté legacy)
const stillOpen = (djs || []).filter(j => j.status === 'open' && !j.assigned_tech)
const orphan = stillOpen.filter(j => !legacyIds.has(String(j.legacy_ticket_id))).map(j => ({ job: j.name, legacy_ticket_id: j.legacy_ticket_id }))
return { ok: true, legacy_open_3301: legacyIds.size, erpnext_bridged: erpIds.size, missing_count: missing.length, missing, orphan_count: orphan.length, orphan, last_sync: _lastRun }
}
// Auto-fermeture : un Dispatch Job issu du pont dont le ticket legacy est passé `closed` → on le marque « Completed »
// (sort du pool / des listes ouvertes). NE touche PAS « In Progress » (tech en action). SÉQUENTIEL.
async function closeResolved () {
const p = pool(); if (!p) return { checked: 0, closed: 0 }
const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['status', 'in', ['open', 'assigned', 'On Hold']]], fields: ['name', 'legacy_ticket_id', 'status'], limit: 5000 })
if (!djs.length) return { checked: 0, closed: 0 }
const ids = [...new Set(djs.map(j => parseInt(j.legacy_ticket_id)).filter(Boolean))]
const [rows] = await p.query('SELECT id, status FROM ticket WHERE id IN (?)', [ids])
const st = {}; for (const r of rows) st[String(r.id)] = r.status
let closed = 0; const details = []
for (const j of djs) {
if (st[j.legacy_ticket_id] === 'closed') { // fermé côté legacy → on retire d'ERPNext
try { await erp.update('Dispatch Job', j.name, { status: 'Completed' }); closed++; details.push({ job: j.name, legacy_ticket_id: j.legacy_ticket_id }) } catch (e) {}
}
}
return { checked: djs.length, closed, details }
}
// Fil COMPLET d'un ticket legacy (description + commentaires/réponses des collaborateurs) — read-only.
async function ticketThread (legacyId) {
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
const id = String(legacyId || '').replace(/[^0-9]/g, ''); if (!id) return { ok: false, error: 'id invalide' }
const [trows] = await p.query('SELECT subject, status FROM ticket WHERE id = ? LIMIT 1', [id])
const [rows] = await p.query(
`SELECT mm.id, mm.date_orig, mm.staff_id, s.first_name, s.last_name, s.username, mm.msg
FROM ticket_msg mm LEFT JOIN staff s ON s.id = mm.staff_id
WHERE mm.ticket_id = ? ORDER BY mm.id ASC LIMIT 200`, [id])
const messages = (rows || []).map(r => ({
at: r.date_orig ? new Date(Number(r.date_orig) * 1000).toISOString() : null,
author: [r.first_name, r.last_name].filter(Boolean).join(' ') || r.username || (r.staff_id ? ('Staff ' + r.staff_id) : 'Système / client'),
text: stripHtml(r.msg, 6000),
})).filter(m => m.text)
return { ok: true, ticket: id, subject: (trows && trows[0] && trows[0].subject) || '', status: (trows && trows[0] && trows[0].status) || '', count: messages.length, messages }
}
// ── Récurrence (setInterval) ──
let _timer = null
let _lastRun = null // heartbeat : dernier passage réussi (pour /status + Uptime-Kuma)
function startSync () {
// OPT-IN : la récurrence ne démarre QUE si LEGACY_DISPATCH_SYNC ∈ {on,1,true}.
// (Évite toute écriture automatique surprise au boot ; preview/run manuels restent dispo via les routes.)
if (!/^(on|1|true)$/i.test(String(process.env.LEGACY_DISPATCH_SYNC || ''))) { log('legacy-dispatch-sync: récurrence désactivée (poser LEGACY_DISPATCH_SYNC=on pour activer)'); return }
if (!mysql) { log('legacy-dispatch-sync: mysql2 absent → pont inactif'); return }
const minutes = Number(process.env.LEGACY_DISPATCH_SYNC_MIN) || 15
const tick = () => sync({ dryRun: false }).catch(e => log('legacy-dispatch-sync tick error:', e.message))
// 1er passage différé (laisse le boot se stabiliser), puis toutes les `minutes`.
setTimeout(tick, 90 * 1000)
_timer = setInterval(tick, minutes * 60 * 1000)
log(`legacy-dispatch-sync: pont actif (toutes les ${minutes} min, staff ${TARGO_TECH_STAFF_ID})`)
}
function stopSync () { if (_timer) { clearInterval(_timer); _timer = null } }
async function handle (req, res, method, path) {
try {
if (path === '/dispatch/legacy-sync/preview' && method === 'GET') return json(res, 200, await sync({ dryRun: true }))
if (path === '/dispatch/legacy-sync/run' && method === 'POST') return json(res, 200, await sync({ dryRun: false }))
if (path === '/dispatch/legacy-sync/reconcile' && method === 'GET') return json(res, 200, await reconcile())
if (path === '/dispatch/legacy-sync/close-resolved' && method === 'POST') return json(res, 200, await closeResolved())
if (path === '/dispatch/legacy-sync/ticket-thread' && method === 'GET') { const id = new URL(req.url, 'http://localhost').searchParams.get('id'); return json(res, 200, await ticketThread(id)) }
if (path === '/dispatch/legacy-sync/status' && method === 'GET') { // heartbeat pour Uptime-Kuma (keyword "stale":false)
const ageMin = _lastRun ? Math.round((Date.now() - Date.parse(_lastRun.at)) / 60000) : null
const max = (Number(process.env.LEGACY_DISPATCH_SYNC_MIN) || 15) * 3 // toléré = 3 ticks
return json(res, 200, { ok: true, enabled: /^(on|1|true)$/i.test(String(process.env.LEGACY_DISPATCH_SYNC || '')), last_sync: _lastRun, age_min: ageMin, stale: ageMin == null || ageMin > max })
}
return json(res, 404, { error: 'route inconnue' })
} catch (e) {
return json(res, 500, { error: String((e && e.message) || e) })
}
}
module.exports = { handle, sync, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType } // parseurs purs exposés pour les tests

View File

@ -277,6 +277,20 @@ function skillForJob (job) {
const map = getBookingPolicy().skill_by_type || {}
return String(map[job.service_type] || '').trim()
}
// Repli : déduit une COMPÉTENCE (parmi les skills réels des techs) depuis le département/type legacy.
// Sert à colorer les tickets par la couleur de leur compétence (éditable via le gestionnaire de tags).
function deptToSkill (txt) {
const d = String(txt || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
if (!d) return ''
if (/teleph/.test(d)) return 'telephone'
if (/tele|televis/.test(d)) return 'tv'
if (/fusion|episs/.test(d)) return 'épissure'
if (/monteur|aerien/.test(d)) return 'monteur'
if (/netadmin|net admin/.test(d)) return 'netadmin'
if (/repar|desinstall/.test(d)) return 'réparation'
if (/install|fibre/.test(d)) return 'installation'
return ''
}
// Enrichit des jobs avec une adresse LISIBLE (le champ service_location est un code « LOC-… »).
// Batch : 1 seule requête sur Service Location pour tous les codes distincts.
async function attachLocations (jobs) {
@ -511,7 +525,7 @@ async function occupancyByTechDay (start, days) {
const dates = rangeDates(start, days)
const jobs = await erp.list('Dispatch Job', {
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress']]],
fields: ['name', 'subject', 'customer_name', 'service_type', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority'], limit: 5000,
fields: ['name', 'subject', 'customer_name', 'service_type', 'job_type', 'legacy_dept', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'route_order', 'latitude', 'longitude', 'booking_status', 'legacy_detail', 'legacy_ticket_id'], limit: 5000,
})
const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 } // ordre d'affichage
const m = {}
@ -521,11 +535,13 @@ async function occupancyByTechDay (start, days) {
const o = m[k] || (m[k] = { h: 0, blocks: [], jobs: [] })
const dur = Number(j.duration_h) || 0
o.h += dur
const skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject) // compétence → couleur du bloc (palette skills)
const s = j.start_time ? timeToH(j.start_time) : null
if (s != null) o.blocks.push({ s, e: s + dur })
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, priority: j.priority || 'low' })
if (s != null) o.blocks.push({ s, e: s + dur, skill, job: j.name }) // 1 bloc = 1 job, coloré par sa compétence
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', legacy_id: j.legacy_ticket_id || '', detail: (j.legacy_detail || '').slice(0, 400) })
}
for (const k in m) m[k].jobs.sort((a, b) => (PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3) || ((a.start_h ?? 99) - (b.start_h ?? 99))) // priorité puis heure
// ordre = route_order manuel s'il existe, sinon priorité puis heure
for (const k in m) m[k].jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || (PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3) || ((a.start_h ?? 99) - (b.start_h ?? 99)))
return m
}
@ -734,9 +750,9 @@ async function handle (req, res, method, path, url) {
}
// Jobs À ASSIGNER (non assignés) avec leur groupe/dépendances (parent_job, depends_on, step_order, chaîne On Hold).
if (path === '/roster/unassigned-jobs' && method === 'GET') {
const rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'subject', 'customer_name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech'], orderBy: 'modified desc', limit: 400 })
const rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'subject', 'customer_name', 'service_location', 'service_type', 'job_type', 'legacy_dept', 'legacy_detail', 'legacy_ticket_id', 'legacy_activation_url', 'priority', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech'], orderBy: 'modified desc', limit: 400 })
const jobs = (rows || []).filter(j => !j.assigned_tech) // non assignés
for (const j of jobs) j.required_skill = skillForJob(j)
for (const j of jobs) j.required_skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject) // skill explicite, sinon déduit du type → couleur
await attachLocations(jobs)
return json(res, 200, { jobs })
}
@ -757,6 +773,30 @@ async function handle (req, res, method, path, url) {
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, tech: b.tech, start_time: placed, duration_h: dur })
}
// Réordonner / re-prioriser les jobs d'un tech×jour (depuis le menu de la cellule).
// body.updates = [{ job, route_order, priority? }] — SÉQUENTIEL (frappe_pg).
if (path === '/roster/reorder-jobs' && method === 'POST') {
const b = await parseBody(req); const ups = b.updates || []
let ok = 0; let errors = 0
for (const u of ups) {
if (!u.job) continue
const patch = {}
if (u.route_order != null) patch.route_order = Number(u.route_order) || 0
if (u.priority) patch.priority = u.priority
if (u.duration_h != null && Number(u.duration_h) > 0) patch.duration_h = Number(u.duration_h) // durée éditée dans le timeline
if (u.start_time) patch.start_time = (String(u.start_time).length === 5 ? u.start_time + ':00' : u.start_time) // heure recalculée par le planificateur de tournée
if (!Object.keys(patch).length) continue
const r = await retryWrite(() => erp.update('Dispatch Job', u.job, patch))
if (r.ok) ok++; else errors++
}
return json(res, 200, { ok: true, updated: ok, errors })
}
// Retirer un job d'un tech (depuis l'éditeur de journée) → retour au pool (non assigné).
if (path === '/roster/unassign-job' && method === 'POST') {
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { assigned_tech: null, status: 'open', start_time: null }))
return json(res, r.ok ? 200 : 500, { ...r, job: b.job })
}
// Backfill : pose un start_time (premier trou libre) sur les jobs DÉJÀ assignés mais SANS heure
// → leurs blocs d'occupation apparaissent enfin sur la grille. Idempotent (ne touche que start_time vide).
if (path === '/roster/backfill-start-times' && method === 'POST') {

View File

@ -0,0 +1,36 @@
'use strict'
/**
* util/legacy-parse.js parseurs/mapping PURS du pont legacy (osTicket Dispatch Job).
* Aucune dépendance I/O (pg/mysql/erp) testable en isolation (Phase 1 : logique pure séparée des I/O).
*/
// dept_id legacy → job_type Dispatch Job (valeurs valides : Installation/Réparation/Retrait/Dépannage/Autre)
const DEPT_JOBTYPE = {
27: 'Installation', 12: 'Installation', 7: 'Installation', // Installation Fibre / Installation / Monteur
26: 'Réparation', 10: 'Réparation', 33: 'Réparation', // Réparation Fibre / Réparation / Fusionneur
15: 'Retrait', // Désinstallation
}
const DUR = { Installation: 2, 'Réparation': 1.5, Retrait: 1, 'Dépannage': 1, Autre: 1 } // durée par défaut (le legacy n'en a pas)
const jobType = (deptId) => DEPT_JOBTYPE[deptId] || 'Autre'
const prio = (p) => { p = Number(p) || 0; return p >= 3 ? 'high' : p === 2 ? 'medium' : 'low' }
// due_date legacy = epoch à minuit LOCAL → date America/Toronto (évite le décalage UTC)
const tzDate = (unix) => (unix ? new Date(Number(unix) * 1000).toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) : null)
function startTime (dueTime) {
if (!dueTime) return null
const m = String(dueTime).match(/^(\d{1,2}):(\d{2})/)
if (m) return m[1].padStart(2, '0') + ':' + m[2] + ':00'
const t = String(dueTime).toLowerCase()
if (t === 'am') return '08:00:00'
if (t === 'pm') return '13:00:00'
return null // 'day' / inconnu → pas d'heure précise
}
// Coords legacy = chaînes ("-73.5599440" / "45.2528570"). Parse + valide les bornes Québec
// (lat 44→63, lon -80→-57) pour rejeter 0/0, placeholders et valeurs aberrantes → routage fiable.
function coord (lat, lon) {
const la = parseFloat(lat), lo = parseFloat(lon)
if (!isFinite(la) || !isFinite(lo)) return null
if (la < 44 || la > 63 || lo < -80 || lo > -57) return null
return { lat: la, lon: lo }
}
module.exports = { DEPT_JOBTYPE, DUR, jobType, prio, tzDate, startTime, coord }

View File

@ -0,0 +1,17 @@
'use strict'
/**
* util/text.js helpers texte PARTAGÉS (Phase 1 modularisation : -duplication).
* Remplace les -implémentations locales de `norm` (address-db, legacy-dispatch-sync, ).
*/
// Normalisation pour comparaison/recherche : minuscules + sans accents (NFD) + espaces compactés + trim.
// (Surensemble des variantes locales — le compactage d'espaces est inoffensif et plus robuste.)
const norm = (s) => (s || '')
.toString()
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/\s+/g, ' ')
.trim()
module.exports = { norm }

View File

@ -4,7 +4,11 @@
"description": "SSE relay + unified message hub for Targo/Gigafibre",
"main": "server.js",
"scripts": {
"start": "node server.js"
"start": "node server.js",
"test": "vitest run"
},
"devDependencies": {
"vitest": "^2.1.9"
},
"dependencies": {
"mjml": "^5.2.2",

View File

@ -98,7 +98,8 @@ const server = http.createServer(async (req, res) => {
if (path.startsWith('/auth/')) return auth.handle(req, res, method, path, url)
if (path.startsWith('/conversations')) return conversation.handle(req, res, method, path, url)
if (path.startsWith('/traccar')) return traccar.handle(req, res, method, path)
if (path.startsWith('/address/')) return require('./lib/address-validate').handle(req, res, method, path)
if (path.startsWith('/address/conformity')) return require('./lib/address-conformity').handle(req, res, method, path)
if (path.startsWith('/address/') || path.startsWith('/rpc/')) return require('./lib/address-validate').handle(req, res, method, path)
if (path.startsWith('/magic-link')) return require('./lib/magic-link').handle(req, res, method, path)
if (path.startsWith('/portal/')) return require('./lib/portal-auth').handle(req, res, method, path)
// Lightweight tech mobile page: /t/{token}[/action]
@ -116,6 +117,7 @@ const server = http.createServer(async (req, res) => {
// iCal feed: /dispatch/calendar/TECH-001.ics?token=xxx (token auth, no SSO)
const icalMatch = path.match(/^\/dispatch\/calendar\/(.+)\.ics$/)
if (icalMatch && method === 'GET') return ical.handleCalendar(req, res, icalMatch[1], url.searchParams)
if (path.startsWith('/dispatch/legacy-sync')) return require('./lib/legacy-dispatch-sync').handle(req, res, method, path)
if (path.startsWith('/dispatch')) return dispatch.handle(req, res, method, path)
if (path.startsWith('/admin/pollers')) return require('./lib/poller-control').handle(req, res, method, path)
// Legacy-MariaDB analytical reports — must be checked BEFORE the ERPNext
@ -224,4 +226,7 @@ server.listen(cfg.PORT, '0.0.0.0', () => {
// Start PPA (pre-authorized payment) cron scheduler
try { require('./lib/payments').startPPACron() }
catch (e) { log('PPA cron failed to start:', e.message) }
// Pont legacy (osTicket) → Dispatch Job : tire les tickets « Tech Targo » à dispatcher
try { require('./lib/legacy-dispatch-sync').startSync() }
catch (e) { log('legacy-dispatch-sync failed to start:', e.message) }
})

View File

@ -0,0 +1,42 @@
// Tests des helpers PURS (Phase 1 : fondation de tests, zéro dépendance I/O).
// Couvre la logique critique du pipeline d'adresses/pont legacy que je ne veux plus voir régresser.
import { describe, it, expect } from 'vitest'
import { norm } from '../lib/util/text.js'
import { coord, prio, startTime, jobType, tzDate } from '../lib/util/legacy-parse.js'
describe('norm', () => {
it('minuscule + sans accents', () => expect(norm('RUE René-Vinet')).toBe('rue rene-vinet'))
it('compacte les espaces + trim', () => expect(norm(' Sainte Clotilde ')).toBe('sainte clotilde'))
it('ville accentuée', () => expect(norm('Sainte-Clotilde-de-Châteauguay')).toBe('sainte-clotilde-de-chateauguay'))
it('null/undefined → chaîne vide', () => { expect(norm(null)).toBe(''); expect(norm(undefined)).toBe('') })
})
describe('coord (bornes Québec)', () => {
it('coord QC valide (chaînes legacy)', () => expect(coord('45.2528570', '-73.5599440')).toEqual({ lat: 45.252857, lon: -73.559944 }))
it('0/0 rejeté', () => expect(coord(0, 0)).toBeNull())
it('hors bornes (Toronto lat 43.65) rejeté', () => expect(coord(43.65, -79.38)).toBeNull())
it('longitude hors bornes rejetée', () => expect(coord(45.5, -50)).toBeNull())
it('non-numérique rejeté', () => { expect(coord('abc', 'x')).toBeNull(); expect(coord(null, null)).toBeNull() })
})
describe('prio (priorité legacy → Dispatch)', () => {
it('≥3 → high', () => { expect(prio(3)).toBe('high'); expect(prio('5')).toBe('high') })
it('2 → medium', () => expect(prio(2)).toBe('medium'))
it('1/0/null → low', () => { expect(prio(1)).toBe('low'); expect(prio(0)).toBe('low'); expect(prio(null)).toBe('low') })
})
describe('startTime (due_time legacy → HH:MM:SS)', () => {
it('heure explicite', () => { expect(startTime('14:30')).toBe('14:30:00'); expect(startTime('9:05')).toBe('09:05:00') })
it('am/pm', () => { expect(startTime('am')).toBe('08:00:00'); expect(startTime('pm')).toBe('13:00:00') })
it('day / vide → null', () => { expect(startTime('day')).toBeNull(); expect(startTime('')).toBeNull() })
})
describe('jobType (dept_id → job_type)', () => {
it('mappings connus', () => { expect(jobType(27)).toBe('Installation'); expect(jobType(26)).toBe('Réparation'); expect(jobType(15)).toBe('Retrait') })
it('inconnu → Autre', () => expect(jobType(999)).toBe('Autre'))
})
describe('tzDate (epoch → date America/Toronto)', () => {
it('null → null', () => expect(tzDate(null)).toBeNull())
it('epoch → YYYY-MM-DD', () => expect(tzDate(1749182400)).toMatch(/^\d{4}-\d{2}-\d{2}$/))
})