diff --git a/composer.json b/composer.json
index b7deb03..0e5eb29 100644
--- a/composer.json
+++ b/composer.json
@@ -24,6 +24,7 @@
"symfony/expression-language": "8.0.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*",
+ "symfony/http-client": "8.0.*",
"symfony/intl": "8.0.*",
"symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0",
@@ -95,7 +96,6 @@
"doctrine/doctrine-fixtures-bundle": "^4.3",
"friendsofphp/php-cs-fixer": "^3.94",
"phpunit/phpunit": "^13.0",
- "symfony/browser-kit": "8.0.*",
- "symfony/http-client": "8.0.*"
+ "symfony/browser-kit": "8.0.*"
}
}
diff --git a/composer.lock b/composer.lock
index 649a02d..f5fba04 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "2dc5db01e7f5d6aecd5956749b21a092",
+ "content-hash": "b029c1484227c926d39dfd3ae5cb0699",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -5412,6 +5412,180 @@
],
"time": "2026-03-30T15:14:47+00:00"
},
+ {
+ "name": "symfony/http-client",
+ "version": "v8.0.13",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/http-client.git",
+ "reference": "c7f40f9103233630167c25c9a4570acf805fdade"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/http-client/zipball/c7f40f9103233630167c25c9a4570acf805fdade",
+ "reference": "c7f40f9103233630167c25c9a4570acf805fdade",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4",
+ "psr/log": "^1|^2|^3",
+ "symfony/http-client-contracts": "~3.4.4|^3.5.2",
+ "symfony/service-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "amphp/amp": "<3",
+ "php-http/discovery": "<1.15"
+ },
+ "provide": {
+ "php-http/async-client-implementation": "*",
+ "php-http/client-implementation": "*",
+ "psr/http-client-implementation": "1.0",
+ "symfony/http-client-implementation": "3.0"
+ },
+ "require-dev": {
+ "amphp/http-client": "^5.3.2",
+ "amphp/http-tunnel": "^2.0",
+ "guzzlehttp/promises": "^1.4|^2.0",
+ "nyholm/psr7": "^1.0",
+ "php-http/httplug": "^1.0|^2.0",
+ "psr/http-client": "^1.0",
+ "symfony/cache": "^7.4|^8.0",
+ "symfony/dependency-injection": "^7.4|^8.0",
+ "symfony/http-kernel": "^7.4|^8.0",
+ "symfony/messenger": "^7.4|^8.0",
+ "symfony/process": "^7.4|^8.0",
+ "symfony/rate-limiter": "^7.4|^8.0",
+ "symfony/stopwatch": "^7.4|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\HttpClient\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "http"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/http-client/tree/v8.0.13"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-24T09:58:02+00:00"
+ },
+ {
+ "name": "symfony/http-client-contracts",
+ "version": "v3.6.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/http-client-contracts.git",
+ "reference": "75d7043853a42837e68111812f4d964b01e5101c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
+ "reference": "75d7043853a42837e68111812f4d964b01e5101c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.6-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\HttpClient\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Test/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Generic abstractions related to HTTP clients",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-04-29T11:18:49+00:00"
+ },
{
"name": "symfony/http-foundation",
"version": "v8.0.8",
@@ -11785,180 +11959,6 @@
],
"time": "2026-03-30T15:14:47+00:00"
},
- {
- "name": "symfony/http-client",
- "version": "v8.0.8",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/http-client.git",
- "reference": "356e43d6994ae9d7761fd404d40f78691deabe0e"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e",
- "reference": "356e43d6994ae9d7761fd404d40f78691deabe0e",
- "shasum": ""
- },
- "require": {
- "php": ">=8.4",
- "psr/log": "^1|^2|^3",
- "symfony/http-client-contracts": "~3.4.4|^3.5.2",
- "symfony/service-contracts": "^2.5|^3"
- },
- "conflict": {
- "amphp/amp": "<3",
- "php-http/discovery": "<1.15"
- },
- "provide": {
- "php-http/async-client-implementation": "*",
- "php-http/client-implementation": "*",
- "psr/http-client-implementation": "1.0",
- "symfony/http-client-implementation": "3.0"
- },
- "require-dev": {
- "amphp/http-client": "^5.3.2",
- "amphp/http-tunnel": "^2.0",
- "guzzlehttp/promises": "^1.4|^2.0",
- "nyholm/psr7": "^1.0",
- "php-http/httplug": "^1.0|^2.0",
- "psr/http-client": "^1.0",
- "symfony/cache": "^7.4|^8.0",
- "symfony/dependency-injection": "^7.4|^8.0",
- "symfony/http-kernel": "^7.4|^8.0",
- "symfony/messenger": "^7.4|^8.0",
- "symfony/process": "^7.4|^8.0",
- "symfony/rate-limiter": "^7.4|^8.0",
- "symfony/stopwatch": "^7.4|^8.0"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Symfony\\Component\\HttpClient\\": ""
- },
- "exclude-from-classmap": [
- "/Tests/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
- "homepage": "https://symfony.com",
- "keywords": [
- "http"
- ],
- "support": {
- "source": "https://github.com/symfony/http-client/tree/v8.0.8"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://github.com/nicolas-grekas",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2026-03-30T15:14:47+00:00"
- },
- {
- "name": "symfony/http-client-contracts",
- "version": "v3.6.0",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/http-client-contracts.git",
- "reference": "75d7043853a42837e68111812f4d964b01e5101c"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
- "reference": "75d7043853a42837e68111812f4d964b01e5101c",
- "shasum": ""
- },
- "require": {
- "php": ">=8.1"
- },
- "type": "library",
- "extra": {
- "thanks": {
- "url": "https://github.com/symfony/contracts",
- "name": "symfony/contracts"
- },
- "branch-alias": {
- "dev-main": "3.6-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Symfony\\Contracts\\HttpClient\\": ""
- },
- "exclude-from-classmap": [
- "/Test/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Generic abstractions related to HTTP clients",
- "homepage": "https://symfony.com",
- "keywords": [
- "abstractions",
- "contracts",
- "decoupling",
- "interfaces",
- "interoperability",
- "standards"
- ],
- "support": {
- "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2025-04-29T11:18:49+00:00"
- },
{
"name": "symfony/process",
"version": "v8.0.8",
diff --git a/config/services.yaml b/config/services.yaml
index fa6c942..012de5a 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -33,3 +33,16 @@ services:
App\Module\Sites\Application\Service\CurrentSiteProviderInterface:
alias: App\Module\Sites\Application\Service\CurrentSiteProvider
+
+ # Geocodage des adresses Tiers (M6.1) : BAN api-adresse.data.gouv.fr.
+ App\Shared\Domain\Contract\GeocoderInterface:
+ alias: App\Shared\Infrastructure\Geocoding\BanGeocoder
+
+# En test : geocodeur en memoire, deterministe et sans reseau (les tests
+# fonctionnels d'adresse ne doivent jamais appeler la BAN reelle).
+when@test:
+ services:
+ App\Tests\Fixtures\Geocoding\InMemoryGeocoder: ~
+
+ App\Shared\Domain\Contract\GeocoderInterface:
+ alias: App\Tests\Fixtures\Geocoding\InMemoryGeocoder
diff --git a/docs/specs/M6-field-sales/spec.md b/docs/specs/M6-field-sales/spec.md
new file mode 100644
index 0000000..7c4797e
--- /dev/null
+++ b/docs/specs/M6-field-sales/spec.md
@@ -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/
diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json
index d3ea681..855094c 100644
--- a/frontend/i18n/locales/fr.json
+++ b/frontend/i18n/locales/fr.json
@@ -49,6 +49,14 @@
"commercial": {
"title": "Commercial",
"welcome": "Module Commercial",
+ "geo": {
+ "title": "Position géographique",
+ "toGeolocate": "À géolocaliser",
+ "manualPin": "Pin ajusté manuellement",
+ "dragHint": "Déplacez le marqueur pour ajuster la position exacte (lieu-dit, entrée de site...).",
+ "regeocode": "Re-géocoder depuis l'adresse",
+ "regeocodeFailed": "Adresse introuvable — position inchangée."
+ },
"suppliers": {
"title": "Répertoire fournisseurs",
"add": "Ajouter",
diff --git a/frontend/modules/commercial/components/AddressGeoPin.vue b/frontend/modules/commercial/components/AddressGeoPin.vue
new file mode 100644
index 0000000..7434cf6
--- /dev/null
+++ b/frontend/modules/commercial/components/AddressGeoPin.vue
@@ -0,0 +1,216 @@
+
+
+
+ {{ t('commercial.geo.title') }}
+
+
+ {{ t('commercial.geo.toGeolocate') }}
+
+
+
+ {{ t('commercial.geo.manualPin') }}
+
+
+
+
+
+
+ {{ t('commercial.geo.dragHint') }}
+
+
+
+
+
+ {{ t('commercial.geo.regeocodeFailed') }}
+
+
+
+
+
+
diff --git a/frontend/modules/commercial/components/ClientAddressBlock.vue b/frontend/modules/commercial/components/ClientAddressBlock.vue
index 3533a7d..5d2e15a 100644
--- a/frontend/modules/commercial/components/ClientAddressBlock.vue
+++ b/frontend/modules/commercial/components/ClientAddressBlock.vue
@@ -178,6 +178,19 @@
/>
+
+
+
@@ -289,6 +302,24 @@ function update(field: K, value: AddressFormDr
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
+// Adresse postale a re-geocoder (« rue, code postal ville ») — miroir du
+// getDisplayLabel() serveur (le complement bruite le geocodage, exclu).
+const geocodeQuery = computed(() => {
+ const locality = [model.value.postalCode, model.value.city].filter(Boolean).join(' ')
+ const parts = [model.value.street, locality].filter(part => part && String(part).trim() !== '')
+ return parts.length > 0 ? parts.join(', ') : null
+})
+
+/** Pin deplace / re-geocode : repercute coordonnees + drapeau manuel (RG-6.08). */
+function onCoordsUpdate(coords: { latitude: string, longitude: string, geoManual: boolean }): void {
+ emit('update:modelValue', {
+ ...props.modelValue,
+ latitude: coords.latitude,
+ longitude: coords.longitude,
+ geoManual: coords.geoManual,
+ })
+}
+
/** Revele le 2e champ email de facturation (clic sur le « + »). */
function revealSecondaryBillingEmail(): void {
emit('update:modelValue', { ...props.modelValue, hasSecondaryBillingEmail: true })
diff --git a/frontend/modules/commercial/components/SupplierAddressBlock.vue b/frontend/modules/commercial/components/SupplierAddressBlock.vue
index 2c6eec6..cd8fa84 100644
--- a/frontend/modules/commercial/components/SupplierAddressBlock.vue
+++ b/frontend/modules/commercial/components/SupplierAddressBlock.vue
@@ -162,6 +162,19 @@
:readonly="readonly"
@update:model-value="(v: boolean) => update('triageProvider', v)"
/>
+
+
+
@@ -243,6 +256,24 @@ function update(field: K, value: Suppl
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
+// Adresse postale a re-geocoder (« rue, code postal ville ») — miroir du
+// getDisplayLabel() serveur (le complement bruite le geocodage, exclu).
+const geocodeQuery = computed(() => {
+ const locality = [model.value.postalCode, model.value.city].filter(Boolean).join(' ')
+ const parts = [model.value.street, locality].filter(part => part && String(part).trim() !== '')
+ return parts.length > 0 ? parts.join(', ') : null
+})
+
+/** Pin deplace / re-geocode : repercute coordonnees + drapeau manuel (RG-6.08). */
+function onCoordsUpdate(coords: { latitude: string, longitude: string, geoManual: boolean }): void {
+ emit('update:modelValue', {
+ ...props.modelValue,
+ latitude: coords.latitude,
+ longitude: coords.longitude,
+ geoManual: coords.geoManual,
+ })
+}
+
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
function notifyUnavailable(): void {
if (!unavailableNotified) {
diff --git a/frontend/modules/commercial/components/__tests__/AddressGeoPin.spec.ts b/frontend/modules/commercial/components/__tests__/AddressGeoPin.spec.ts
new file mode 100644
index 0000000..4dbfb59
--- /dev/null
+++ b/frontend/modules/commercial/components/__tests__/AddressGeoPin.spec.ts
@@ -0,0 +1,151 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
+import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
+import AddressGeoPin from '../AddressGeoPin.vue'
+
+// Mock Leaflet (hoisted) : capture le handler `dragend` et pilote la position
+// renvoyee par getLatLng — permet de simuler un drag du marqueur sans DOM reel.
+const leafletState = vi.hoisted(() => ({
+ dragendHandler: null as (() => void) | null,
+ markerPosition: { lat: 0, lng: 0 },
+}))
+
+vi.mock('leaflet', () => {
+ const marker = {
+ addTo: vi.fn().mockReturnThis(),
+ on: vi.fn((event: string, handler: () => void) => {
+ if (event === 'dragend') {
+ leafletState.dragendHandler = handler
+ }
+ }),
+ getLatLng: vi.fn(() => leafletState.markerPosition),
+ setLatLng: vi.fn(),
+ }
+ const map = {
+ setView: vi.fn().mockReturnThis(),
+ panTo: vi.fn(),
+ remove: vi.fn(),
+ }
+ const L = {
+ map: vi.fn(() => map),
+ tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
+ divIcon: vi.fn(() => ({})),
+ marker: vi.fn(() => marker),
+ }
+ return { default: L, ...L }
+})
+vi.mock('leaflet/dist/leaflet.css', () => ({ default: {} }))
+
+// Mock controlable du geocodage BAN (bouton « Re-geocoder »).
+const { geocodeMock } = vi.hoisted(() => ({ geocodeMock: vi.fn() }))
+vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
+ useAddressAutocomplete: () => ({ geocode: geocodeMock }),
+}))
+
+// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
+vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
+vi.stubGlobal('ref', ref)
+vi.stubGlobal('computed', computed)
+vi.stubGlobal('watch', watch)
+vi.stubGlobal('nextTick', nextTick)
+vi.stubGlobal('onMounted', onMounted)
+vi.stubGlobal('onBeforeUnmount', onBeforeUnmount)
+
+interface PinProps {
+ latitude?: string | null
+ longitude?: string | null
+ geoManual?: boolean
+ geocodeQuery?: string | null
+ readonly?: boolean
+}
+
+function mountPin(props: PinProps = {}) {
+ return mount(AddressGeoPin, {
+ props: {
+ latitude: null,
+ longitude: null,
+ geoManual: false,
+ geocodeQuery: '1 rue du Test, 86100 Châtellerault',
+ ...props,
+ },
+ global: {
+ stubs: { MalioButton: true },
+ },
+ })
+}
+
+beforeEach(() => {
+ leafletState.dragendHandler = null
+ geocodeMock.mockReset()
+})
+
+describe('AddressGeoPin — adresse sans coordonnees', () => {
+ it('affiche le badge « a geolocaliser » et aucune carte', () => {
+ const wrapper = mountPin()
+
+ expect(wrapper.find('[data-testid="geo-badge-missing"]').exists()).toBe(true)
+ expect(wrapper.find('[data-testid="geo-map"]').exists()).toBe(false)
+ })
+})
+
+describe('AddressGeoPin — drag du marqueur (RG-6.08)', () => {
+ it('emet les coordonnees corrigees avec geoManual=true au dragend', async () => {
+ const wrapper = mountPin({ latitude: '46.5802596', longitude: '0.3404333' })
+ await flushPromises() // import dynamique de Leaflet + montage carte
+
+ expect(leafletState.dragendHandler).not.toBeNull()
+
+ // L'utilisateur depose le pin ailleurs (lieu-dit mal geocode).
+ leafletState.markerPosition = { lat: 48.1234567, lng: -1.6543217 }
+ leafletState.dragendHandler?.()
+
+ const emitted = wrapper.emitted('update:coords')
+ expect(emitted).toHaveLength(1)
+ expect(emitted?.[0]?.[0]).toEqual({
+ latitude: '48.1234567',
+ longitude: '-1.6543217',
+ geoManual: true,
+ })
+ })
+
+ it('affiche le badge « pin manuel » quand geoManual est vrai', () => {
+ const wrapper = mountPin({ latitude: '46.58', longitude: '0.34', geoManual: true })
+
+ expect(wrapper.find('[data-testid="geo-badge-manual"]').exists()).toBe(true)
+ expect(wrapper.find('[data-testid="geo-badge-missing"]').exists()).toBe(false)
+ })
+})
+
+describe('AddressGeoPin — re-geocodage depuis l\'adresse', () => {
+ it('emet la position BAN avec geoManual=false (le back refera autorite au save)', async () => {
+ geocodeMock.mockResolvedValueOnce({ latitude: '46.5802596', longitude: '0.3404333' })
+ const wrapper = mountPin()
+
+ await wrapper.find('[data-testid="geo-regeocode"]').trigger('click')
+ await flushPromises()
+
+ expect(geocodeMock).toHaveBeenCalledWith('1 rue du Test, 86100 Châtellerault')
+ expect(wrapper.emitted('update:coords')?.[0]?.[0]).toEqual({
+ latitude: '46.5802596',
+ longitude: '0.3404333',
+ geoManual: false,
+ })
+ })
+
+ it('signale l\'echec sans emettre quand la BAN ne trouve rien', async () => {
+ geocodeMock.mockResolvedValueOnce(null)
+ const wrapper = mountPin()
+
+ await wrapper.find('[data-testid="geo-regeocode"]').trigger('click')
+ await flushPromises()
+
+ expect(wrapper.emitted('update:coords')).toBeUndefined()
+ expect(wrapper.find('[data-testid="geo-regeocode-failed"]').exists()).toBe(true)
+ })
+
+ it('masque le bouton en lecture seule', () => {
+ const wrapper = mountPin({ readonly: true })
+
+ expect(wrapper.find('[data-testid="geo-regeocode"]').exists()).toBe(false)
+ })
+})
diff --git a/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts b/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts
index 782349a..3a6f94f 100644
--- a/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts
+++ b/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts
@@ -65,6 +65,8 @@ function mountBlock(street: string | null) {
MalioSelectCheckbox: true,
MalioInputText: true,
MalioInputAutocomplete: MalioInputAutocompleteStub,
+ // Pin geographique (M6.1) : teste dans AddressGeoPin.spec.ts.
+ AddressGeoPin: true,
},
},
})
@@ -130,6 +132,8 @@ describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
MalioSelectCheckbox: true,
MalioInputAutocomplete: MalioInputAutocompleteStub,
MalioInputText: MalioInputTextProbe,
+ // Pin geographique (M6.1) : teste dans AddressGeoPin.spec.ts.
+ AddressGeoPin: true,
},
},
})
diff --git a/frontend/modules/commercial/types/clientForm.ts b/frontend/modules/commercial/types/clientForm.ts
index 9313bf3..3144d4f 100644
--- a/frontend/modules/commercial/types/clientForm.ts
+++ b/frontend/modules/commercial/types/clientForm.ts
@@ -51,6 +51,12 @@ export interface AddressFormDraft {
billingEmailSecondary: string | null
/** Drapeau UI : 2e champ email revele (comme hasSecondaryPhone). */
hasSecondaryBillingEmail: boolean
+ /** Latitude WGS84 (chaine decimale NUMERIC(10,7)), null si non geolocalisee (M6.1). */
+ latitude: string | null
+ /** Longitude WGS84 (chaine decimale NUMERIC(10,7)), null si non geolocalisee (M6.1). */
+ longitude: string | null
+ /** RG-6.08 : pin corrige a la main — le geocodage auto ne reecrit plus. */
+ geoManual: boolean
}
/** Un RIB du client (onglet Comptabilite). */
@@ -96,6 +102,9 @@ export function emptyAddress(): AddressFormDraft {
billingEmail: null,
billingEmailSecondary: null,
hasSecondaryBillingEmail: false,
+ latitude: null,
+ longitude: null,
+ geoManual: false,
}
}
diff --git a/frontend/modules/commercial/types/supplierForm.ts b/frontend/modules/commercial/types/supplierForm.ts
index 4f72ce6..fd9e906 100644
--- a/frontend/modules/commercial/types/supplierForm.ts
+++ b/frontend/modules/commercial/types/supplierForm.ts
@@ -55,6 +55,12 @@ export interface SupplierAddressFormDraft {
bennes: string | null
/** Prestation de triage (specifique fournisseur, portee par l'adresse — RG). */
triageProvider: boolean
+ /** Latitude WGS84 (chaine decimale NUMERIC(10,7)), null si non geolocalisee (M6.1). */
+ latitude: string | null
+ /** Longitude WGS84 (chaine decimale NUMERIC(10,7)), null si non geolocalisee (M6.1). */
+ longitude: string | null
+ /** RG-6.08 : pin corrige a la main — le geocodage auto ne reecrit plus. */
+ geoManual: boolean
}
/** Un RIB du fournisseur (onglet Comptabilite). */
@@ -95,6 +101,9 @@ export function emptyAddress(): SupplierAddressFormDraft {
contactIris: [],
bennes: '0',
triageProvider: false,
+ latitude: null,
+ longitude: null,
+ geoManual: false,
}
}
diff --git a/frontend/modules/commercial/utils/clientConsultation.ts b/frontend/modules/commercial/utils/clientConsultation.ts
index ae0366a..a7a3240 100644
--- a/frontend/modules/commercial/utils/clientConsultation.ts
+++ b/frontend/modules/commercial/utils/clientConsultation.ts
@@ -69,6 +69,10 @@ export interface AddressRead extends HydraRef {
isBilling?: boolean
isBroker?: boolean
isDistributor?: boolean
+ /** Geolocalisation (M6.1) : chaines decimales NUMERIC(10,7) cote API. */
+ latitude?: string | null
+ longitude?: string | null
+ geoManual?: boolean
sites?: SiteRead[]
categories?: CategoryRead[]
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
@@ -225,6 +229,9 @@ export function mapAddressToDraft(address: AddressRead): AddressFormDraft {
billingEmail: address.billingEmail ?? null,
billingEmailSecondary: address.billingEmailSecondary ?? null,
hasSecondaryBillingEmail: (address.billingEmailSecondary ?? null) !== null && address.billingEmailSecondary !== '',
+ latitude: address.latitude ?? null,
+ longitude: address.longitude ?? null,
+ geoManual: address.geoManual === true,
}
}
diff --git a/frontend/modules/commercial/utils/clientEdit.ts b/frontend/modules/commercial/utils/clientEdit.ts
index a0d4dff..ff9c1d4 100644
--- a/frontend/modules/commercial/utils/clientEdit.ts
+++ b/frontend/modules/commercial/utils/clientEdit.ts
@@ -254,6 +254,11 @@ export function buildAddressPayload(
contacts: address.contactIris,
billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null,
billingEmailSecondary: isBillingEmailRequired && address.hasSecondaryBillingEmail ? (address.billingEmailSecondary || null) : null,
+ // Geolocalisation (M6.1) : pin manuel persiste avec geoManual=true ;
+ // geoManual=false laisse le back regeocoder depuis l'adresse postale.
+ latitude: address.latitude || null,
+ longitude: address.longitude || null,
+ geoManual: address.geoManual,
}, ADDRESS_REQUIRED_NON_NULLABLE_KEYS, options)
}
diff --git a/frontend/modules/commercial/utils/supplierConsultation.ts b/frontend/modules/commercial/utils/supplierConsultation.ts
index 9264b61..f2abeae 100644
--- a/frontend/modules/commercial/utils/supplierConsultation.ts
+++ b/frontend/modules/commercial/utils/supplierConsultation.ts
@@ -76,6 +76,10 @@ export interface AddressRead extends HydraRef {
streetComplement?: string | null
bennes?: number | null
triageProvider?: boolean
+ /** Geolocalisation (M6.1) : chaines decimales NUMERIC(10,7) cote API. */
+ latitude?: string | null
+ longitude?: string | null
+ geoManual?: boolean
sites?: SiteRead[]
categories?: CategoryRead[]
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
@@ -200,6 +204,9 @@ export function mapAddressToDraft(address: AddressRead): SupplierAddressFormDraf
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
bennes: address.bennes != null ? String(address.bennes) : '0',
triageProvider: address.triageProvider ?? false,
+ latitude: address.latitude ?? null,
+ longitude: address.longitude ?? null,
+ geoManual: address.geoManual === true,
}
}
diff --git a/frontend/modules/commercial/utils/supplierEdit.ts b/frontend/modules/commercial/utils/supplierEdit.ts
index 2f8a5e5..b6dd346 100644
--- a/frontend/modules/commercial/utils/supplierEdit.ts
+++ b/frontend/modules/commercial/utils/supplierEdit.ts
@@ -237,6 +237,11 @@ export function buildAddressPayload(address: SupplierAddressFormDraft, options:
contacts: address.contactIris,
bennes: address.bennes !== null && address.bennes !== '' ? Number(address.bennes) : null,
triageProvider: address.triageProvider,
+ // Geolocalisation (M6.1) : pin manuel persiste avec geoManual=true ;
+ // geoManual=false laisse le back regeocoder depuis l'adresse postale.
+ latitude: address.latitude || null,
+ longitude: address.longitude || null,
+ geoManual: address.geoManual,
}, ADDRESS_REQUIRED_NON_NULLABLE_KEYS, options)
}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 43f4fc0..72037a3 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -12,6 +12,8 @@
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3",
+ "@types/leaflet": "^1.9.21",
+ "leaflet": "^1.9.4",
"nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0",
"pinia": "^3.0.4",
@@ -85,6 +87,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -582,27 +585,6 @@
"integrity": "sha512-/B8YJGPzaYq1NbsQmwgP8EZqg40NpTw4ZB3suuI0TplbxKHeK94jeaawLmVhCv+YwUnOpiWEz9U6SeThku/8JQ==",
"license": "MIT"
},
- "node_modules/@emnapi/core": {
- "version": "1.11.0",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz",
- "integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/wasi-threads": "1.2.2",
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@emnapi/runtime": {
- "version": "1.11.0",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
- "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
@@ -1303,6 +1285,7 @@
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
@@ -2221,6 +2204,7 @@
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.4.2.tgz",
"integrity": "sha512-5+IPRNX2CjkBhuWUwz0hBuLqiaJPRoKzQ+SvcdrQDbAyE+VDeFt74VpSFr5/R0ujrK4b+XnSHUJWdS72w6hsog==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"c12": "^3.3.3",
"consola": "^3.4.2",
@@ -2323,6 +2307,7 @@
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.4.2.tgz",
"integrity": "sha512-/q6C7Qhiricgi+PKR7ovBnJlKTL0memCbA1CzRT+itCW/oeYzUfeMdQ35mGntlBoyRPNrMXbzuSUhfDbSCU57w==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vue/shared": "^3.5.30",
"defu": "^6.1.4",
@@ -4638,6 +4623,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.2.tgz",
"integrity": "sha512-yjv2N7gaQMbIVfsSZHBMscLoybgetcTraXsSMrELAerl/jfRipg5S1dBXMFvgRy8Kh48+TGoH+5nqshxdOEGoQ==",
"license": "MIT",
+ "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -4886,6 +4872,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.23.2.tgz",
"integrity": "sha512-tRbbjpOPrY4ApIHtn3ctnKIhkkioewMsZa5gJzqVB47LJFNyzLXLo/aID4sJRKTIMi1wd1fA9TiBKPe6KqczPA==",
"license": "MIT",
+ "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -4991,6 +4978,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.23.2.tgz",
"integrity": "sha512-K2o1gMwn09nrd5ewftSy08U6LMC1cW3Cmml5+vHT9P/VeMtYwkbNg+9Mt1uFh7VfAZmlkj8d3u7RYqfl8xMVJA==",
"license": "MIT",
+ "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -5017,6 +5005,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.23.2.tgz",
"integrity": "sha512-kRHQ3nSbAfkFdxj9FtDdr4hpREndGgWFA6ZEAwlLeGUxf8QYTpuF9zb2yxdBPBlTc5+JsbPcskNt+u1PazGKYw==",
"license": "MIT",
+ "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -5031,6 +5020,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.23.2.tgz",
"integrity": "sha512-1kvsBqGNu2ZJ0P/lkxN0pAMqSyUcpkMIzE4xwGUIyAiD0pZV6dr+OCMwGWOTLllSyrn91xI5K7OLk3pYeCPKqA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-commands": "^1.6.2",
@@ -5140,12 +5130,27 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT"
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "license": "MIT"
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT"
},
+ "node_modules/@types/leaflet": {
+ "version": "1.9.21",
+ "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
+ "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
"node_modules/@types/linkify-it": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz",
@@ -5174,6 +5179,7 @@
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~7.19.0"
}
@@ -5236,6 +5242,7 @@
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.58.2",
"@typescript-eslint/types": "8.58.2",
@@ -6015,6 +6022,7 @@
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz",
"integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/parser": "^7.29.2",
"@vue/compiler-core": "3.5.32",
@@ -6258,6 +6266,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6645,6 +6654,7 @@
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"license": "Apache-2.0",
+ "peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -6842,6 +6852,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -6956,6 +6967,7 @@
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=8"
}
@@ -7150,7 +7162,8 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/clean-regexp": {
"version": "1.0.0",
@@ -8203,6 +8216,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -9361,6 +9375,7 @@
"integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
@@ -10532,6 +10547,12 @@
"safe-buffer": "~5.1.0"
}
},
+ "node_modules/leaflet": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -11807,6 +11828,7 @@
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.4.2.tgz",
"integrity": "sha512-iWVFpr/YEqVU/CenqIHMnIkvb2HE/9f+q8oxZ+pj2et+60NljGRClCgnmbvGPdmNFE0F1bEhoBCYfqbDOCim3Q==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@dxup/nuxt": "^0.4.0",
"@nuxt/cli": "^3.34.0",
@@ -12865,6 +12887,7 @@
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6",
@@ -12922,6 +12945,7 @@
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz",
"integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@oxc-project/types": "^0.112.0"
},
@@ -13188,6 +13212,7 @@
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
@@ -13313,6 +13338,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -13856,6 +13882,7 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -14646,6 +14673,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -15549,6 +15577,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -16228,6 +16257,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -16494,6 +16524,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -17412,6 +17443,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
"integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.32",
"@vue/compiler-sfc": "3.5.32",
@@ -17456,6 +17488,7 @@
"integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"debug": "^4.4.0",
"eslint-scope": "^8.2.0 || ^9.0.0",
@@ -17492,6 +17525,7 @@
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.1.tgz",
"integrity": "sha512-azq8fhVnCwJAw0iXW7i44h9P+Bj+snNuevBAaJ9bxn0I3YVsRU3deVFPNnTfZ2uxVJefGp83JUmL68ddCPw5Pw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@intlify/core-base": "11.3.1",
"@intlify/devtools-types": "11.3.1",
diff --git a/frontend/package.json b/frontend/package.json
index a60e49b..c9d2155 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -22,6 +22,8 @@
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3",
+ "@types/leaflet": "^1.9.21",
+ "leaflet": "^1.9.4",
"nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0",
"pinia": "^3.0.4",
diff --git a/frontend/shared/composables/useAddressAutocomplete.ts b/frontend/shared/composables/useAddressAutocomplete.ts
index 7854eb1..1d2560f 100644
--- a/frontend/shared/composables/useAddressAutocomplete.ts
+++ b/frontend/shared/composables/useAddressAutocomplete.ts
@@ -31,9 +31,21 @@ export interface AddressSuggestion {
city: string
}
+/** Coordonnees WGS84 d'une adresse geocodee (chaines decimales, format API). */
+export interface GeocodedCoordinates {
+ latitude: string
+ longitude: string
+}
+
export interface AddressAutocomplete {
searchCity(postalCode: string): Promise
searchAddress(query: string, postalCode?: string): Promise
+ /**
+ * Geocode une adresse complete en coordonnees (M6.1) — previsualisation du
+ * pin cote front uniquement : la valeur persistee reste celle du geocodage
+ * serveur (BanGeocoder) au save. `null` si la BAN ne trouve rien.
+ */
+ geocode(query: string): Promise
}
/** Erreur signalant que le service d'autocompletion BAN n'est pas disponible. */
@@ -57,7 +69,11 @@ interface BanFeatureProperties {
/** Reponse GeoJSON FeatureCollection de la BAN. */
interface BanResponse {
- features?: { properties?: BanFeatureProperties }[]
+ features?: {
+ properties?: BanFeatureProperties
+ /** GeoJSON : coordinates = [longitude, latitude]. */
+ geometry?: { coordinates?: [number, number] }
+ }[]
}
export function useAddressAutocomplete(): AddressAutocomplete {
@@ -113,5 +129,32 @@ export function useAddressAutocomplete(): AddressAutocomplete {
}
})
},
+
+ async geocode(query: string): Promise {
+ if (query.trim().length < 3) {
+ return null
+ }
+
+ let res: BanResponse
+ try {
+ res = await httpExternal(BAN_SEARCH_URL, {
+ query: { q: query, limit: '1' },
+ })
+ }
+ catch {
+ throw new AddressAutocompleteUnavailableError()
+ }
+
+ const coordinates = res.features?.[0]?.geometry?.coordinates
+ if (!coordinates || coordinates.length < 2) {
+ return null
+ }
+
+ // GeoJSON = [longitude, latitude] ; 7 decimales = format NUMERIC(10,7).
+ return {
+ latitude: coordinates[1].toFixed(7),
+ longitude: coordinates[0].toFixed(7),
+ }
+ },
}
}
diff --git a/migrations/Version20260611130000.php b/migrations/Version20260611130000.php
new file mode 100644
index 0000000..4422f84
--- /dev/null
+++ b/migrations/Version20260611130000.php
@@ -0,0 +1,73 @@
+addSql(sprintf('ALTER TABLE %s ADD COLUMN latitude NUMERIC(10, 7) DEFAULT NULL', $table));
+ $this->addSql(sprintf('ALTER TABLE %s ADD COLUMN longitude NUMERIC(10, 7) DEFAULT NULL', $table));
+ $this->addSql(sprintf('ALTER TABLE %s ADD COLUMN geo_manual BOOLEAN DEFAULT false NOT NULL', $table));
+ $this->addSql(sprintf('ALTER TABLE %s ADD COLUMN geocoded_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL', $table));
+
+ $this->comment($table, 'latitude', 'Latitude WGS84 de l adresse (geocodage BAN ou pin manuel). NULL = non geolocalisee, exclue du calcul de tournee (RG-6.05).');
+ $this->comment($table, 'longitude', 'Longitude WGS84 de l adresse (geocodage BAN ou pin manuel). NULL = non geolocalisee.');
+ $this->comment($table, 'geo_manual', 'Pin positionne/corrige a la main : si vrai, le geocodage auto ne reecrit plus les coordonnees (RG-6.08). Faux par defaut.');
+ $this->comment($table, 'geocoded_at', 'Date du dernier geocodage automatique reussi (NULL si jamais geocode ou pin 100% manuel).');
+ }
+ }
+
+ public function down(Schema $schema): void
+ {
+ foreach (self::TABLES as $table) {
+ $this->addSql(sprintf('ALTER TABLE %s DROP COLUMN latitude', $table));
+ $this->addSql(sprintf('ALTER TABLE %s DROP COLUMN longitude', $table));
+ $this->addSql(sprintf('ALTER TABLE %s DROP COLUMN geo_manual', $table));
+ $this->addSql(sprintf('ALTER TABLE %s DROP COLUMN geocoded_at', $table));
+ }
+ }
+
+ /**
+ * Emet un `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour
+ * eviter tout echappement.
+ */
+ private function comment(string $table, string $column, string $description): void
+ {
+ $this->addSql(sprintf(
+ 'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
+ '"'.str_replace('"', '""', $table).'"',
+ '"'.str_replace('"', '""', $column).'"',
+ $description,
+ ));
+ }
+}
diff --git a/src/Module/Commercial/Domain/Entity/ClientAddress.php b/src/Module/Commercial/Domain/Entity/ClientAddress.php
index 98d0a45..ff417fa 100644
--- a/src/Module/Commercial/Domain/Entity/ClientAddress.php
+++ b/src/Module/Commercial/Domain/Entity/ClientAddress.php
@@ -15,9 +15,11 @@ use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientAddressRepositor
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
+use App\Shared\Domain\Contract\GeolocatableAddressInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
+use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -89,7 +91,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Table(name: 'client_address')]
#[ORM\Index(name: 'idx_client_address_client', columns: ['client_id'])]
#[Auditable]
-class ClientAddress implements TimestampableInterface, BlamableInterface
+class ClientAddress implements TimestampableInterface, BlamableInterface, GeolocatableAddressInterface
{
use TimestampableBlamableTrait;
@@ -191,6 +193,35 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
#[Groups(['client_address:read', 'client_address:write'])]
private int $position = 0;
+ // Geolocalisation portee par l'adresse (M6.1, spec § 3.2 / § 4.1) :
+ // coordonnees WGS84 alimentees par le geocodage BAN automatique
+ // (AddressGeocoder, appele par le processor si geoManual = false) ou par le
+ // pin manuel cote front (PATCH latitude/longitude + geoManual = true).
+ // Doctrine decimal -> chaine PHP ; setter tolerant (le JSON porte un nombre).
+ #[ORM\Column(type: 'decimal', precision: 10, scale: 7, nullable: true)]
+ #[Assert\Range(notInRangeMessage: 'La latitude doit être comprise entre {{ min }} et {{ max }}.', min: -90, max: 90)]
+ #[Groups(['client_address:read', 'client_address:write'])]
+ private ?string $latitude = null;
+
+ #[ORM\Column(type: 'decimal', precision: 10, scale: 7, nullable: true)]
+ #[Assert\Range(notInRangeMessage: 'La longitude doit être comprise entre {{ min }} et {{ max }}.', min: -180, max: 180)]
+ #[Groups(['client_address:read', 'client_address:write'])]
+ private ?string $longitude = null;
+
+ // RG-6.08 : pin corrige a la main -> le geocodage auto ne reecrit plus les
+ // coordonnees. Groupe d'ECRITURE seul sur la propriete ; la LECTURE est
+ // portee par le getter isGeoManual() + SerializedName (meme piege booleen
+ // que isProspect : sans cela la cle serait droppee du JSON).
+ #[ORM\Column(name: 'geo_manual', options: ['default' => false])]
+ #[Groups(['client_address:write'])]
+ private bool $geoManual = false;
+
+ // Date du dernier geocodage automatique reussi — posee par AddressGeocoder,
+ // jamais ecrite par le client (lecture seule API).
+ #[ORM\Column(name: 'geocoded_at', type: 'datetimetz_immutable', nullable: true)]
+ #[Groups(['client_address:read'])]
+ private ?DateTimeImmutable $geocodedAt = null;
+
// RG-1.10 : au moins un site rattache a chaque adresse.
/** @var Collection */
#[ORM\ManyToMany(targetEntity: SiteInterface::class)]
@@ -540,6 +571,70 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
return $this;
}
+ public function getLatitude(): ?string
+ {
+ return $this->latitude;
+ }
+
+ public function setLatitude(float|string|null $latitude): static
+ {
+ $this->latitude = null === $latitude ? null : (string) $latitude;
+
+ return $this;
+ }
+
+ public function getLongitude(): ?string
+ {
+ return $this->longitude;
+ }
+
+ public function setLongitude(float|string|null $longitude): static
+ {
+ $this->longitude = null === $longitude ? null : (string) $longitude;
+
+ return $this;
+ }
+
+ // Groupe de lecture + nom serialise explicite (cf. note sur la propriete) :
+ // meme pattern que isProspect pour garantir la cle `geoManual` dans le JSON.
+ #[Groups(['client_address:read'])]
+ #[SerializedName('geoManual')]
+ public function isGeoManual(): bool
+ {
+ return $this->geoManual;
+ }
+
+ public function setGeoManual(bool $geoManual): static
+ {
+ $this->geoManual = $geoManual;
+
+ return $this;
+ }
+
+ public function getGeocodedAt(): ?DateTimeImmutable
+ {
+ return $this->geocodedAt;
+ }
+
+ public function setGeocodedAt(?DateTimeImmutable $geocodedAt): static
+ {
+ $this->geocodedAt = $geocodedAt;
+
+ return $this;
+ }
+
+ /**
+ * Adresse postale affichable / geocodable : « rue, code postal ville ». Le
+ * complement (etage, batiment) est volontairement exclu — il bruite le
+ * geocodage BAN (contrat GeolocatableAddressInterface, M6.1).
+ */
+ public function getDisplayLabel(): string
+ {
+ $locality = trim(implode(' ', array_filter([$this->postalCode, $this->city])));
+
+ return implode(', ', array_filter([$this->street, '' !== $locality ? $locality : null]));
+ }
+
/** @return Collection */
public function getSites(): Collection
{
diff --git a/src/Module/Commercial/Domain/Entity/SupplierAddress.php b/src/Module/Commercial/Domain/Entity/SupplierAddress.php
index 94a3aaf..066c1b9 100644
--- a/src/Module/Commercial/Domain/Entity/SupplierAddress.php
+++ b/src/Module/Commercial/Domain/Entity/SupplierAddress.php
@@ -15,9 +15,11 @@ use App\Module\Commercial\Infrastructure\Doctrine\DoctrineSupplierAddressReposit
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
+use App\Shared\Domain\Contract\GeolocatableAddressInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
+use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -96,7 +98,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Table(name: 'supplier_address')]
#[ORM\Index(name: 'idx_supplier_address_supplier', columns: ['supplier_id'])]
#[Auditable]
-class SupplierAddress implements TimestampableInterface, BlamableInterface
+class SupplierAddress implements TimestampableInterface, BlamableInterface, GeolocatableAddressInterface
{
use TimestampableBlamableTrait;
@@ -181,6 +183,35 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
#[ORM\Column(options: ['default' => 0])]
private int $position = 0;
+ // Geolocalisation portee par l'adresse (M6.1, spec § 3.2 / § 4.1) :
+ // coordonnees WGS84 alimentees par le geocodage BAN automatique
+ // (AddressGeocoder, appele par le processor si geoManual = false) ou par le
+ // pin manuel cote front (PATCH latitude/longitude + geoManual = true).
+ // Doctrine decimal -> chaine PHP ; setter tolerant (le JSON porte un nombre).
+ #[ORM\Column(type: 'decimal', precision: 10, scale: 7, nullable: true)]
+ #[Assert\Range(notInRangeMessage: 'La latitude doit être comprise entre {{ min }} et {{ max }}.', min: -90, max: 90)]
+ #[Groups(['supplier:item:read', 'supplier:write:addresses'])]
+ private ?string $latitude = null;
+
+ #[ORM\Column(type: 'decimal', precision: 10, scale: 7, nullable: true)]
+ #[Assert\Range(notInRangeMessage: 'La longitude doit être comprise entre {{ min }} et {{ max }}.', min: -180, max: 180)]
+ #[Groups(['supplier:item:read', 'supplier:write:addresses'])]
+ private ?string $longitude = null;
+
+ // RG-6.08 : pin corrige a la main -> le geocodage auto ne reecrit plus les
+ // coordonnees. Groupe d'ECRITURE seul sur la propriete ; la LECTURE est
+ // portee par le getter isGeoManual() + SerializedName (meme piege booleen
+ // que triageProvider : sans cela la cle serait droppee du JSON).
+ #[ORM\Column(name: 'geo_manual', options: ['default' => false])]
+ #[Groups(['supplier:write:addresses'])]
+ private bool $geoManual = false;
+
+ // Date du dernier geocodage automatique reussi — posee par AddressGeocoder,
+ // jamais ecrite par le client (lecture seule API).
+ #[ORM\Column(name: 'geocoded_at', type: 'datetimetz_immutable', nullable: true)]
+ #[Groups(['supplier:item:read'])]
+ private ?DateTimeImmutable $geocodedAt = null;
+
// RG-2.06 : au moins un site rattache a chaque adresse.
/** @var Collection */
#[ORM\ManyToMany(targetEntity: SiteInterface::class)]
@@ -372,6 +403,70 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
return $this;
}
+ public function getLatitude(): ?string
+ {
+ return $this->latitude;
+ }
+
+ public function setLatitude(float|string|null $latitude): static
+ {
+ $this->latitude = null === $latitude ? null : (string) $latitude;
+
+ return $this;
+ }
+
+ public function getLongitude(): ?string
+ {
+ return $this->longitude;
+ }
+
+ public function setLongitude(float|string|null $longitude): static
+ {
+ $this->longitude = null === $longitude ? null : (string) $longitude;
+
+ return $this;
+ }
+
+ // Groupe de lecture + nom serialise explicite (cf. note sur la propriete) :
+ // meme pattern que triageProvider pour garantir la cle `geoManual` dans le JSON.
+ #[Groups(['supplier:item:read'])]
+ #[SerializedName('geoManual')]
+ public function isGeoManual(): bool
+ {
+ return $this->geoManual;
+ }
+
+ public function setGeoManual(bool $geoManual): static
+ {
+ $this->geoManual = $geoManual;
+
+ return $this;
+ }
+
+ public function getGeocodedAt(): ?DateTimeImmutable
+ {
+ return $this->geocodedAt;
+ }
+
+ public function setGeocodedAt(?DateTimeImmutable $geocodedAt): static
+ {
+ $this->geocodedAt = $geocodedAt;
+
+ return $this;
+ }
+
+ /**
+ * Adresse postale affichable / geocodable : « rue, code postal ville ». Le
+ * complement (etage, batiment) est volontairement exclu — il bruite le
+ * geocodage BAN (contrat GeolocatableAddressInterface, M6.1).
+ */
+ public function getDisplayLabel(): string
+ {
+ $locality = trim(implode(' ', array_filter([$this->postalCode, $this->city])));
+
+ return implode(', ', array_filter([$this->street, '' !== $locality ? $locality : null]));
+ }
+
/** @return Collection */
public function getSites(): Collection
{
diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php
index 6fc350d..3ee27c1 100644
--- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php
+++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php
@@ -10,6 +10,7 @@ use ApiPlatform\State\ProcessorInterface;
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\ClientAddress;
+use App\Shared\Application\Service\AddressGeocoder;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -19,7 +20,9 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
*
* Sequence :
* - POST / PATCH : normalisation serveur du billingEmail en lowercase (RG-1.21)
- * via le ClientFieldNormalizer partage. Les autres regles de l'onglet Adresse
+ * via le ClientFieldNormalizer partage, puis geocodage automatique BAN
+ * (AddressGeocoder, M6.1) — no-op si le pin a ete corrige a la main
+ * (geoManual = true, RG-6.08). Les autres regles de l'onglet Adresse
* sont deja garanties en amont : RG-1.09 (code postal) et RG-1.10 (>= 1 site)
* par des contraintes Assert sur l'entite, RG-1.06/07/08/11 par des CHECK BDD.
* - DELETE : aucune regle metier specifique (suppression physique directe).
@@ -37,6 +40,7 @@ final class ClientAddressProcessor implements ProcessorInterface
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly ClientFieldNormalizer $normalizer,
+ private readonly AddressGeocoder $addressGeocoder,
private readonly EntityManagerInterface $em,
) {}
@@ -52,6 +56,7 @@ final class ClientAddressProcessor implements ProcessorInterface
$this->linkParent($data, $uriVariables);
$this->normalize($data);
+ $this->addressGeocoder->geocode($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierAddressProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierAddressProcessor.php
index 43f0d2b..d2120f9 100644
--- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierAddressProcessor.php
+++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierAddressProcessor.php
@@ -9,6 +9,7 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Commercial\Domain\Entity\SupplierAddress;
+use App\Shared\Application\Service\AddressGeocoder;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -19,7 +20,9 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
* perimetre ERP-88.
*
* Sequence :
- * - POST / PATCH : rattachement au fournisseur parent. Aucune normalisation
+ * - POST / PATCH : rattachement au fournisseur parent, puis geocodage
+ * automatique BAN (AddressGeocoder, M6.1) — no-op si le pin a ete corrige a
+ * la main (geoManual = true, RG-6.08). Aucune normalisation
* specifique (pas d'email de facturation au M2). Les regles de l'onglet
* Adresse sont garanties en amont par des contraintes sur l'entite, jouees
* par API Platform avant ce processor : RG-2.05 (code postal, Assert\Regex),
@@ -40,6 +43,7 @@ final class SupplierAddressProcessor implements ProcessorInterface
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
+ private readonly AddressGeocoder $addressGeocoder,
private readonly EntityManagerInterface $em,
) {}
@@ -54,6 +58,7 @@ final class SupplierAddressProcessor implements ProcessorInterface
}
$this->linkParent($data, $uriVariables);
+ $this->addressGeocoder->geocode($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
diff --git a/src/Shared/Application/Service/AddressGeocoder.php b/src/Shared/Application/Service/AddressGeocoder.php
new file mode 100644
index 0000000..09ff13d
--- /dev/null
+++ b/src/Shared/Application/Service/AddressGeocoder.php
@@ -0,0 +1,49 @@
+ coordonnees figees.
+ if ($address->isGeoManual()) {
+ return;
+ }
+
+ $coordinates = $this->geocoder->geocode($address->getDisplayLabel());
+ if (null === $coordinates) {
+ return;
+ }
+
+ $address
+ ->setLatitude($coordinates->latitude)
+ ->setLongitude($coordinates->longitude)
+ ->setGeocodedAt(new DateTimeImmutable())
+ ;
+ }
+}
diff --git a/src/Shared/Domain/Contract/GeocoderInterface.php b/src/Shared/Domain/Contract/GeocoderInterface.php
new file mode 100644
index 0000000..db80d8c
--- /dev/null
+++ b/src/Shared/Domain/Contract/GeocoderInterface.php
@@ -0,0 +1,24 @@
+ 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).',
'billing_email_secondary' => '2e email de facturation, optionnel (max 2). Interdit hors facturation, normalise en minuscules (RG-1.21).',
'position' => 'Ordre d affichage de l adresse dans la liste du client (croissant).',
+ 'latitude' => 'Latitude WGS84 de l adresse (geocodage BAN ou pin manuel). NULL = non geolocalisee, exclue du calcul de tournee (RG-6.05).',
+ 'longitude' => 'Longitude WGS84 de l adresse (geocodage BAN ou pin manuel). NULL = non geolocalisee.',
+ 'geo_manual' => 'Pin positionne/corrige a la main : si vrai, le geocodage auto ne reecrit plus les coordonnees (RG-6.08). Faux par defaut.',
+ 'geocoded_at' => 'Date du dernier geocodage automatique reussi (NULL si jamais geocode ou pin 100% manuel).',
] + self::timestampableBlamableComments(),
'client_address_site' => [
@@ -332,6 +336,10 @@ final class ColumnCommentsCatalog
'bennes' => 'Nombre de bennes sur le site fournisseur (entier nullable) — specifique fournisseur.',
'triage_provider' => 'Le fournisseur est prestataire de triage sur cette adresse. Faux par defaut.',
'position' => 'Ordre d affichage de l adresse dans la liste du fournisseur (croissant).',
+ 'latitude' => 'Latitude WGS84 de l adresse (geocodage BAN ou pin manuel). NULL = non geolocalisee, exclue du calcul de tournee (RG-6.05).',
+ 'longitude' => 'Longitude WGS84 de l adresse (geocodage BAN ou pin manuel). NULL = non geolocalisee.',
+ 'geo_manual' => 'Pin positionne/corrige a la main : si vrai, le geocodage auto ne reecrit plus les coordonnees (RG-6.08). Faux par defaut.',
+ 'geocoded_at' => 'Date du dernier geocodage automatique reussi (NULL si jamais geocode ou pin 100% manuel).',
] + self::timestampableBlamableComments(),
'supplier_address_site' => [
diff --git a/src/Shared/Infrastructure/Geocoding/BanGeocoder.php b/src/Shared/Infrastructure/Geocoding/BanGeocoder.php
new file mode 100644
index 0000000..73472a3
--- /dev/null
+++ b/src/Shared/Infrastructure/Geocoding/BanGeocoder.php
@@ -0,0 +1,83 @@
+httpClient->request('GET', self::SEARCH_URL, [
+ 'query' => ['q' => $query, 'limit' => 1],
+ 'timeout' => 5,
+ ]);
+
+ /** @var array{features?: list} $payload */
+ $payload = $response->toArray();
+ } catch (Throwable $e) {
+ $this->logger->warning('Geocodage BAN indisponible : {message}', [
+ 'message' => $e->getMessage(),
+ 'address' => $query,
+ ]);
+
+ return null;
+ }
+
+ $feature = $payload['features'][0] ?? null;
+ if (null === $feature) {
+ return null;
+ }
+
+ $score = (float) ($feature['properties']['score'] ?? 0.0);
+ if ($score < self::MIN_SCORE) {
+ return null;
+ }
+
+ // GeoJSON : coordinates = [longitude, latitude].
+ $coordinates = $feature['geometry']['coordinates'] ?? null;
+ if (!is_array($coordinates) || !isset($coordinates[0], $coordinates[1])
+ || !is_numeric($coordinates[0]) || !is_numeric($coordinates[1])) {
+ return null;
+ }
+
+ return Coordinates::fromFloats((float) $coordinates[1], (float) $coordinates[0]);
+ }
+}
diff --git a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php
index fb5d338..9e3d2eb 100644
--- a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php
+++ b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php
@@ -271,6 +271,24 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
return $props;
}
+ // Range : min ET max poses -> notInRangeMessage (le message utilise par
+ // Symfony dans ce cas) ; borne unique -> message dedie a la borne.
+ if ($constraint instanceof Assert\Range) {
+ if (null !== $constraint->min && null !== $constraint->max) {
+ return ['notInRangeMessage'];
+ }
+
+ $props = [];
+ if (null !== $constraint->min) {
+ $props[] = 'minMessage';
+ }
+ if (null !== $constraint->max) {
+ $props[] = 'maxMessage';
+ }
+
+ return $props;
+ }
+
if (in_array($constraint::class, self::SIMPLE_MESSAGE_CONSTRAINTS, true)) {
return ['message'];
}
@@ -288,6 +306,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
Assert\Length::class => new Assert\Length(max: 1),
Assert\Count::class => new Assert\Count(min: 1),
Assert\Regex::class => new Assert\Regex(pattern: '/^x$/'),
+ Assert\Range::class => new Assert\Range(min: 0, max: 1),
default => new $class(),
};
diff --git a/tests/Fixtures/Geocoding/InMemoryGeocoder.php b/tests/Fixtures/Geocoding/InMemoryGeocoder.php
new file mode 100644
index 0000000..fd0c517
--- /dev/null
+++ b/tests/Fixtures/Geocoding/InMemoryGeocoder.php
@@ -0,0 +1,31 @@
+ le geocodage auto reprend ;
+ * - bornes WGS84 (Assert\Range) -> 422 avec message FR par champ.
+ *
+ * @internal
+ */
+final class AddressGeolocationTest extends AbstractSupplierApiTestCase
+{
+ public function testClientAddressIsGeocodedOnCreate(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+ $client = $this->createAdminClient();
+ $seed = $this->seedClient('Geo Create');
+
+ $data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => $this->clientAddressPayload(),
+ ])->toArray();
+
+ self::assertResponseStatusCodeSame(201);
+ self::assertSame(InMemoryGeocoder::LATITUDE, $data['latitude']);
+ self::assertSame(InMemoryGeocoder::LONGITUDE, $data['longitude']);
+ self::assertFalse($data['geoManual']);
+ self::assertNotNull($data['geocodedAt']);
+ }
+
+ public function testSupplierAddressIsGeocodedOnCreate(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+ $client = $this->createAdminClient();
+ $seed = $this->seedSupplier('Geo Supplier Create');
+ $category = $this->supplierCategory('NEGOCIANT');
+
+ $data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => [
+ 'addressType' => 'DEPART',
+ 'postalCode' => '86100',
+ 'city' => 'Châtellerault',
+ 'street' => '1 rue du Test',
+ 'sites' => [$this->firstSiteIri()],
+ 'categories' => ['/api/categories/'.$category->getId()],
+ ],
+ ])->toArray();
+
+ self::assertResponseStatusCodeSame(201);
+ self::assertSame(InMemoryGeocoder::LATITUDE, $data['latitude']);
+ self::assertSame(InMemoryGeocoder::LONGITUDE, $data['longitude']);
+ self::assertFalse($data['geoManual']);
+ self::assertNotNull($data['geocodedAt']);
+ }
+
+ public function testManualPinIsPersistedViaPatch(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+ $client = $this->createAdminClient();
+ $addressId = $this->createClientAddress($client, 'Geo Pin');
+
+ // Pin deplace a la main cote front : PATCH coordonnees + geoManual.
+ $data = $client->request('PATCH', '/api/client_addresses/'.$addressId, [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => [
+ 'latitude' => '48.1234567',
+ 'longitude' => '-1.6543217',
+ 'geoManual' => true,
+ ],
+ ])->toArray();
+
+ self::assertResponseStatusCodeSame(200);
+ self::assertSame('48.1234567', $data['latitude']);
+ self::assertSame('-1.6543217', $data['longitude']);
+ self::assertTrue($data['geoManual']);
+ }
+
+ public function testManualPinIsNotOverwrittenByAutoGeocoding(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+ $client = $this->createAdminClient();
+ $addressId = $this->createClientAddress($client, 'Geo RG-608');
+
+ $client->request('PATCH', '/api/client_addresses/'.$addressId, [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['latitude' => '48.1234567', 'longitude' => '-1.6543217', 'geoManual' => true],
+ ]);
+ self::assertResponseStatusCodeSame(200);
+
+ // RG-6.08 : une modification metier ulterieure (rue changee) ne doit PAS
+ // relancer le geocodage — l'InMemoryGeocoder renverrait Poitiers, ce qui
+ // trahirait une reecriture indue du pin manuel.
+ $data = $client->request('PATCH', '/api/client_addresses/'.$addressId, [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['street' => '2 rue du Pin Manuel'],
+ ])->toArray();
+
+ self::assertResponseStatusCodeSame(200);
+ self::assertSame('48.1234567', $data['latitude']);
+ self::assertSame('-1.6543217', $data['longitude']);
+ self::assertTrue($data['geoManual']);
+ }
+
+ public function testClearingManualFlagTriggersRegeocoding(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+ $client = $this->createAdminClient();
+ $addressId = $this->createClientAddress($client, 'Geo Regeocode');
+
+ $client->request('PATCH', '/api/client_addresses/'.$addressId, [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['latitude' => '48.1234567', 'longitude' => '-1.6543217', 'geoManual' => true],
+ ]);
+ self::assertResponseStatusCodeSame(200);
+
+ // Bouton « Re-geocoder depuis l'adresse » : geoManual repasse a false ->
+ // le processor regeocode depuis l'adresse postale.
+ $data = $client->request('PATCH', '/api/client_addresses/'.$addressId, [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['geoManual' => false],
+ ])->toArray();
+
+ self::assertResponseStatusCodeSame(200);
+ self::assertSame(InMemoryGeocoder::LATITUDE, $data['latitude']);
+ self::assertSame(InMemoryGeocoder::LONGITUDE, $data['longitude']);
+ self::assertFalse($data['geoManual']);
+ self::assertNotNull($data['geocodedAt']);
+ }
+
+ public function testOutOfRangeCoordinatesAreRejected(): void
+ {
+ $this->skipIfSitesModuleDisabled();
+ $client = $this->createAdminClient();
+ $addressId = $this->createClientAddress($client, 'Geo Bounds');
+
+ $body = $client->request('PATCH', '/api/client_addresses/'.$addressId, [
+ 'headers' => ['Content-Type' => self::MERGE],
+ 'json' => ['latitude' => '95', 'longitude' => '200', 'geoManual' => true],
+ ])->toArray(false);
+
+ self::assertResponseStatusCodeSame(422);
+ $byPath = $this->violationsByPath($body);
+ self::assertSame('La latitude doit être comprise entre -90 et 90.', $byPath['latitude'] ?? null);
+ self::assertSame('La longitude doit être comprise entre -180 et 180.', $byPath['longitude'] ?? null);
+ }
+
+ /** Cree un client + une adresse valide, et retourne l'id de l'adresse. */
+ private function createClientAddress(\ApiPlatform\Symfony\Bundle\Test\Client $client, string $companyName): int
+ {
+ $seed = $this->seedClient($companyName);
+
+ $data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
+ 'headers' => ['Content-Type' => self::LD],
+ 'json' => $this->clientAddressPayload(),
+ ])->toArray();
+
+ self::assertResponseStatusCodeSame(201);
+
+ return (int) $data['id'];
+ }
+
+ /** Payload minimal valide d'une adresse client (livraison, 1 site, 1 categorie). */
+ private function clientAddressPayload(): array
+ {
+ $category = $this->createCategory('SECTEUR');
+
+ return [
+ 'isDelivery' => true,
+ 'postalCode' => '86100',
+ 'city' => 'Châtellerault',
+ 'street' => '1 rue du Test',
+ 'sites' => [$this->firstSiteIri()],
+ 'categories' => ['/api/categories/'.$category->getId()],
+ ];
+ }
+
+ /** Retourne l'IRI du premier site seede (fixtures Sites). */
+ private function firstSiteIri(): string
+ {
+ $site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
+ self::assertNotNull($site, 'Aucun site seede : impossible de tester les adresses.');
+
+ return '/api/sites/'.$site->getId();
+ }
+}
diff --git a/tests/Shared/Geocoding/BanGeocoderTest.php b/tests/Shared/Geocoding/BanGeocoderTest.php
new file mode 100644
index 0000000..e8d76c5
--- /dev/null
+++ b/tests/Shared/Geocoding/BanGeocoderTest.php
@@ -0,0 +1,107 @@
+ null, jamais d'exception).
+ *
+ * @internal
+ */
+final class BanGeocoderTest extends TestCase
+{
+ public function testGeocodeMapsBanFeatureToCoordinates(): void
+ {
+ $geocoder = $this->geocoderReturning([
+ 'features' => [[
+ 'geometry' => ['coordinates' => [0.3404333, 46.5802596]],
+ 'properties' => ['score' => 0.97],
+ ]],
+ ]);
+
+ $coordinates = $geocoder->geocode('1 rue du Test, 86100 Châtellerault');
+
+ self::assertNotNull($coordinates);
+ // GeoJSON = [longitude, latitude] : l'ordre doit etre inverse.
+ self::assertSame('46.5802596', $coordinates->latitude);
+ self::assertSame('0.3404333', $coordinates->longitude);
+ }
+
+ public function testGeocodeRoundsToSevenDecimals(): void
+ {
+ $geocoder = $this->geocoderReturning([
+ 'features' => [[
+ 'geometry' => ['coordinates' => [0.34043337777, 46.58025961111]],
+ 'properties' => ['score' => 0.9],
+ ]],
+ ]);
+
+ $coordinates = $geocoder->geocode('1 rue du Test');
+
+ self::assertNotNull($coordinates);
+ self::assertSame('46.5802596', $coordinates->latitude);
+ self::assertSame('0.3404334', $coordinates->longitude);
+ }
+
+ public function testGeocodeReturnsNullWhenScoreTooLow(): void
+ {
+ // Score < 0.4 : resultat juge non fiable (adresse etrangere, lieu-dit
+ // inconnu) -> pas de coordonnees plutot qu'une position fantaisiste.
+ $geocoder = $this->geocoderReturning([
+ 'features' => [[
+ 'geometry' => ['coordinates' => [0.34, 46.58]],
+ 'properties' => ['score' => 0.12],
+ ]],
+ ]);
+
+ self::assertNull($geocoder->geocode('Bahnhofstrasse 1, Zürich'));
+ }
+
+ public function testGeocodeReturnsNullWhenNoFeature(): void
+ {
+ $geocoder = $this->geocoderReturning(['features' => []]);
+
+ self::assertNull($geocoder->geocode('zzz introuvable'));
+ }
+
+ public function testGeocodeReturnsNullOnServerError(): void
+ {
+ $client = new MockHttpClient(new MockResponse('oops', ['http_code' => 500]));
+ $geocoder = new BanGeocoder($client, new NullLogger());
+
+ self::assertNull($geocoder->geocode('1 rue du Test'));
+ }
+
+ public function testGeocodeReturnsNullOnBlankAddressWithoutHttpCall(): void
+ {
+ $client = new MockHttpClient(static function (): never {
+ self::fail('Aucun appel HTTP attendu pour une adresse vide.');
+ });
+ $geocoder = new BanGeocoder($client, new NullLogger());
+
+ self::assertNull($geocoder->geocode(' '));
+ }
+
+ /** Fabrique un BanGeocoder repondant le payload JSON donne (HTTP 200). */
+ private function geocoderReturning(array $payload): BanGeocoder
+ {
+ $client = new MockHttpClient(new MockResponse(
+ json_encode($payload, JSON_THROW_ON_ERROR),
+ ['response_headers' => ['content-type' => 'application/json']],
+ ));
+
+ return new BanGeocoder($client, new NullLogger());
+ }
+}