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 @@ + + + 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()); + } +}