feat(field_sales) : calcul de trajet, optimisation, duplication & roadbook PDF (ERP-125)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 52s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Failing after 11s

- RouteEngineInterface (computeMatrix/optimizeOrder/estimateLegDurations) + HaversineRouteEngine V1 (vitesse moyenne parametrable, plus proche voisin)
- TourRouteCalculator : resolution coords, ETA (RG-6.11), exclusion sans coords (RG-6.05), totaux ; optimize = reorder + recompute
- Endpoints API Platform POST /tours/{id}/compute, /optimize, /duplicate (TourDuplicator, RG-6.13) + Processors, security manage
- Feuille de route PDF GET /tours/{id}/roadbook.pdf (Dompdf + Twig) via PdfRendererInterface (Shared), controller priority:1, security view
- TierAddressResolver etendu (coords + location DBAL)
- Tests : HaversineRouteEngine (unit), compute/optimize/duplicate/roadbook (API)
This commit is contained in:
Matthieu
2026-06-11 16:46:49 +02:00
parent 0052eab1fe
commit f8f7571cc0
21 changed files with 2120 additions and 4 deletions
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\FieldSales\Api;
use App\Module\FieldSales\Domain\Entity\TourStop;
/**
* Tests fonctionnels de la feuille de route PDF (M6.4 — GET
* /api/tours/{id}/roadbook.pdf). Couvre la production du binaire PDF, le gating
* de permission (view) et l'isolation par proprietaire (RG-6.01).
*
* @internal
*/
final class TourRoadbookApiTest extends AbstractFieldSalesApiTestCase
{
private const string LD = 'application/ld+json';
public function testRoadbookReturnsPdfBinary(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$admin = $this->getUserByUsername('admin');
$tour = $this->seedTour($admin, 'Tournée PDF');
// Une etape custom + une etape sur Tiers referentiel geolocalise.
$this->seedCustomStop($tour, 0, 47.0, -1.0, 'RDV prospect');
$tier = $this->seedClient('Ferme PDF');
$address = $this->seedClientAddress($tier);
$this->seedTierStop($tour, $tier, $address, 1);
$response = $client->request('GET', '/api/tours/'.$tour->getId().'/roadbook.pdf');
self::assertResponseStatusCodeSame(200);
self::assertResponseHeaderSame('Content-Type', 'application/pdf');
$content = $response->getContent();
self::assertStringStartsWith('%PDF', $content, 'Le corps est bien un binaire PDF.');
self::assertGreaterThan(800, strlen($content), 'Le PDF n\'est pas vide.');
}
public function testRoadbookRequiresViewPermission(): void
{
$creds = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$admin = $this->getUserByUsername('admin');
$tour = $this->seedTour($admin);
$client->request('GET', '/api/tours/'.$tour->getId().'/roadbook.pdf');
self::assertResponseStatusCodeSame(403);
}
/**
* RG-6.01 : une Commerciale ne peut pas exporter la tournee d'un autre.
*/
public function testRoadbookHidesOthersTour(): void
{
$credsA = $this->createUserWithPermissions(['field_sales.tours.view', 'field_sales.tours.manage']);
$credsB = $this->createUserWithPermissions(['field_sales.tours.view', 'field_sales.tours.manage']);
$client = $this->authenticatedClient($credsA['username'], $credsA['password']);
$userB = $this->getUserByUsername($credsB['username']);
$tourB = $this->seedTour($userB, 'Privée B');
$client->request('GET', '/api/tours/'.$tourB->getId().'/roadbook.pdf');
self::assertResponseStatusCodeSame(404);
}
private function seedCustomStop(\App\Module\FieldSales\Domain\Entity\Tour $tour, int $position, float $lat, float $lng, string $label): TourStop
{
$em = $this->getEm();
$stop = new TourStop();
$stop->setTour($tour);
$stop->setTierType(TourStop::TIER_TYPE_CUSTOM);
$stop->setCustomLabel($label);
$stop->setCustomAddress('5 place du Marché, 44000 Nantes');
$stop->setCustomLatitude($lat);
$stop->setCustomLongitude($lng);
$stop->setPosition($position);
$em->persist($stop);
$em->flush();
return $stop;
}
private function seedTierStop(\App\Module\FieldSales\Domain\Entity\Tour $tour, \App\Module\Commercial\Domain\Entity\Client $tier, \App\Module\Commercial\Domain\Entity\ClientAddress $address, int $position): TourStop
{
$em = $this->getEm();
$stop = new TourStop();
$stop->setTour($tour);
$stop->setTierType('client');
$stop->setTierId($tier->getId());
$stop->setAddressId($address->getId());
$stop->setPosition($position);
$em->persist($stop);
$em->flush();
return $stop;
}
}
@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\FieldSales\Api;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\ClientAddress;
use App\Module\Core\Domain\Entity\User;
use App\Module\FieldSales\Domain\Entity\Tour;
use App\Module\FieldSales\Domain\Entity\TourStop;
use DateTimeImmutable;
/**
* Tests fonctionnels du calcul de trajet, de l'optimisation et de la duplication
* (M6.4 — § 5 /compute /optimize /duplicate). Couvre RG-6.05 (exclusion sans
* coords), RG-6.11 (ETA), l'ordre plus proche voisin et RG-6.13 (duplication sans
* calculs).
*
* @internal
*/
final class TourRouteApiTest extends AbstractFieldSalesApiTestCase
{
private const string LD = 'application/ld+json';
/**
* RG-6.11 : ETA = depart + Σ trajets + Σ visites precedentes. RG-6.05 : une
* etape sans coordonnees est exclue (legs/eta null), les totaux ne la comptent
* pas.
*/
public function testComputeFillsEtaAndExcludesStopsWithoutCoords(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$admin = $this->getUserByUsername('admin');
$tour = $this->seedTour($admin);
// 2 points geolocalises alignes (0,2° de latitude ≈ 22,2 km) + 1 etape
// sur Tiers dont l'adresse n'a pas de coordonnees (exclue, RG-6.05).
$this->seedCustomStop($tour, 0, 47.0, -1.0);
$this->seedCustomStop($tour, 1, 47.2, -1.0);
$tier = $this->seedClient('Sans coords');
$address = $this->seedClientAddressWithoutCoords($tier);
$this->seedTierStop($tour, $tier, $address, 2);
$body = $client->request('POST', '/api/tours/'.$tour->getId().'/compute', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
])->toArray();
$stops = $this->stopsByPosition($body);
// 1re etape = depart (aucun start_* sur la tournee) -> leg nul, eta = 08:00.
self::assertSame(0, $stops[0]['legDistanceM']);
self::assertStringContainsString('08:00:00', (string) $stops[0]['eta']);
// 2e etape : ~22,2 km, eta posterieure au depart (RG-6.11).
self::assertEqualsWithDelta(22_240, $stops[1]['legDistanceM'], 600);
self::assertNotNull($stops[1]['eta']);
self::assertStringContainsString('08:', (string) $stops[1]['eta']);
self::assertGreaterThan($stops[0]['eta'], $stops[1]['eta'], 'ETA croissante le long de la tournee.');
// 3e etape exclue (RG-6.05) : legs + eta restent null.
self::assertNull($stops[2]['legDistanceM'], 'Etape sans coords exclue (RG-6.05).');
self::assertNull($stops[2]['eta']);
// Totaux : seules les etapes geolocalisees comptent.
self::assertEqualsWithDelta(22_240, $body['totalDistanceM'], 600);
self::assertGreaterThan(0, $body['totalDurationS']);
}
/**
* /optimize reordonne les etapes selon le plus proche voisin depuis la 1re
* etape (pas de start_*) puis recompute.
*/
public function testOptimizeReordersStopsByNearestNeighbour(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$admin = $this->getUserByUsername('admin');
$tour = $this->seedTour($admin);
// Depart = 1re etape (47.0). Fournies en desordre : la plus eloignee (47.3)
// en position 1, puis 47.2, puis la plus proche (47.1).
$this->seedCustomStop($tour, 0, 47.0, -1.0, 'Départ');
$this->seedCustomStop($tour, 1, 47.3, -1.0, 'Loin');
$this->seedCustomStop($tour, 2, 47.2, -1.0, 'Milieu');
$this->seedCustomStop($tour, 3, 47.1, -1.0, 'Proche');
$body = $client->request('POST', '/api/tours/'.$tour->getId().'/optimize', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
])->toArray();
$labels = array_map(
static fn (array $s) => $s['customLabel'],
$this->stopsByPosition($body),
);
self::assertSame(['Départ', 'Proche', 'Milieu', 'Loin'], $labels, 'Ordre plus proche voisin depuis le depart.');
}
/**
* RG-6.13 : la duplication copie depart + etapes a une nouvelle date, en
* draft, SANS les calculs (eta / legs recalcules ensuite).
*/
public function testDuplicateCopiesStopsWithoutComputedValues(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$admin = $this->getUserByUsername('admin');
$tour = $this->seedTour($admin, 'Tournée à dupliquer');
$this->seedCustomStop($tour, 0, 47.0, -1.0, 'A');
$this->seedCustomStop($tour, 1, 47.2, -1.0, 'B');
// On calcule d'abord la source pour s'assurer qu'elle porte des eta/legs.
$client->request('POST', '/api/tours/'.$tour->getId().'/compute', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
]);
$body = $client->request('POST', '/api/tours/'.$tour->getId().'/duplicate', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['tourDate' => '2026-09-01'],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertNotSame($tour->getId(), $body['id'], 'Une nouvelle tournee est creee.');
self::assertSame('draft', $body['status'], 'La copie repart en draft.');
self::assertStringStartsWith('2026-09-01', $body['tourDate']);
self::assertNull($body['totalDistanceM'], 'RG-6.13 : pas de totaux copies.');
$stops = $this->stopsByPosition($body);
self::assertCount(2, $stops);
self::assertSame(['A', 'B'], array_map(static fn (array $s) => $s['customLabel'], $stops));
self::assertNull($stops[0]['eta'], 'RG-6.13 : eta non copiee (recalculee ensuite).');
self::assertNull($stops[0]['legDistanceM'], 'RG-6.13 : legs non copies.');
}
public function testDuplicateRequiresTourDate(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$admin = $this->getUserByUsername('admin');
$tour = $this->seedTour($admin);
$response = $client->request('POST', '/api/tours/'.$tour->getId().'/duplicate', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('tourDate', $this->violationsByPath($response->toArray(false)));
}
public function testComputeRequiresManagePermission(): void
{
$creds = $this->createUserWithPermissions(['field_sales.tours.view']);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$user = $this->getUserByUsername($creds['username']);
$tour = $this->seedTour($user);
$client->request('POST', '/api/tours/'.$tour->getId().'/compute', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
]);
self::assertResponseStatusCodeSame(403, 'compute exige field_sales.tours.manage.');
}
// =================================================================
// Helpers de seed specifiques au calcul de trajet
// =================================================================
private function seedCustomStop(Tour $tour, int $position, float $lat, float $lng, string $label = 'Point libre'): TourStop
{
$em = $this->getEm();
$stop = new TourStop();
$stop->setTour($tour);
$stop->setTierType(TourStop::TIER_TYPE_CUSTOM);
$stop->setCustomLabel($label);
$stop->setCustomLatitude($lat);
$stop->setCustomLongitude($lng);
$stop->setPosition($position);
$em->persist($stop);
$em->flush();
return $stop;
}
private function seedTierStop(Tour $tour, Client $tier, ClientAddress $address, int $position): TourStop
{
$em = $this->getEm();
$stop = new TourStop();
$stop->setTour($tour);
$stop->setTierType('client');
$stop->setTierId($tier->getId());
$stop->setAddressId($address->getId());
$stop->setPosition($position);
$em->persist($stop);
$em->flush();
return $stop;
}
private function seedClientAddressWithoutCoords(Client $client): ClientAddress
{
$em = $this->getEm();
$address = new ClientAddress();
$address->setClient($client);
$address->setIsProspect(true);
$address->setPostalCode('44000');
$address->setCity('NANTES');
$address->setStreet('1 rue Sans Coords');
$em->persist($address);
$em->flush();
return $address;
}
/**
* Etapes de la reponse (item Tour) indexees par position croissante.
*
* @param array<string, mixed> $body
*
* @return list<array<string, mixed>>
*/
private function stopsByPosition(array $body): array
{
$stops = $body['stops'] ?? [];
usort($stops, static fn (array $a, array $b) => $a['position'] <=> $b['position']);
return array_values($stops);
}
}
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\FieldSales\Domain\Route;
use App\Module\FieldSales\Domain\Route\RoutePoint;
use App\Module\FieldSales\Infrastructure\Route\HaversineRouteEngine;
use PHPUnit\Framework\TestCase;
/**
* Test unitaire du moteur de trajet V1 (M6 § 3.4). Couvre :
* - la matrice de distances (symetrie, diagonale nulle, ordre de grandeur) ;
* - l'ordre « plus proche voisin » depuis le depart (RG : heuristique gratuite) ;
* - l'estimation distance/duree d'un segment a partir de la vitesse moyenne.
*
* Jeu de points aligne sur le meme meridien (longitude constante) : la distance
* ne depend alors que de l'ecart de latitude (~111,2 km par degre), ce qui rend
* l'ordre du plus proche voisin deterministe et verifiable a la main.
*
* @internal
*/
final class HaversineRouteEngineTest extends TestCase
{
/** 1° de latitude ≈ 111,2 km. Vitesse de test ronde pour des durees lisibles. */
private const float SPEED_KMH = 60.0;
private function engine(): HaversineRouteEngine
{
return new HaversineRouteEngine(self::SPEED_KMH);
}
public function testMatrixIsSymmetricWithZeroDiagonal(): void
{
$points = [
new RoutePoint('a', 47.0, -1.0),
new RoutePoint('b', 47.1, -1.0),
new RoutePoint('c', 47.3, -1.0),
];
$matrix = $this->engine()->computeMatrix($points);
foreach ([0, 1, 2] as $i) {
self::assertSame(0, $matrix[$i][$i], 'Diagonale nulle (distance d\'un point a lui-meme).');
foreach ([0, 1, 2] as $j) {
self::assertSame($matrix[$i][$j], $matrix[$j][$i], 'Matrice symetrique.');
}
}
// 0,1° de latitude ≈ 11,1 km : on tolere ±300 m sur le modele Haversine.
self::assertEqualsWithDelta(11_100, $matrix[0][1], 300, 'Distance a-b ≈ 11,1 km.');
}
public function testOptimizeOrderFromStartIsNearestNeighbour(): void
{
$start = new RoutePoint('start', 47.0, -1.0);
// Fournis en desordre : b(47.3) le plus loin, c(47.2), a(47.1) le plus proche.
$points = [
new RoutePoint('b', 47.3, -1.0),
new RoutePoint('c', 47.2, -1.0),
new RoutePoint('a', 47.1, -1.0),
];
$ordered = $this->engine()->optimizeOrder($start, $points);
self::assertSame(['a', 'c', 'b'], array_map(static fn (RoutePoint $p) => $p->ref, $ordered));
}
public function testOptimizeOrderWithoutStartKeepsFirstPointAsDeparture(): void
{
// Sans depart explicite : le 1er point fourni est le depart (reste en tete).
$points = [
new RoutePoint('depart', 47.0, -1.0),
new RoutePoint('b', 47.3, -1.0),
new RoutePoint('c', 47.2, -1.0),
new RoutePoint('a', 47.1, -1.0),
];
$ordered = $this->engine()->optimizeOrder(null, $points);
self::assertSame(['depart', 'a', 'c', 'b'], array_map(static fn (RoutePoint $p) => $p->ref, $ordered));
}
public function testLegDurationsUseAverageSpeed(): void
{
$start = new RoutePoint('start', 47.0, -1.0);
$points = [
new RoutePoint('a', 47.1, -1.0), // ~11,1 km du depart
new RoutePoint('b', 47.2, -1.0), // ~11,1 km de a
];
$legs = $this->engine()->estimateLegDurations($start, $points);
self::assertCount(2, $legs);
// 11,1 km a 60 km/h = 0,185 h ≈ 666 s. Tolerance large (modele Haversine).
self::assertEqualsWithDelta(666, $legs[0]->durationSeconds, 30);
self::assertEqualsWithDelta(11_100, $legs[0]->distanceMeters, 300);
}
public function testLegDurationsWithoutStartFirstLegIsZero(): void
{
// Sans depart, le 1er point EST le depart -> 1er segment nul.
$points = [
new RoutePoint('depart', 47.0, -1.0),
new RoutePoint('a', 47.1, -1.0),
];
$legs = $this->engine()->estimateLegDurations(null, $points);
self::assertCount(2, $legs);
self::assertSame(0, $legs[0]->distanceMeters, 'Aucun trajet pour atteindre le point de depart.');
self::assertSame(0, $legs[0]->durationSeconds);
self::assertEqualsWithDelta(11_100, $legs[1]->distanceMeters, 300);
}
}