--- # === 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/