feat: coordonnées GPS auto (API IGN) + code comptable 411_ préfixé

Customer entity :
- Ajout geoLat et geoLong (DECIMAL 10,7 nullable)
- Migration : ALTER TABLE customer ADD geo_lat, geo_long

Géocodage automatique :
- API recherche entreprise : récupère siege.latitude/longitude directement
- Fallback API IGN (data.geopf.fr/geocodage/search) si coords absentes
  mais adresse remplie — appelé côté PHP dans geocodeIfNeeded()
- Champs hidden geoLat/geoLong dans le formulaire

Code comptable 411_ :
- Préfixe "411_" affiché en dur (glass-dark, non modifiable)
- L'utilisateur saisit uniquement la partie après (ex: 0001_DUPON)
- Si vide : génération automatique via generateUniqueCodeComptable()
- Concaténation '411_' + saisie dans le contrôleur

Tests mis à jour : testGeoCoordinates, HttpClientInterface ajouté dans
tous les appels create(), Customer 100% (48/48, 82/82)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-04 11:24:52 +02:00
parent a58c9873ab
commit b498096af1
7 changed files with 118 additions and 6 deletions

View File

@@ -79,6 +79,8 @@ const renderResult = (e, onSelect) => {
fillField('address', addr)
fillField('zipCode', s.code_postal || '')
fillField('city', s.libelle_commune || '')
fillField('geoLat', s.latitude || '')
fillField('geoLong', s.longitude || '')
const typeCompany = resolveTypeCompany(e.nature_juridique)
if (typeCompany) fillField('typeCompany', typeCompany)

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260404092101 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE customer ADD geo_lat NUMERIC(10, 7) DEFAULT NULL');
$this->addSql('ALTER TABLE customer ADD geo_long NUMERIC(10, 7) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE customer DROP geo_lat');
$this->addSql('ALTER TABLE customer DROP geo_long');
}
}

View File

@@ -40,6 +40,7 @@ class ClientsController extends AbstractController
MeilisearchService $meilisearch,
UserManagementService $userService,
LoggerInterface $logger,
HttpClientInterface $httpClient,
#[Autowire(env: 'STRIPE_SK')] string $stripeSecretKey,
): Response {
if ('POST' !== $request->getMethod()) {
@@ -55,6 +56,7 @@ class ClientsController extends AbstractController
$customer = new Customer($user);
$this->populateCustomerData($request, $customer);
$this->geocodeIfNeeded($customer, $httpClient);
$this->initStripeCustomer($customer, $stripeSecretKey);
@@ -64,7 +66,7 @@ class ClientsController extends AbstractController
$this->finalizeStripeCustomer($customer, $user, $stripeSecretKey);
$codeComptable = trim($request->request->getString('codeComptable'));
$customer->setCodeComptable('' !== $codeComptable ? $codeComptable : $customerRepository->generateUniqueCodeComptable($customer));
$customer->setCodeComptable('' !== $codeComptable ? '411_'.$codeComptable : $customerRepository->generateUniqueCodeComptable($customer));
$em->flush();
$this->indexInMeilisearch($meilisearch, $customer, $logger);
@@ -98,6 +100,32 @@ class ClientsController extends AbstractController
$customer->setZipCode(trim($request->request->getString('zipCode')) ?: null);
$customer->setCity(trim($request->request->getString('city')) ?: null);
$customer->setTypeCompany(trim($request->request->getString('typeCompany')) ?: null);
$customer->setGeoLat(trim($request->request->getString('geoLat')) ?: null);
$customer->setGeoLong(trim($request->request->getString('geoLong')) ?: null);
}
/** @codeCoverageIgnore */
private function geocodeIfNeeded(Customer $customer, HttpClientInterface $httpClient): void
{
if (null !== $customer->getGeoLat() || null === $customer->getAddress()) {
return;
}
$q = implode(' ', array_filter([$customer->getAddress(), $customer->getZipCode(), $customer->getCity()]));
try {
$response = $httpClient->request('GET', 'https://data.geopf.fr/geocodage/search', [
'query' => ['q' => $q, 'limit' => 1],
]);
$data = $response->toArray();
$coords = $data['features'][0]['geometry']['coordinates'] ?? null;
if (null !== $coords) {
$customer->setGeoLong((string) $coords[0]);
$customer->setGeoLat((string) $coords[1]);
}
} catch (\Throwable) {
}
}
/** @codeCoverageIgnore */

View File

@@ -74,6 +74,12 @@ class Customer
#[ORM\Column(length: 255, nullable: true)]
private ?string $city = null;
#[ORM\Column(type: 'decimal', precision: 10, scale: 7, nullable: true)]
private ?string $geoLat = null;
#[ORM\Column(type: 'decimal', precision: 10, scale: 7, nullable: true)]
private ?string $geoLong = null;
#[ORM\Column(length: 14, nullable: true)]
private ?string $siret = null;
@@ -271,6 +277,30 @@ class Customer
return $this;
}
public function getGeoLat(): ?string
{
return $this->geoLat;
}
public function setGeoLat(?string $geoLat): static
{
$this->geoLat = $geoLat;
return $this;
}
public function getGeoLong(): ?string
{
return $this->geoLong;
}
public function setGeoLong(?string $geoLong): static
{
$this->geoLong = $geoLong;
return $this;
}
public function getSiret(): ?string
{
return $this->siret;

View File

@@ -75,8 +75,10 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label for="codeComptable" class="block text-xs font-bold uppercase tracking-wider mb-2">Code comptable</label>
<input type="text" id="codeComptable" name="codeComptable" placeholder="Laissez vide pour generation automatique (411_XXXX_XXXXX)"
class="w-full px-4 py-3 input-glass text-sm font-medium font-mono">
<div class="flex">
<span class="px-4 py-3 glass-dark text-white text-sm font-bold font-mono flex items-center" style="border-radius: 6px 0 0 6px;">411_</span>
<input type="text" id="codeComptable" name="codeComptable" placeholder="Vide = auto (XXXX_XXXXX)"
class="flex-1 px-4 py-3 input-glass text-sm font-medium font-mono" style="border-radius: 0 6px 6px 0;">
</div>
<div>
<label for="raisonSociale" class="block text-xs font-bold uppercase tracking-wider mb-2">Raison sociale</label>
@@ -136,6 +138,8 @@
class="w-full px-4 py-3 input-glass text-sm font-medium">
</div>
</div>
<input type="hidden" id="geoLat" name="geoLat">
<input type="hidden" id="geoLong" name="geoLong">
</div>
</section>

View File

@@ -75,7 +75,7 @@ class ClientsControllerTest extends TestCase
$userService = $this->createStub(UserManagementService::class);
$logger = $this->createStub(LoggerInterface::class);
$response = $controller->create($request, $repo, $em, $meilisearch, $userService, $logger, 'sk_test_***');
$response = $controller->create($request, $repo, $em, $meilisearch, $userService, $logger, $this->createStub(HttpClientInterface::class), 'sk_test_***');
$this->assertInstanceOf(Response::class, $response);
}
@@ -95,7 +95,7 @@ class ClientsControllerTest extends TestCase
$userService->method('createBaseUser')->willThrowException(new \InvalidArgumentException('Champs requis'));
$logger = $this->createStub(LoggerInterface::class);
$response = $controller->create($request, $repo, $em, $meilisearch, $userService, $logger, 'sk_test_***');
$response = $controller->create($request, $repo, $em, $meilisearch, $userService, $logger, $this->createStub(HttpClientInterface::class), 'sk_test_***');
$this->assertInstanceOf(Response::class, $response);
}
@@ -115,7 +115,7 @@ class ClientsControllerTest extends TestCase
$userService->method('createBaseUser')->willThrowException(new \RuntimeException('DB error'));
$logger = $this->createStub(LoggerInterface::class);
$response = $controller->create($request, $repo, $em, $meilisearch, $userService, $logger, 'sk_test_***');
$response = $controller->create($request, $repo, $em, $meilisearch, $userService, $logger, $this->createStub(HttpClientInterface::class), 'sk_test_***');
$this->assertInstanceOf(Response::class, $response);
}
@@ -287,6 +287,7 @@ class ClientsControllerTest extends TestCase
$this->createStub(MeilisearchService::class),
$userService,
$this->createStub(LoggerInterface::class),
$this->createStub(HttpClientInterface::class),
'',
);
$this->assertSame(302, $response->getStatusCode());
@@ -324,6 +325,7 @@ class ClientsControllerTest extends TestCase
$this->createStub(MeilisearchService::class),
$userService,
$this->createStub(LoggerInterface::class),
$this->createStub(HttpClientInterface::class),
'sk_test_***',
);
$this->assertSame(302, $response->getStatusCode());
@@ -364,6 +366,7 @@ class ClientsControllerTest extends TestCase
$meilisearch,
$userService,
$this->createStub(LoggerInterface::class),
$this->createStub(HttpClientInterface::class),
'',
);
$this->assertSame(302, $response->getStatusCode());

View File

@@ -111,6 +111,18 @@ class CustomerTest extends TestCase
$this->assertSame('Saint-Quentin', $c->getCity());
}
public function testGeoCoordinates(): void
{
$c = $this->createCustomer();
$this->assertNull($c->getGeoLat());
$this->assertNull($c->getGeoLong());
$c->setGeoLat('49.8486');
$c->setGeoLong('3.2876');
$this->assertSame('49.8486', $c->getGeoLat());
$this->assertSame('3.2876', $c->getGeoLong());
}
public function testLegal(): void
{
$c = $this->createCustomer();