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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user