feat(field_sales) : carte interactive Leaflet + écran de planification de tournée (ERP-127)
- API visitable_tiers (provider DBAL bbox/q/type, paginé) pour les pins de la carte
- POST /tours/{id}/reorder (drag & drop) : renumérotation atomique + recompute
- Layer front field-sales : TourMap (pins, popup, polyline, sélection rectangle),
liste d'étapes draggable (vuedraggable), composable de planification + Vitest
- Pages /tours, /tours/new, /tours/[id]/plan (split responsive, point custom géocodé)
- i18n FR, deep links Waze/Google/Apple, état 100% local
This commit is contained in:
@@ -6,6 +6,8 @@ namespace App\Tests\Module\FieldSales\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
use App\Module\Commercial\Domain\Entity\SupplierAddress;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\FieldSales\Domain\Entity\Tour;
|
||||
@@ -29,6 +31,7 @@ use DateTimeImmutable;
|
||||
abstract class AbstractFieldSalesApiTestCase extends AbstractApiTestCase
|
||||
{
|
||||
protected const string TEST_CLIENT_PREFIX = 'TEST_FS_CLIENT_';
|
||||
protected const string TEST_SUPPLIER_PREFIX = 'TEST_FS_SUPPLIER_';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
@@ -73,6 +76,40 @@ abstract class AbstractFieldSalesApiTestCase extends AbstractApiTestCase
|
||||
return $address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un Fournisseur minimal (companyName uniquement).
|
||||
*/
|
||||
protected function seedSupplier(string $companyName): Supplier
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$supplier = new Supplier();
|
||||
$supplier->setCompanyName(self::TEST_SUPPLIER_PREFIX.mb_strtoupper($companyName, 'UTF-8'));
|
||||
$em->persist($supplier);
|
||||
$em->flush();
|
||||
|
||||
return $supplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede une adresse geolocalisee rattachee a $supplier (type PROSPECT).
|
||||
*/
|
||||
protected function seedSupplierAddress(Supplier $supplier, float $lat = 47.218, float $lng = -1.553): SupplierAddress
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$address = new SupplierAddress();
|
||||
$address->setSupplier($supplier);
|
||||
$address->setAddressType('PROSPECT');
|
||||
$address->setPostalCode('44000');
|
||||
$address->setCity('NANTES');
|
||||
$address->setStreet('2 rue de Test');
|
||||
$address->setLatitude($lat);
|
||||
$address->setLongitude($lng);
|
||||
$em->persist($address);
|
||||
$em->flush();
|
||||
|
||||
return $address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede une tournee appartenant a $owner (sans passer par l'API).
|
||||
*/
|
||||
@@ -135,6 +172,16 @@ abstract class AbstractFieldSalesApiTestCase extends AbstractApiTestCase
|
||||
'DELETE FROM '.Client::class.' c WHERE c.companyName LIKE :prefix',
|
||||
)->setParameter('prefix', self::TEST_CLIENT_PREFIX.'%')->execute();
|
||||
|
||||
// Adresses puis fournisseurs de test (FK supplier_address.supplier_id CASCADE).
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.SupplierAddress::class.' a WHERE a.supplier IN ('
|
||||
.'SELECT s.id FROM '.Supplier::class.' s WHERE s.companyName LIKE :prefix)',
|
||||
)->setParameter('prefix', self::TEST_SUPPLIER_PREFIX.'%')->execute();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.Supplier::class.' s WHERE s.companyName LIKE :prefix',
|
||||
)->setParameter('prefix', self::TEST_SUPPLIER_PREFIX.'%')->execute();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix',
|
||||
)->setParameter('prefix', 'testuser_%')->execute();
|
||||
|
||||
@@ -146,6 +146,49 @@ final class TourRouteApiTest extends AbstractFieldSalesApiTestCase
|
||||
self::assertArrayHasKey('tourDate', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
/**
|
||||
* /reorder applique l'ordre fourni (drag & drop) en renumerotant les
|
||||
* positions sans heurter l'unique (tour_id, position), puis recompute.
|
||||
*/
|
||||
public function testReorderAppliesGivenOrderAndRecomputes(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$admin = $this->getUserByUsername('admin');
|
||||
$tour = $this->seedTour($admin);
|
||||
|
||||
$a = $this->seedCustomStop($tour, 0, 47.0, -1.0, 'A');
|
||||
$b = $this->seedCustomStop($tour, 1, 47.1, -1.0, 'B');
|
||||
$c = $this->seedCustomStop($tour, 2, 47.2, -1.0, 'C');
|
||||
|
||||
// Ordre inverse demande : C, B, A.
|
||||
$body = $client->request('POST', '/api/tours/'.$tour->getId().'/reorder', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => ['stopIds' => [$c->getId(), $b->getId(), $a->getId()]],
|
||||
])->toArray();
|
||||
|
||||
$stops = $this->stopsByPosition($body);
|
||||
self::assertSame([0, 1, 2], array_map(static fn (array $s) => $s['position'], $stops));
|
||||
self::assertSame(['C', 'B', 'A'], array_map(static fn (array $s) => $s['customLabel'], $stops));
|
||||
// Recompute applique : la 1re etape est le depart (leg nul).
|
||||
self::assertSame(0, $stops[0]['legDistanceM']);
|
||||
self::assertNotNull($body['totalDistanceM']);
|
||||
}
|
||||
|
||||
public function testReorderRequiresStopIds(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$admin = $this->getUserByUsername('admin');
|
||||
$tour = $this->seedTour($admin);
|
||||
|
||||
$response = $client->request('POST', '/api/tours/'.$tour->getId().'/reorder', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => [],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('stopIds', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
public function testComputeRequiresManagePermission(): void
|
||||
{
|
||||
$creds = $this->createUserWithPermissions(['field_sales.tours.view']);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\FieldSales\Api;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'endpoint GET /api/visitable_tiers (M6.5 — pins de la
|
||||
* carte de planification).
|
||||
*
|
||||
* Couvre : exposition des adresses geolocalisees Client/Fournisseur, exclusion
|
||||
* des adresses sans coordonnees (RG-6.05) et des Tiers archives/supprimes,
|
||||
* filtres bbox / q / type, securite RBAC, echappatoire ?pagination=false.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class VisitableTierApiTest extends AbstractFieldSalesApiTestCase
|
||||
{
|
||||
private const string LD = 'application/ld+json';
|
||||
|
||||
/** Isole les Tiers seedes par ces tests (prefixe commun client + supplier). */
|
||||
private const string ISOLATE_Q = 'TEST_FS_';
|
||||
|
||||
public function testExposesGeolocatedClientAndSupplierPins(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
|
||||
$tierClient = $this->seedClient('Ferme Pin');
|
||||
$this->seedClientAddress($tierClient, 47.218, -1.553);
|
||||
$tierSupplier = $this->seedSupplier('Negoce Pin');
|
||||
$this->seedSupplierAddress($tierSupplier, 47.220, -1.560);
|
||||
|
||||
$data = $client->request('GET', '/api/visitable_tiers', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
'query' => ['q' => self::ISOLATE_Q],
|
||||
])->toArray();
|
||||
|
||||
self::assertSame(2, $data['totalItems'], 'Une adresse geolocalisee Client + une Fournisseur = 2 pins.');
|
||||
|
||||
$byType = [];
|
||||
foreach ($data['member'] as $pin) {
|
||||
$byType[$pin['tierType']] = $pin;
|
||||
}
|
||||
self::assertArrayHasKey('client', $byType);
|
||||
self::assertArrayHasKey('supplier', $byType);
|
||||
self::assertSame((float) 47.218, $byType['client']['latitude']);
|
||||
self::assertSame($tierClient->getId(), $byType['client']['tierId']);
|
||||
self::assertStringContainsString('NANTES', $byType['client']['address']);
|
||||
self::assertSame('client-'.$byType['client']['addressId'], $byType['client']['id']);
|
||||
}
|
||||
|
||||
public function testExcludesAddressesWithoutCoordinates(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
|
||||
$tier = $this->seedClient('Sans Geo');
|
||||
// Adresse sans lat/lng (RG-6.05 : exclue du calcul/carte).
|
||||
$this->seedClientAddress($tier, 0, 0);
|
||||
$this->getEm()->getConnection()->executeStatement(
|
||||
"UPDATE client_address SET latitude = NULL, longitude = NULL WHERE city = 'NANTES' AND street = '1 rue de Test' AND client_id = :id",
|
||||
['id' => $tier->getId()],
|
||||
);
|
||||
|
||||
$data = $client->request('GET', '/api/visitable_tiers', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
'query' => ['q' => self::ISOLATE_Q],
|
||||
])->toArray();
|
||||
|
||||
self::assertSame(0, $data['totalItems'], 'Une adresse sans coordonnees ne produit aucun pin (RG-6.05).');
|
||||
}
|
||||
|
||||
public function testBboxRestrictsToVisibleArea(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
|
||||
$inside = $this->seedClient('Dedans');
|
||||
$this->seedClientAddress($inside, 47.0, -1.5);
|
||||
$outside = $this->seedClient('Dehors');
|
||||
$this->seedClientAddress($outside, 10.0, 10.0);
|
||||
|
||||
// bbox = minLng,minLat,maxLng,maxLat autour du point « dedans ».
|
||||
$data = $client->request('GET', '/api/visitable_tiers', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
'query' => ['q' => self::ISOLATE_Q, 'bbox' => '-2.0,46.5,-1.0,47.5'],
|
||||
])->toArray();
|
||||
|
||||
self::assertSame(1, $data['totalItems'], 'Seul le pin dans la bbox est retourne.');
|
||||
self::assertSame($inside->getId(), $data['member'][0]['tierId']);
|
||||
}
|
||||
|
||||
public function testTypeFilterRestrictsTiers(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
|
||||
$this->seedClientAddress($this->seedClient('Client T'));
|
||||
$this->seedSupplierAddress($this->seedSupplier('Supplier T'));
|
||||
|
||||
$data = $client->request('GET', '/api/visitable_tiers', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
'query' => ['q' => self::ISOLATE_Q, 'type' => 'supplier'],
|
||||
])->toArray();
|
||||
|
||||
self::assertSame(1, $data['totalItems'], 'type=supplier ne retourne que les fournisseurs.');
|
||||
self::assertSame('supplier', $data['member'][0]['tierType']);
|
||||
}
|
||||
|
||||
public function testInvalidTypeReturns400(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
|
||||
$client->request('GET', '/api/visitable_tiers', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
'query' => ['type' => 'prestataire_inexistant'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(400, 'Un type hors whitelist est rejete en 400.');
|
||||
}
|
||||
|
||||
public function testMalformedBboxReturns400(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
|
||||
$client->request('GET', '/api/visitable_tiers', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
'query' => ['bbox' => '1,2,3'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(400, 'Une bbox a 3 valeurs est rejetee en 400.');
|
||||
}
|
||||
|
||||
public function testExcludesArchivedTier(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
|
||||
$tier = $this->seedClient('Archive');
|
||||
$this->seedClientAddress($tier);
|
||||
$this->getEm()->getConnection()->executeStatement(
|
||||
'UPDATE client SET is_archived = TRUE WHERE id = :id',
|
||||
['id' => $tier->getId()],
|
||||
);
|
||||
|
||||
$data = $client->request('GET', '/api/visitable_tiers', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
'query' => ['q' => self::ISOLATE_Q],
|
||||
])->toArray();
|
||||
|
||||
self::assertSame(0, $data['totalItems'], 'Un Tiers archive ne produit aucun pin.');
|
||||
}
|
||||
|
||||
public function testRequiresViewPermission(): void
|
||||
{
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$client->request('GET', '/api/visitable_tiers', ['headers' => ['Accept' => self::LD]]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403, 'Sans field_sales.tours.view -> 403.');
|
||||
}
|
||||
|
||||
public function testPaginationFalseReturnsAllPins(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
|
||||
for ($i = 0; $i < 12; ++$i) {
|
||||
$this->seedClientAddress($this->seedClient('Bulk '.$i), 47.0 + $i / 1000, -1.5);
|
||||
}
|
||||
|
||||
$data = $client->request('GET', '/api/visitable_tiers', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
'query' => ['q' => self::ISOLATE_Q, 'pagination' => 'false'],
|
||||
])->toArray();
|
||||
|
||||
self::assertCount(12, $data['member'], 'pagination=false retourne tous les pins de la zone (carte).');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user