feat(commercial) : géolocalisation des adresses Tiers (lat/lng + géocodage BAN + pin ajustable) (ERP-122)
Ajoute la géolocalisation aux adresses Client et Fournisseur, socle de la tournée commerciale (M6 field-sales). Back : - migration : latitude/longitude NUMERIC(10,7), geo_manual BOOLEAN, geocoded_at TIMESTAMPTZ sur client_address et supplier_address (+ COMMENT ON COLUMN FR) - GeolocatableAddressInterface (Shared/Domain/Contract) implémenté par les deux entités ; bornes WGS84 validées (Range -90/90, -180/180, messages FR) - GeocoderInterface + BanGeocoder (api-adresse.data.gouv.fr), branché via AddressGeocoder dans les processors ; géocodage auto au create/update - RG-6.08 : geo_manual=true fige les coordonnées (pas de réécriture auto) - symfony/http-client passe en dépendance de production Front : - AddressGeoPin (Leaflet + OSM) : marqueur déplaçable -> PATCH lat/lng + geoManual=true, bouton Re-géocoder, badges « à géolocaliser » / « pin manuel » - intégration dans les blocs adresse Client et Fournisseur Tests : PHPUnit (géocodage create, non-réécriture RG-6.08, mapping BAN, bornes) + Vitest (drag du pin, badges, re-géocodage).
This commit is contained in:
+2
-2
@@ -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.*"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+175
-175
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<div data-testid="geo-pin">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-700">{{ t('commercial.geo.title') }}</span>
|
||||
<!-- Badge « a geolocaliser » : adresse valide mais sans coordonnees
|
||||
(spec M6 § 3.2 — exclue du calcul de tournee, RG-6.05). -->
|
||||
<span
|
||||
v-if="!hasCoords"
|
||||
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800"
|
||||
data-testid="geo-badge-missing"
|
||||
>
|
||||
{{ t('commercial.geo.toGeolocate') }}
|
||||
</span>
|
||||
<!-- Pin fige a la main (RG-6.08) : informatif. -->
|
||||
<span
|
||||
v-else-if="geoManual"
|
||||
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
|
||||
data-testid="geo-badge-manual"
|
||||
>
|
||||
{{ t('commercial.geo.manualPin') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Mini-carte Leaflet (exception documentee a @malio/layer-ui : carte
|
||||
interactive, type non couvert par la lib — cf. frontend.md
|
||||
§ Composants formulaires). TODO : migrer si la lib couvre un jour
|
||||
les cartes. -->
|
||||
<div
|
||||
v-if="hasCoords"
|
||||
ref="mapEl"
|
||||
class="h-48 w-full rounded border border-gray-200"
|
||||
data-testid="geo-map"
|
||||
/>
|
||||
<p v-if="hasCoords && !readonly" class="mt-1 text-xs text-gray-500">
|
||||
{{ t('commercial.geo.dragHint') }}
|
||||
</p>
|
||||
|
||||
<div v-if="!readonly" class="mt-2 flex items-center gap-4">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
:label="t('commercial.geo.regeocode')"
|
||||
:disabled="regeocoding || !canRegeocode"
|
||||
data-testid="geo-regeocode"
|
||||
@click="regeocode"
|
||||
/>
|
||||
<span v-if="regeocodeFailed" class="text-xs text-red-600" data-testid="geo-regeocode-failed">
|
||||
{{ t('commercial.geo.regeocodeFailed') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Map as LeafletMap, Marker } from 'leaflet'
|
||||
import { useAddressAutocomplete } from '~/shared/composables/useAddressAutocomplete'
|
||||
|
||||
/**
|
||||
* Mini-carte d'ajustement du pin d'une adresse Tiers (M6.1, spec § 8.3).
|
||||
*
|
||||
* - Marqueur deplacable : au drag, emet les coordonnees corrigees avec
|
||||
* geoManual = true (RG-6.08 : le geocodage auto ne reecrira plus). Le parent
|
||||
* met a jour le brouillon ; la persistance suit le submit du formulaire
|
||||
* (POST/PATCH de l'adresse), comme tous les champs du bloc.
|
||||
* - « Re-geocoder depuis l'adresse » : previsualise la position BAN cote front
|
||||
* et emet geoManual = false — au save, le back (BanGeocoder) refait autorite
|
||||
* et pose geocodedAt.
|
||||
* - Sans coordonnees : pas de carte, badge « a geolocaliser ».
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** Latitude WGS84 (chaine decimale) ou null si non geolocalisee. */
|
||||
latitude: string | null
|
||||
/** Longitude WGS84 (chaine decimale) ou null si non geolocalisee. */
|
||||
longitude: string | null
|
||||
/** RG-6.08 : pin deja corrige a la main. */
|
||||
geoManual: boolean
|
||||
/** Adresse postale a re-geocoder (« rue, code postal ville »). */
|
||||
geocodeQuery: string | null
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Nouveau positionnement du pin (drag manuel ou re-geocodage previsualise). */
|
||||
'update:coords': [value: { latitude: string, longitude: string, geoManual: boolean }]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const autocomplete = useAddressAutocomplete()
|
||||
|
||||
const mapEl = ref<HTMLElement | null>(null)
|
||||
const regeocoding = ref(false)
|
||||
const regeocodeFailed = ref(false)
|
||||
|
||||
const hasCoords = computed(() =>
|
||||
props.latitude !== null && props.latitude !== ''
|
||||
&& props.longitude !== null && props.longitude !== '',
|
||||
)
|
||||
|
||||
const canRegeocode = computed(() => (props.geocodeQuery ?? '').trim().length >= 3)
|
||||
|
||||
// Instances Leaflet (hors reactivite Vue : un proxy sur la Map casse Leaflet).
|
||||
let map: LeafletMap | null = null
|
||||
let marker: Marker | null = null
|
||||
|
||||
/** Zoom d'affichage du pin (niveau rue). */
|
||||
const PIN_ZOOM = 16
|
||||
|
||||
/**
|
||||
* Monte la carte Leaflet dans le conteneur (import dynamique : la lib n'est
|
||||
* chargee que si l'adresse a des coordonnees).
|
||||
*/
|
||||
async function ensureMap(): Promise<void> {
|
||||
if (map !== null || mapEl.value === null || !hasCoords.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const mod = await import('leaflet')
|
||||
const L = mod.default ?? mod
|
||||
await import('leaflet/dist/leaflet.css')
|
||||
|
||||
// Le conteneur peut avoir disparu pendant le chargement async (v-if).
|
||||
if (mapEl.value === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const position: [number, number] = [Number(props.latitude), Number(props.longitude)]
|
||||
|
||||
map = L.map(mapEl.value, { scrollWheelZoom: false }).setView(position, PIN_ZOOM)
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19,
|
||||
}).addTo(map)
|
||||
|
||||
// divIcon SVG inline : evite les assets PNG de Leaflet (chemins casses par
|
||||
// le bundler Vite sans configuration dediee).
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="28" height="40" fill="#2563eb" stroke="#1e40af" stroke-width="0.5"><path d="M12 0C7 0 3 4 3 9c0 6.6 9 15 9 15s9-8.4 9-15c0-5-4-9-9-9zm0 12.5A3.5 3.5 0 1 1 12 5.5a3.5 3.5 0 0 1 0 7z"/></svg>',
|
||||
iconSize: [28, 40],
|
||||
iconAnchor: [14, 40],
|
||||
})
|
||||
|
||||
marker = L.marker(position, { icon, draggable: !props.readonly }).addTo(map)
|
||||
marker.on('dragend', onMarkerDragEnd)
|
||||
}
|
||||
|
||||
/** Drag du pin -> coordonnees corrigees + geoManual (RG-6.08). */
|
||||
function onMarkerDragEnd(): void {
|
||||
if (marker === null) {
|
||||
return
|
||||
}
|
||||
const position = marker.getLatLng()
|
||||
emit('update:coords', {
|
||||
latitude: position.lat.toFixed(7),
|
||||
longitude: position.lng.toFixed(7),
|
||||
geoManual: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* « Re-geocoder depuis l'adresse » : previsualisation BAN cote front. Emet
|
||||
* geoManual = false — le geocodage serveur refait autorite au save.
|
||||
*/
|
||||
async function regeocode(): Promise<void> {
|
||||
regeocodeFailed.value = false
|
||||
const query = (props.geocodeQuery ?? '').trim()
|
||||
if (query.length < 3) {
|
||||
regeocodeFailed.value = true
|
||||
return
|
||||
}
|
||||
|
||||
regeocoding.value = true
|
||||
try {
|
||||
const coords = await autocomplete.geocode(query)
|
||||
if (coords === null) {
|
||||
regeocodeFailed.value = true
|
||||
return
|
||||
}
|
||||
emit('update:coords', { ...coords, geoManual: false })
|
||||
}
|
||||
catch {
|
||||
// BAN indisponible : position inchangee, message inline.
|
||||
regeocodeFailed.value = true
|
||||
}
|
||||
finally {
|
||||
regeocoding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Coordonnees modifiees par le parent (drag deja applique, re-geocodage,
|
||||
// rechargement) : recale le marqueur, ou monte la carte si elle n'existe pas
|
||||
// encore (premieres coordonnees d'une adresse « a geolocaliser »).
|
||||
watch(
|
||||
() => [props.latitude, props.longitude] as const,
|
||||
async () => {
|
||||
if (!hasCoords.value) {
|
||||
return
|
||||
}
|
||||
if (map === null) {
|
||||
await nextTick()
|
||||
await ensureMap()
|
||||
return
|
||||
}
|
||||
const position: [number, number] = [Number(props.latitude), Number(props.longitude)]
|
||||
marker?.setLatLng(position)
|
||||
map.panTo(position)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(ensureMap)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
map?.remove()
|
||||
map = null
|
||||
marker = null
|
||||
})
|
||||
</script>
|
||||
@@ -178,6 +178,19 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Pin geographique de l'adresse (M6.1, spec § 8.3) : mini-carte avec
|
||||
marqueur ajustable, persiste au submit comme le reste du bloc. -->
|
||||
<div class="col-span-4">
|
||||
<AddressGeoPin
|
||||
:latitude="model.latitude"
|
||||
:longitude="model.longitude"
|
||||
:geo-manual="model.geoManual"
|
||||
:geocode-query="geocodeQuery"
|
||||
:readonly="readonly"
|
||||
@update:coords="onCoordsUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -289,6 +302,24 @@ function update<K extends keyof AddressFormDraft>(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<string | null>(() => {
|
||||
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 })
|
||||
|
||||
@@ -162,6 +162,19 @@
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: boolean) => update('triageProvider', v)"
|
||||
/>
|
||||
|
||||
<!-- Pin geographique de l'adresse (M6.1, spec § 8.3) : mini-carte avec
|
||||
marqueur ajustable, persiste au submit comme le reste du bloc. -->
|
||||
<div class="col-span-4">
|
||||
<AddressGeoPin
|
||||
:latitude="model.latitude"
|
||||
:longitude="model.longitude"
|
||||
:geo-manual="model.geoManual"
|
||||
:geocode-query="geocodeQuery"
|
||||
:readonly="readonly"
|
||||
@update:coords="onCoordsUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -243,6 +256,24 @@ function update<K extends keyof SupplierAddressFormDraft>(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<string | null>(() => {
|
||||
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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Generated
+56
-22
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<CitySuggestion[]>
|
||||
searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]>
|
||||
/**
|
||||
* 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<GeocodedCoordinates | null>
|
||||
}
|
||||
|
||||
/** 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<GeocodedCoordinates | null> {
|
||||
if (query.trim().length < 3) {
|
||||
return null
|
||||
}
|
||||
|
||||
let res: BanResponse
|
||||
try {
|
||||
res = await httpExternal<BanResponse>(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),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Commercial — geolocalisation des adresses Tiers (M6.1 / ERP-122, spec M6
|
||||
* § 3.2 / § 4.1).
|
||||
*
|
||||
* Ajoute sur `client_address` ET `supplier_address` :
|
||||
* - latitude / longitude NUMERIC(10,7) null : coordonnees WGS84, alimentees
|
||||
* par le geocodage BAN automatique ou par le pin manuel ;
|
||||
* - geo_manual BOOLEAN default false : RG-6.08, un pin corrige a la main fige
|
||||
* les coordonnees (le geocodage auto ne reecrit plus) ;
|
||||
* - geocoded_at TIMESTAMPTZ null : date du dernier geocodage auto reussi.
|
||||
*
|
||||
* Migration au namespace racine `DoctrineMigrations` (pratique effective du
|
||||
* projet : le namespace modulaire Commercial n'est pas enregistre dans
|
||||
* doctrine_migrations.yaml et souffrirait du tri FQCN inter-namespaces sur
|
||||
* base vide — cf. regle ABSOLUE n°11 / architecture.md § Migrations).
|
||||
*/
|
||||
final class Version20260611130000 extends AbstractMigration
|
||||
{
|
||||
private const array TABLES = ['client_address', 'supplier_address'];
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Commercial : geolocalisation des adresses Tiers (latitude/longitude/geo_manual/geocoded_at sur client_address et supplier_address).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
foreach (self::TABLES as $table) {
|
||||
$this->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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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<int, SiteInterface> */
|
||||
#[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<int, SiteInterface> */
|
||||
public function getSites(): Collection
|
||||
{
|
||||
|
||||
@@ -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<int, SiteInterface> */
|
||||
#[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<int, SiteInterface> */
|
||||
public function getSites(): Collection
|
||||
{
|
||||
|
||||
+6
-1
@@ -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);
|
||||
}
|
||||
|
||||
+6
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Application\Service;
|
||||
|
||||
use App\Shared\Domain\Contract\GeocoderInterface;
|
||||
use App\Shared\Domain\Contract\GeolocatableAddressInterface;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Geocodage automatique d'une adresse Tiers a la creation / mise a jour
|
||||
* (M6.1, spec § 7). Appele par les processors d'adresse (ClientAddressProcessor
|
||||
* / SupplierAddressProcessor) AVANT le persist.
|
||||
*
|
||||
* RG-6.08 : si le pin a ete corrige a la main (geoManual = true), les
|
||||
* coordonnees sont FIGEES — aucun geocodage, aucune reecriture. Le front
|
||||
* « re-geocode » en repassant geoManual a false (le prochain save regeocode).
|
||||
*
|
||||
* En cas d'echec du geocodage (BAN indisponible, adresse introuvable), les
|
||||
* coordonnees existantes sont CONSERVEES en l'etat : pas de retour en arriere
|
||||
* silencieux, l'adresse reste sauvegardable (badge « a geolocaliser » cote
|
||||
* front si elle n'a aucune coordonnee).
|
||||
*/
|
||||
final class AddressGeocoder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GeocoderInterface $geocoder,
|
||||
) {}
|
||||
|
||||
public function geocode(GeolocatableAddressInterface $address): void
|
||||
{
|
||||
// RG-6.08 : pin manuel -> 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())
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
use App\Shared\Domain\ValueObject\Coordinates;
|
||||
|
||||
/**
|
||||
* Geocodage d'une adresse postale en coordonnees WGS84 (M6.1, spec § 7).
|
||||
*
|
||||
* Encapsule le fournisseur de geocodage (decision Q-M6-3 : BAN
|
||||
* api-adresse.data.gouv.fr) derriere un contrat pour pouvoir en changer sans
|
||||
* toucher aux appelants.
|
||||
*
|
||||
* Contrat d'erreur : `null` si l'adresse n'est pas geocodable (aucun resultat,
|
||||
* score trop faible) OU si le fournisseur est indisponible (reseau, 5xx). Le
|
||||
* geocodage ne doit JAMAIS bloquer la sauvegarde d'une adresse — une adresse
|
||||
* sans coordonnees reste valide (badge « a geolocaliser », spec § 3.2).
|
||||
*/
|
||||
interface GeocoderInterface
|
||||
{
|
||||
public function geocode(string $address): ?Coordinates;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Adresse Tiers geolocalisable (M6.1, spec § 3.2 / § 4.1).
|
||||
*
|
||||
* Implementee par ClientAddress et SupplierAddress (module Commercial) ; le
|
||||
* futur module FieldSales consommera ce contrat pour router les tournees sans
|
||||
* importer le module Commercial (regle ABSOLUE n°1).
|
||||
*
|
||||
* Les setters (latitude / longitude / geocodedAt) sont la surface d'ecriture
|
||||
* du service de geocodage automatique (AddressGeocoder) ; isGeoManual() porte
|
||||
* la RG-6.08 — un pin corrige a la main fige les coordonnees, le geocodage
|
||||
* auto ne les reecrit plus.
|
||||
*/
|
||||
interface GeolocatableAddressInterface
|
||||
{
|
||||
/** Latitude WGS84 en chaine decimale NUMERIC(10,7), null si non geolocalisee. */
|
||||
public function getLatitude(): ?string;
|
||||
|
||||
/** Longitude WGS84 en chaine decimale NUMERIC(10,7), null si non geolocalisee. */
|
||||
public function getLongitude(): ?string;
|
||||
|
||||
/** Adresse postale affichable / geocodable (rue, code postal, ville). */
|
||||
public function getDisplayLabel(): string;
|
||||
|
||||
/** RG-6.08 : pin corrige a la main — le geocodage auto ne reecrit plus. */
|
||||
public function isGeoManual(): bool;
|
||||
|
||||
public function setLatitude(float|string|null $latitude): static;
|
||||
|
||||
public function setLongitude(float|string|null $longitude): static;
|
||||
|
||||
/** Date du dernier geocodage automatique reussi (posee par AddressGeocoder). */
|
||||
public function setGeocodedAt(?DateTimeImmutable $geocodedAt): static;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\ValueObject;
|
||||
|
||||
/**
|
||||
* Paire de coordonnees WGS84 (latitude / longitude) portee en chaines
|
||||
* decimales a 7 decimales — meme format que les colonnes NUMERIC(10,7) des
|
||||
* adresses Tiers (M6.1, spec § 4.1). Immutable.
|
||||
*/
|
||||
final readonly class Coordinates
|
||||
{
|
||||
public function __construct(
|
||||
public string $latitude,
|
||||
public string $longitude,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fabrique depuis des flottants (ex: geometry GeoJSON de la BAN), arrondis
|
||||
* au format NUMERIC(10,7) des colonnes.
|
||||
*/
|
||||
public static function fromFloats(float $latitude, float $longitude): self
|
||||
{
|
||||
return new self(
|
||||
number_format($latitude, 7, '.', ''),
|
||||
number_format($longitude, 7, '.', ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -243,6 +243,10 @@ final class ColumnCommentsCatalog
|
||||
'billing_email' => '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' => [
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Geocoding;
|
||||
|
||||
use App\Shared\Domain\Contract\GeocoderInterface;
|
||||
use App\Shared\Domain\ValueObject\Coordinates;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Geocodeur branche sur la Base Adresse Nationale (BAN) —
|
||||
* `api-adresse.data.gouv.fr` (decision Q-M6-3, spec M6 § 7). Meme endpoint
|
||||
* `/search/` que l'autocompletion d'adresse cote front
|
||||
* (useAddressAutocomplete) : integration unique, service public gratuit.
|
||||
*
|
||||
* Tolerance aux pannes : toute erreur (reseau, 5xx, payload inattendu) est
|
||||
* loggee et convertie en `null` — le geocodage ne bloque jamais la sauvegarde
|
||||
* d'une adresse (contrat GeocoderInterface).
|
||||
*/
|
||||
final class BanGeocoder implements GeocoderInterface
|
||||
{
|
||||
private const string SEARCH_URL = 'https://api-adresse.data.gouv.fr/search/';
|
||||
|
||||
/**
|
||||
* Score BAN minimal (0..1) pour considerer le resultat fiable. En deca
|
||||
* (adresse etrangere, lieu-dit inconnu...), on prefere « pas de
|
||||
* coordonnees » a une position fantaisiste — le pin manuel prend le relais
|
||||
* (spec § 3.2).
|
||||
*/
|
||||
private const float MIN_SCORE = 0.4;
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function geocode(string $address): ?Coordinates
|
||||
{
|
||||
$query = trim($address);
|
||||
if ('' === $query) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', self::SEARCH_URL, [
|
||||
'query' => ['q' => $query, 'limit' => 1],
|
||||
'timeout' => 5,
|
||||
]);
|
||||
|
||||
/** @var array{features?: list<array{geometry?: array{coordinates?: array{0: float|int, 1: float|int}}, properties?: array{score?: float|int}}>} $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]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Fixtures\Geocoding;
|
||||
|
||||
use App\Shared\Domain\Contract\GeocoderInterface;
|
||||
use App\Shared\Domain\ValueObject\Coordinates;
|
||||
|
||||
/**
|
||||
* Geocodeur en memoire branche a la place de BanGeocoder en env test
|
||||
* (alias when@test de config/services.yaml) : deterministe, sans reseau.
|
||||
*
|
||||
* Renvoie toujours les memes coordonnees (Poitiers) — suffisant pour verifier
|
||||
* le geocodage au create/update et la RG-6.08 (les tests posent un pin manuel
|
||||
* a des coordonnees DIFFERENTES pour detecter une reecriture indue).
|
||||
*/
|
||||
final class InMemoryGeocoder implements GeocoderInterface
|
||||
{
|
||||
public const string LATITUDE = '46.5802596';
|
||||
public const string LONGITUDE = '0.3404333';
|
||||
|
||||
public function geocode(string $address): ?Coordinates
|
||||
{
|
||||
if ('' === trim($address)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Coordinates(self::LATITUDE, self::LONGITUDE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Tests\Fixtures\Geocoding\InMemoryGeocoder;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de la geolocalisation des adresses Tiers (M6.1 / ERP-122,
|
||||
* spec M6 § 3.2 / § 4.1 / § 7).
|
||||
*
|
||||
* Le geocodeur reel (BanGeocoder) est remplace en env test par
|
||||
* l'InMemoryGeocoder (alias when@test) : deterministe, sans reseau. Couvre :
|
||||
* - geocodage automatique au POST (client ET fournisseur) ;
|
||||
* - pin manuel : PATCH latitude/longitude + geoManual = true ;
|
||||
* - RG-6.08 : geoManual = true fige les coordonnees (un PATCH ulterieur de
|
||||
* l'adresse ne les reecrit pas) ;
|
||||
* - « re-geocoder » : PATCH geoManual = false -> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Shared\Geocoding;
|
||||
|
||||
use App\Shared\Infrastructure\Geocoding\BanGeocoder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
/**
|
||||
* Mapping de la reponse BAN (api-adresse.data.gouv.fr/search/) vers le value
|
||||
* object Coordinates (M6.1, spec § 7) — via MockHttpClient, sans reseau.
|
||||
*
|
||||
* Contrat teste : GeoJSON [longitude, latitude] inverse en (latitude,
|
||||
* longitude), arrondi NUMERIC(10,7), score minimal, et tolerance aux pannes
|
||||
* (toute erreur -> 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user