# 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` où `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).
