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
@@ -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
{