feat(commercial) : géolocalisation des adresses Tiers (lat/lng + géocodage BAN + pin ajustable) (ERP-122)

Ajoute la géolocalisation aux adresses Client et Fournisseur, socle de la
tournée commerciale (M6 field-sales).

Back :
- migration : latitude/longitude NUMERIC(10,7), geo_manual BOOLEAN, geocoded_at
  TIMESTAMPTZ sur client_address et supplier_address (+ COMMENT ON COLUMN FR)
- GeolocatableAddressInterface (Shared/Domain/Contract) implémenté par les deux
  entités ; bornes WGS84 validées (Range -90/90, -180/180, messages FR)
- GeocoderInterface + BanGeocoder (api-adresse.data.gouv.fr), branché via
  AddressGeocoder dans les processors ; géocodage auto au create/update
- RG-6.08 : geo_manual=true fige les coordonnées (pas de réécriture auto)
- symfony/http-client passe en dépendance de production

Front :
- AddressGeoPin (Leaflet + OSM) : marqueur déplaçable -> PATCH lat/lng +
  geoManual=true, bouton Re-géocoder, badges « à géolocaliser » / « pin manuel »
- intégration dans les blocs adresse Client et Fournisseur

Tests : PHPUnit (géocodage create, non-réécriture RG-6.08, mapping BAN, bornes) +
Vitest (drag du pin, badges, re-géocodage).
This commit is contained in:
Matthieu
2026-06-11 14:31:35 +02:00
parent 431d831c8b
commit de4aaa1d64
34 changed files with 1966 additions and 204 deletions
+322
View File
@@ -0,0 +1,322 @@
---
# === IDENTITÉ ===
module: M6
nom: "Tournées commerciales terrain"
ecran: tournees-terrain
owner_spec: Matthieu
backup_spec: ""
version: V0.2
# Historique :
# V0.2 (2026-06-11) — RÉDUCTION DE SCOPE : suppression du volet « rapport de visite »
# (entité VisitReport, fichiers, offres de prix, note /5, saisie vocale, historique des
# visites) et du mode terrain mobile dédié. Périmètre recentré sur : géolocalisation,
# carte interactive, planification de tournées, et onglet « Carte » dans les fiches Tiers.
# V0.1 (2026-06-11) — Rédaction initiale (inspirée de Badger Maps, SPOTIO, Portatour, Nomadia).
date_redaction: 2026-06-11
# === LIENS ===
spec_front: ./spec-front.md
maquette_figma: ""
# === LIEN LESSTIME ===
lesstime_taskgroup_id: 28 # M6 — Tournées commerciales terrain (projet STARSEED #6)
lesstime_project_id: 6
statut_global: en_dev
# === DÉPENDANCES AMONT ===
depend_de:
- M1-clients # Client / ClientAddress (cible de visite + onglet Carte)
- M2-suppliers # Supplier / SupplierAddress (cible de visite + onglet Carte)
- Sites # rattachement site d'une adresse (déjà en place)
- Core # User (commercial), Role, Permission, JWT
- Shared # TimestampableBlamableTrait + contrats inter-modules
---
# Spec — Module 6 : Tournées commerciales terrain (`field_sales`)
> **Périmètre V0.2 (réduit)** : géolocalisation des adresses Tiers, carte interactive, planification
> de tournées (étapes, optimisation, navigation Waze/Maps, feuille de route PDF) et onglet « Carte »
> dans les fiches Client/Fournisseur. **Hors scope : tout rapport de visite** (compte-rendu, note,
> offres de prix, fichiers, saisie vocale) et le mode terrain mobile dédié.
## 1. Contexte & objectif
Donner aux commerciaux terrain (technico-commerciaux agricoles : visites d'exploitations, coopératives,
négoces) un outil de **planification de tournées** intégré à Starseed, reposant sur le référentiel Tiers
existant (Clients M1 + Fournisseurs M2). Fonctionne sur **desktop et mobile/tablette** (responsive, pas
d'offline en V1).
Le commercial doit pouvoir :
1. Voir ses Tiers sur une **carte interactive** (pins colorés par type : client / fournisseur / prospect / custom).
2. **Construire une tournée lui-même** : ajouter des étapes (une étape = une adresse précise d'un Tiers ou un
point libre), les **réordonner en drag & drop**, fixer une **heure de départ**.
3. Obtenir le **temps total** et le **temps entre chaque étape** (calcul auto), avec heure d'arrivée estimée.
4. Cliquer **« Trajet logique »** (V1, heuristique gratuite) puis **« Optimiser »** (V2, routier réel) pour
ordonner les étapes au mieux.
5. **Lancer la navigation** (Waze / Google Maps / Plan) vers une étape en un tap.
6. **Dupliquer** une tournée et **exporter une feuille de route PDF**.
7. Consulter un **onglet « Carte »** dans la fiche Client/Fournisseur affichant les adresses géolocalisées du Tiers.
## 2. Inspiration — logiciels de tournée de référence
| Logiciel | Pattern repris | Application Starseed |
|---|---|---|
| **Badger Maps** | *Lasso tool* : on entoure des Tiers sur la carte → route optimisée auto. Pins colorés par type. | Sélection lasso/rectangle sur la carte pour bâtir la tournée. Pins par type. |
| **SPOTIO** | Réordonnancement **drag & drop**, lieu de départ + bouton *Optimize*. | Liste d'étapes draggable, point de départ paramétrable, boutons « Trajet logique » / « Optimiser ». |
| **Portatour** | Optimisation en quelques secondes, temps entre RDV. | Durée de visite paramétrable par étape, intégrée au temps total. |
| **Nomadia Field Sales** | Carte + tournée dans un outil mobile responsive. | Écran de planification responsive desktop + mobile. |
## 3. Décisions d'architecture
### 3.1 Nouveau module `field_sales`
La tournée est **transverse** : elle vise aussi bien des Clients (M1) que des Fournisseurs (M2). Module dédié
`src/Module/FieldSales/` (ID `field_sales`, label « Tournées »), `REQUIRED = false` (activable via
`config/modules.php`).
**Règle ABSOLUE n°1 respectée** : `FieldSales` n'importe **aucune** classe de `Commercial`. Il référence les
Tiers visités via un **contrat partagé** `App\Shared\Domain\Contract\VisitableInterface` (`getId()`,
`getDisplayName()`, `getVisitableType()` = `client|supplier`) résolu par `resolve_target_entities`, comme
`ClientAddress` référence `SiteInterface` / `CategoryInterface`.
### 3.1.bis Une étape vise tout Tiers — et même un point libre
Une étape n'est pas limitée à Client/Fournisseur : elle vise **tout type de Tiers** (Client, Fournisseur,
**Prestataire** à venir) via `VisitableInterface` (extensible sans toucher au module FieldSales), **ou un point
`custom`** (prospect/RDV sans fiche : libellé + adresse + coordonnées saisis à la main). L'enum `tier_type` est
volontairement **ouvert** (string + Assert\Choice = types Visitable enregistrés + `custom`).
### 3.2 Géolocalisation portée par l'adresse Tiers — **FAIT (ticket M6.1 / ERP-122)**
`latitude` / `longitude` / `geo_manual` / `geocoded_at` sur `client_address` et `supplier_address`, géocodage
api-adresse.data.gouv.fr + pin ajustable. Prérequis routage : une étape sans coordonnées reste utilisable mais
**exclue du calcul de trajet** (badge « à géolocaliser »).
### 3.3 Carte interactive — Leaflet + OpenStreetMap (pas Google Maps JS)
Pour l'**affichage** carte/pins : **Leaflet** + tuiles **OpenStreetMap** (ou IGN). Gratuit, RGPD-friendly, pas
de clé facturée pour le rendu. Composant carte encapsulé dans `frontend/modules/field-sales/` ; côté
formulaire/filtre on reste sur les composants `Malio*`. La carte est une **exception documentée** à
`@malio/layer-ui` (type non couvert). Le **routing réel** (matrice de temps) est un service distinct (§ 3.4).
### 3.4 Stratégie de calcul de trajet — phasée
| Phase | Bouton | Moteur | Coût |
|---|---|---|---|
| **V1** | « Trajet logique » | Heuristique maison **plus proche voisin** (Haversine), départ fixé. Temps estimé = distance × vitesse moyenne paramétrable. | 0 € |
| **V2** | « Optimiser » | **Matrix API** (temps routiers réels) + optimisation TSP (OpenRouteService / OSRM / Mapbox). | Par appel (cache, debounce) |
Contrat `RouteEngineInterface` (`computeMatrix`, `optimizeOrder`, `estimateLegDurations`) posé dès la V1 avec
`HaversineRouteEngine`. La V2 ajoute `OrsRouteEngine` sans toucher au front. **On n'écrit jamais l'algo routier
— on branche un fournisseur.**
### 3.5 IDs entier auto-increment, Audit, Timestampable/Blamable
Cohérent avec M0/M1/M2. Toutes les entités métier : `#[Auditable]`, `implements TimestampableInterface,
BlamableInterface` + `use TimestampableBlamableTrait`. Entités auditées : `Tour`, `TourStop`.
## 4. Modèle de données
### 4.1 Adresses Tiers (M1 + M2) — **FAIT (ERP-122)**
Colonnes `latitude` NUMERIC(10,7), `longitude` NUMERIC(10,7), `geo_manual` BOOLEAN, `geocoded_at` TIMESTAMPTZ
sur `client_address` et `supplier_address` ; contrat `GeolocatableAddressInterface` côté `Shared`.
### 4.2 `Tour` (tournée) — `tour`
| Champ | Type | Règle |
|---|---|---|
| `id` | int PK | |
| `owner_id` | FK User | Commercial propriétaire. Tournée **personnelle** (RG-6.01). |
| `label` | varchar(120) | Nom libre. NotBlank. |
| `tour_date` | date | Date de réalisation. NotBlank. |
| `departure_time` | time | Heure de départ (alimente les ETA). Défaut 08:00. |
| `start_latitude` / `start_longitude` | numeric null | Point de départ (site commercial ou adresse libre). NULL → départ = 1re étape. |
| `start_label` | varchar(180) null | Libellé du point de départ. |
| `default_visit_minutes` | smallint default 30 | Durée de visite par défaut (temps total). |
| `status` | enum `draft\|planned\|in_progress\|done` | Cycle de vie (RG-6.02). |
| `total_distance_m` / `total_duration_s` | int null | Derniers totaux calculés (cache d'affichage). |
`#[Auditable]`, Timestampable/Blamable, soft delete (`deleted_at`). `GetCollection` paginée, filtrée par owner.
### 4.3 `TourStop` (étape) — `tour_stop`
| Champ | Type | Règle |
|---|---|---|
| `id` | int PK | |
| `tour_id` | FK Tour | onDelete CASCADE. |
| `tier_type` | string `client\|supplier\|…\|custom` | Cible (résolue via `VisitableInterface`). `custom` = point libre. |
| `tier_id` | int null | ID du Tiers référentiel. NULL si `custom`. |
| `address_id` | int null | Adresse précise visitée (un Tiers a plusieurs adresses — RG-6.03). NULL si `custom`. |
| `custom_label` | varchar(180) null | Libellé du point libre (obligatoire ssi `custom`). |
| `custom_address` | varchar(255) null | Adresse texte du point libre (ssi `custom`), géocodée. |
| `custom_latitude` / `custom_longitude` | numeric null | Coordonnées du point libre (pin ajustable). |
| `position` | smallint | Ordre dans la tournée (drag & drop). |
| `visit_minutes` | smallint null | Durée de visite spécifique (sinon `tour.default_visit_minutes`). |
| `leg_distance_m` / `leg_duration_s` | int null | Distance/temps **depuis l'étape précédente** (calculés). |
| `eta` | time null | Heure d'arrivée estimée. |
`#[Auditable]`, Timestampable/Blamable. Unicité `(tour_id, position)`. **Pas** de rapport rattaché (scope réduit).
> Deux étapes peuvent viser le même Tiers (RG-6.07) — pas d'unicité sur `tier_id`.
## 5. API (API Platform — providers/processors, jamais de controller)
| Méthode | Endpoint | Sécurité | Note |
|---|---|---|---|
| GET | `/api/tours` | `field_sales.tours.view` | Paginé, filtré sur `owner` courant (admin/bureau voient tout). |
| POST | `/api/tours` | `field_sales.tours.manage` | Crée une tournée draft. |
| GET/PATCH/DELETE | `/api/tours/{id}` | view / manage | DELETE = soft delete. |
| POST | `/api/tours/{tourId}/stops` | `field_sales.tours.manage` | Sous-ressource (Link toProperty `tour`, pattern ClientAddress). |
| PATCH/DELETE | `/api/tour_stops/{id}` | `field_sales.tours.manage` | PATCH `position` = drag & drop. |
| POST | `/api/tours/{id}/compute` | `field_sales.tours.manage` | Recalcule legs + ETA + totaux (`HaversineRouteEngine`). |
| POST | `/api/tours/{id}/optimize` | `field_sales.tours.manage` | Réordonne via `optimizeOrder()` puis recompute. |
| POST | `/api/tours/{id}/duplicate` | `field_sales.tours.manage` | Duplique étapes + départ à une nouvelle `tourDate` (RG-6.13). |
| GET | `/api/tours/{id}/roadbook.pdf` | `field_sales.tours.view` | Feuille de route PDF (skill `pdf`). |
| GET | `/api/visitable_tiers?bbox=...&q=...&type=client,supplier` | `field_sales.tours.view` | Pins dans la zone visible (carte). Paginé / `?pagination=false`. |
Toutes les collections sont **paginées** (règle ABSOLUE n°13). `/api/visitable_tiers` retourne un Paginator,
borné par `bbox`.
## 6. Écrans
### 6.1 Planification de tournée (carte interactive — responsive desktop + mobile)
Layout **split** inspiré de Badger/SPOTIO :
- **Carte interactive Leaflet** : pins des Tiers de la zone (couleur par type, filtrables). Sélection
**lasso/rectangle** → ajoute les Tiers entourés comme étapes. Clic pin → popup (nom, adresse, « + Ajouter »).
Tracé de la tournée dessiné par-dessus (polyline numérotée).
- **Panneau tournée** : nom, date, **heure de départ**, point de départ, liste d'**étapes draggable**
(n° + nom + adresse + ETA + temps depuis étape précédente), totaux (distance / durée / nb visites).
Boutons **« Trajet logique »**, **« Optimiser »**, **« Dupliquer »**, **« PDF »**.
- Chaque étape : menu **« Y aller »** (Waze / Google Maps / Plan via deep links), « Voir le Tiers ».
- Ajout d'un **point libre `custom`** (libellé + adresse + pin).
- En mobile, layout empilé : la navigation se fait via le bouton « Y aller » de chaque étape (pas de mode
terrain dédié).
### 6.2 Onglet « Carte » dans la fiche Client / Fournisseur
Nouvel onglet **« Carte »** dans la fiche **Client (M1)** et **Fournisseur (M2)** : **mini-carte Leaflet**
affichant **toutes les adresses géolocalisées du Tiers** (un marqueur par adresse, popup avec le libellé de
l'adresse). Vue d'ensemble des implantations du Tiers. Le **pin reste ajustable** par adresse (réutilise le
composant de l'onglet Adresse, déjà livré en ERP-122). Adresses sans coordonnées listées comme
« à géolocaliser ». Onglet visible sous `field_sales.tours.view` ; masqué si le module `field_sales` est désactivé.
### 6.3 Ajustement du pin (fiche adresse) — **FAIT (ERP-122)**
Mini-carte Leaflet avec marqueur déplaçable dans le bloc adresse M1/M2 ; drag → `latitude/longitude` +
`geo_manual = true` ; bouton « Re-géocoder depuis l'adresse ».
## 7. Géocodage des adresses — **FAIT (ERP-122)**
Service `GeocoderInterface` / `BanGeocoder` (api-adresse.data.gouv.fr). Correction manuelle systématique via le
pin (`geo_manual = true` fige — RG-6.08).
## 8. RBAC — 3 miroirs obligatoires
Permissions du module `field_sales` (méthode `permissions()` de `FieldSalesModule.php`) — **uniquement les
tournées** (plus de permissions `reports.*` depuis la réduction de scope) :
| Permission | Sens | Admin | Commerciale | Bureau |
|---|---|---|---|---|
| `field_sales.tours.view` | Voir les tournées + l'onglet Carte. | ✅ (toutes) | ✅ (les siennes) | ✅ (consultation) |
| `field_sales.tours.manage` | Créer/éditer/optimiser/dupliquer/supprimer une tournée. | ✅ | ✅ | ❌ |
Attribution : **Commerciale + Admin** = manage ; **Bureau** = view ; Compta exclue. À synchroniser dans les
**3 miroirs** (règle ABSOLUE n°8) : `config/sidebar.php` (section « Tournées » : item `tours` + i18n
`sidebar.field_sales.*`), `frontend/tests/e2e/_fixtures/personas.ts`, `SeedE2ECommand.php`. Sync :
`app:sync-permissions`.
## 9. Conventions & garde-fous (rappel)
- `declare(strict_types=1);` partout ; commentaires FR, code EN.
- Entités métier : `#[Auditable]` + Timestampable/Blamable. Libellés i18n `audit.entity.field_sales_tour` /
`_tourstop` dans `fr.json` (sinon `AuditableEntitiesHaveI18nLabelTest` casse `make test`).
- Migration modulaire : `COMMENT ON COLUMN` sur **chaque** colonne (FR ≤ 200 car.) + helper
`addStandardTimestampableBlamableComments()`.
- Toute collection paginée (`CollectionsArePaginatedTest`).
- Front : `useApi()` uniquement, composants `Malio*`, `MalioDataTable` + `usePaginatedList` pour les listes,
**pas d'état de tableau dans l'URL**. Carte Leaflet = exception documentée.
## 10. Règles de gestion (RG)
| RG | Règle | Garde-fou |
|---|---|---|
| **RG-6.01** | Tournée **personnelle** (`owner`). Commerciale ne voit/édite que les siennes ; Admin/Bureau voient tout en lecture. | Filtre Provider sur `owner` + RBAC. |
| **RG-6.02** | Cycle de vie `draft → planned → in_progress → done` (transitions libres en V1). | Enum + Assert\Choice. |
| **RG-6.03** | Une étape sur Tiers référentiel vise une adresse de ce Tiers (qui en a plusieurs). Ne s'applique pas aux `custom`. | Assert\Callback. |
| **RG-6.05** | Une étape n'entre dans le calcul que si son adresse a `latitude` ET `longitude`. Sinon « à géolocaliser », exclue des totaux. | `RouteEngine` + signalement front. |
| **RG-6.07** | Deux étapes peuvent viser le même Tiers (repasser plus tard). Unicité uniquement sur `(tour_id, position)`. | Index unique partiel. |
| **RG-6.08** | `geo_manual = true` fige les coordonnées (le géocodage auto ne réécrit plus). | Garde dans le géocodeur (FAIT). |
| **RG-6.11** | `eta` = `departure_time` + Σ(trajets précédents) + Σ(durées de visite précédentes). | `RouteEngine::estimateLegDurations()`. |
| **RG-6.12** | Une étape vise tout Tiers ou un point `custom`. Si `custom` : `tier_id`/`address_id` NULL, `custom_label` + coordonnées obligatoires. | Assert\Choice + Assert\Callback. |
| **RG-6.13** | Dupliquer copie départ + étapes (ordre/adresses/durées) à une nouvelle date ; ne copie pas les calculs (ETA/legs recalculés). | Service `TourDuplicator`. |
## 11. Tests à automatiser
- **Architecture (cassent `make test`)** : `ColumnsHaveSqlCommentTest`, `AuditableEntitiesHaveI18nLabelTest`
(`audit.entity.field_sales_tour` / `_tourstop`), `EntitiesAreTimestampableBlamableTest` (Tour, TourStop),
`CollectionsArePaginatedTest`, `EntityConstraintsHaveFrenchMessageTest`.
- **Back (PHPUnit)** : RG-6.03 (adresse hors Tiers → 422), RG-6.05 (étape sans coord exclue), RG-6.07 (doublon
Tiers accepté), RG-6.11 (ETA), RG-6.12 (custom cohérent), RG-6.13 (duplication sans calculs), filtre `owner`,
`HaversineRouteEngine` (ordre plus proche voisin sur un jeu de coordonnées connu).
- **Front (Vitest)** : `usePaginatedList` sur tournées, composable de planification (réordonnancement, totaux,
deep links), onglet Carte (marqueurs des adresses). **Pas de E2E** (règle d'or).
## 12. Hors-périmètre (HP)
- **HP-M6-1** : **rapport de visite** (compte-rendu, note /5, offres de prix, fichiers, catégorie, saisie
vocale, historique des visites) — **retiré du scope (V0.2)**, à réintroduire dans un module/lot ultérieur si besoin.
- **HP-M6-2** : mode terrain mobile dédié (vue du jour + check-in) — retiré ; navigation via l'écran de
planification responsive.
- **HP-M6-3** : routing routier réel + optimisation TSP (Matrix API) — V2 (V1 = heuristique Haversine).
- **HP-M6-4** : suggestion automatique des Tiers « à visiter » (façon Portatour) — V2.
- **HP-M6-5** : offline réel (PWA + synchro) — V3.
- **HP-M6-6** : partage / affectation de tournées entre commerciaux, planning d'équipe — V3.
- **HP-M6-7** : navigation multi-étapes poussée dans Waze (impossible techniquement) — navigation étape par étape.
## 13. Phasage
- **V1 (livrable)** : géoloc adresses + pin (FAIT) ; carte interactive + lasso ; tournée (création, drag & drop,
heure de départ, point de départ) sur tout Tiers + point `custom` ; **« Trajet logique »** + ETA + totaux ;
deep links Waze/Maps ; **duplication** ; **feuille de route PDF** ; **onglet Carte** dans les fiches
Client/Fournisseur ; responsive desktop + mobile.
- **V2** : bouton **« Optimiser »** (routing routier réel ORS/OSRM), temps trafic, suggestion des Tiers proches.
- **V3** : offline réel, partage/affectation de tournées.
## 14. Risques / points ouverts
- **Coût/quota routing en V2** : multi-tenant → cache, debounce, plafonds par tenant.
- **Limite Waze multi-étapes** : Waze ne prend qu'une destination → navigation étape par étape (assumé).
- **Reste à cadrer techniquement** : périmètre de visibilité Bureau (toutes les tournées vs les siennes).
---
## 📦 Tickets Lesstime (scope réduit V0.2)
TaskGroup Lesstime : **#28 — M6 Tournées commerciales terrain** (projet STARSEED #6). Tickets gros grain, chacun
avec un **prompt Fable** prêt à coller (consigne « adapte-toi à la config actuelle » incluse).
| # | Réf | Ticket | Effort | Tag | État |
|---|---|---|---|---|---|
| M6.1 | ERP-122 | Géolocaliser les adresses Tiers (lat/lng + pin) | L | Back+Front | ✅ Fait |
| M6.2 | ERP-123 | Fondations module field_sales + VisitableInterface + RBAC (tournées) | M | Back | Prêt à dev |
| M6.3 | ERP-124 | Entités & API Tournée + Étape | L | Back | Prêt à dev |
| M6.4 | ERP-125 | Calcul trajet, optimisation, duplication & roadbook PDF | L | Back | Prêt à dev |
| M6.5 | ERP-127 | Carte interactive + écran planification (responsive) | L | Front | Prêt à dev |
| M6.6 | ERP-129 | Onglet « Carte » dans les fiches Client & Fournisseur | M | Front | Prêt à dev |
| M6.7 | ERP-130 | Vérification : garde-fous archi, tests RG & golden path | M | Back+Front | Prêt à dev |
Supprimés à la réduction de scope : **ERP-126** (rapport de visite) et **ERP-128** (mode terrain mobile + formulaire rapport).
Ordre d'exécution : M6.2 → M6.3 → M6.4 → M6.5 → M6.6 → M6.7.
---
### Sources d'inspiration (logiciels de référence)
- Badger Maps — *Lasso* + carte : https://www.badgermapping.com/features/
- SPOTIO — drag & drop des étapes + optimize : https://support.spotio.com/hc/en-us/articles/360061370754-Routing-How-to-Build-and-Manage-Routes
- Portatour — multi-stop + recalcul auto : https://www.portatour.com/features/en
- Nomadia Field Sales — carte + tournée mobile : https://www.nomadia.com/ressources/blog/logiciel-commerciaux-itinerants/