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