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

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

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

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

Tests : PHPUnit (géocodage create, non-réécriture RG-6.08, mapping BAN, bornes) +
Vitest (drag du pin, badges, re-géocodage).
This commit is contained in:
Matthieu
2026-06-11 14:31:35 +02:00
parent 431d831c8b
commit de4aaa1d64
34 changed files with 1966 additions and 204 deletions
@@ -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();
}
}
+107
View File
@@ -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());
}
}