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
+1
View File
@@ -12,6 +12,7 @@
"doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6",
"dompdf/dompdf": "^3.1",
"lexik/jwt-authentication-bundle": "^3.2",
"nelmio/cors-bundle": "^2.6",
"nyholm/psr7": "^1.8",
Generated
+446 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "b029c1484227c926d39dfd3ae5cb0699",
"content-hash": "b9a204bab17aa0371f8419362f3bee0c",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -2520,6 +2520,161 @@
},
"time": "2026-02-08T16:21:46+00:00"
},
{
"name": "dompdf/dompdf",
"version": "v3.1.5",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"shasum": ""
},
"require": {
"dompdf/php-font-lib": "^1.0.0",
"dompdf/php-svg-lib": "^1.0.0",
"ext-dom": "*",
"ext-mbstring": "*",
"masterminds/html5": "^2.0",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"ext-gd": "*",
"ext-json": "*",
"ext-zip": "*",
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "^3.5",
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
},
"suggest": {
"ext-gd": "Needed to process images",
"ext-gmagick": "Improves image processing performance",
"ext-imagick": "Improves image processing performance",
"ext-zlib": "Needed for pdf stream compression"
},
"type": "library",
"autoload": {
"psr-4": {
"Dompdf\\": "src/"
},
"classmap": [
"lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1"
],
"authors": [
{
"name": "The Dompdf Community",
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
}
],
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
"source": "https://github.com/dompdf/dompdf/tree/v3.1.5"
},
"time": "2026-03-03T13:54:37+00:00"
},
{
"name": "dompdf/php-font-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-font-lib.git",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
},
"type": "library",
"autoload": {
"psr-4": {
"FontLib\\": "src/FontLib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "The FontLib Community",
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse, export and make subsets of different types of font files.",
"homepage": "https://github.com/dompdf/php-font-lib",
"support": {
"issues": "https://github.com/dompdf/php-font-lib/issues",
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
},
"time": "2026-01-20T14:10:26+00:00"
},
{
"name": "dompdf/php-svg-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabberworm/php-css-parser": "^8.4 || ^9.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
},
"type": "library",
"autoload": {
"psr-4": {
"Svg\\": "src/Svg"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "The SvgLib Community",
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse and export to PDF SVG files.",
"homepage": "https://github.com/dompdf/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
},
"time": "2026-01-02T16:01:13+00:00"
},
{
"name": "lcobucci/jwt",
"version": "5.6.0",
@@ -2894,6 +3049,73 @@
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "masterminds/html5",
"version": "2.10.0",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
"reference": "fcf91eb64359852f00d921887b219479b4f21251"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
"reference": "fcf91eb64359852f00d921887b219479b4f21251",
"shasum": ""
},
"require": {
"ext-dom": "*",
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.7-dev"
}
},
"autoload": {
"psr-4": {
"Masterminds\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Matt Butcher",
"email": "technosophos@gmail.com"
},
{
"name": "Matt Farina",
"email": "matt@mattfarina.com"
},
{
"name": "Asmir Mustafic",
"email": "goetas@gmail.com"
}
],
"description": "An HTML5 parser and serializer.",
"homepage": "http://masterminds.github.io/html5-php",
"keywords": [
"HTML5",
"dom",
"html",
"parser",
"querypath",
"serializer",
"xml"
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
},
"time": "2025-07-25T09:04:22+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",
@@ -3937,6 +4159,86 @@
},
"time": "2021-10-29T13:26:27+00:00"
},
{
"name": "sabberworm/php-css-parser",
"version": "v9.3.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
"reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949",
"reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "1.4.0",
"phpstan/extension-installer": "1.4.3",
"phpstan/phpstan": "1.12.32 || 2.1.32",
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.8",
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7",
"phpunit/phpunit": "8.5.52",
"rawr/phpunit-data-provider": "3.3.1",
"rector/rector": "1.2.10 || 2.2.8",
"rector/type-perfect": "1.0.0 || 2.1.0",
"squizlabs/php_codesniffer": "4.0.1",
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1"
},
"suggest": {
"ext-mbstring": "for parsing UTF-8 CSS"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "9.4.x-dev"
}
},
"autoload": {
"files": [
"src/Rule/Rule.php",
"src/RuleSet/RuleContainer.php"
],
"psr-4": {
"Sabberworm\\CSS\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Raphael Schweikert"
},
{
"name": "Oliver Klee",
"email": "github@oliverklee.de"
},
{
"name": "Jake Hotson",
"email": "jake.github@qzdesign.co.uk"
}
],
"description": "Parser for CSS Files written in PHP",
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"keywords": [
"css",
"parser",
"stylesheet"
],
"support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0"
},
"time": "2026-03-03T17:31:43+00:00"
},
{
"name": "symfony/asset",
"version": "v8.0.8",
@@ -8779,6 +9081,149 @@
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "thecodingmachine/safe",
"version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/safe.git",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10",
"squizlabs/php_codesniffer": "^3.2"
},
"type": "library",
"autoload": {
"files": [
"lib/special_cases.php",
"generated/apache.php",
"generated/apcu.php",
"generated/array.php",
"generated/bzip2.php",
"generated/calendar.php",
"generated/classobj.php",
"generated/com.php",
"generated/cubrid.php",
"generated/curl.php",
"generated/datetime.php",
"generated/dir.php",
"generated/eio.php",
"generated/errorfunc.php",
"generated/exec.php",
"generated/fileinfo.php",
"generated/filesystem.php",
"generated/filter.php",
"generated/fpm.php",
"generated/ftp.php",
"generated/funchand.php",
"generated/gettext.php",
"generated/gmp.php",
"generated/gnupg.php",
"generated/hash.php",
"generated/ibase.php",
"generated/ibmDb2.php",
"generated/iconv.php",
"generated/image.php",
"generated/imap.php",
"generated/info.php",
"generated/inotify.php",
"generated/json.php",
"generated/ldap.php",
"generated/libxml.php",
"generated/lzf.php",
"generated/mailparse.php",
"generated/mbstring.php",
"generated/misc.php",
"generated/mysql.php",
"generated/mysqli.php",
"generated/network.php",
"generated/oci8.php",
"generated/opcache.php",
"generated/openssl.php",
"generated/outcontrol.php",
"generated/pcntl.php",
"generated/pcre.php",
"generated/pgsql.php",
"generated/posix.php",
"generated/ps.php",
"generated/pspell.php",
"generated/readline.php",
"generated/rnp.php",
"generated/rpminfo.php",
"generated/rrd.php",
"generated/sem.php",
"generated/session.php",
"generated/shmop.php",
"generated/sockets.php",
"generated/sodium.php",
"generated/solr.php",
"generated/spl.php",
"generated/sqlsrv.php",
"generated/ssdeep.php",
"generated/ssh2.php",
"generated/stream.php",
"generated/strings.php",
"generated/swoole.php",
"generated/uodbc.php",
"generated/uopz.php",
"generated/url.php",
"generated/var.php",
"generated/xdiff.php",
"generated/xml.php",
"generated/xmlrpc.php",
"generated/yaml.php",
"generated/yaz.php",
"generated/zip.php",
"generated/zlib.php"
],
"classmap": [
"lib/DateTime.php",
"lib/DateTimeImmutable.php",
"lib/Exceptions/",
"generated/Exceptions/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": {
"issues": "https://github.com/thecodingmachine/safe/issues",
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
},
"funding": [
{
"url": "https://github.com/OskarStark",
"type": "github"
},
{
"url": "https://github.com/shish",
"type": "github"
},
{
"url": "https://github.com/silasjoisten",
"type": "github"
},
{
"url": "https://github.com/staabm",
"type": "github"
}
],
"time": "2026-02-04T18:08:13+00:00"
},
{
"name": "twig/twig",
"version": "v3.24.0",
+11
View File
@@ -1,6 +1,8 @@
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
parameters:
# Vitesse moyenne (km/h) du moteur de trajet V1 Haversine (M6 § 3.4).
field_sales.route_average_speed_kmh: 50.0
imports:
- { resource: version.yaml }
@@ -38,6 +40,15 @@ services:
App\Shared\Domain\Contract\GeocoderInterface:
alias: App\Shared\Infrastructure\Geocoding\BanGeocoder
# Moteur de trajet V1 (M6 § 3.4) : Haversine + plus proche voisin. La V2
# rebranchera OrsRouteEngine ici sans toucher au calculateur ni au front.
App\Module\FieldSales\Domain\Route\RouteEngineInterface:
alias: App\Module\FieldSales\Infrastructure\Route\HaversineRouteEngine
# Rendu PDF (feuille de route M6.4, etc.) : Dompdf.
App\Shared\Domain\Contract\PdfRendererInterface:
alias: App\Shared\Infrastructure\Pdf\DompdfRenderer
# En test : geocodeur en memoire, deterministe et sans reseau (les tests
# fonctionnels d'adresse ne doivent jamais appeler la BAN reelle).
when@test:
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Application\Duplication;
use App\Module\FieldSales\Domain\Entity\Tour;
use App\Module\FieldSales\Domain\Entity\TourStop;
use App\Module\FieldSales\Domain\Enum\TourStatus;
use DateTimeImmutable;
/**
* Duplication d'une tournee (M6 § 13, RG-6.13). Cree une NOUVELLE tournee `draft`
* a la date fournie, copiant :
* - les parametres de la tournee source (point de depart, heure de depart, duree
* de visite par defaut, libelle) ;
* - chaque etape (cible Tiers/adresse ou point libre, position, duree de visite).
*
* Ne copie PAS les calculs (eta, leg_distance_m, leg_duration_s, totaux) : ils
* seront recalcules par /compute sur la copie. La copie appartient au meme
* proprietaire que la source (tournee personnelle, RG-6.01).
*
* Service pur : il construit et retourne l'entite ; la persistance (persist +
* flush) est a la charge du processor appelant.
*/
final class TourDuplicator
{
public function duplicate(Tour $source, DateTimeImmutable $tourDate): Tour
{
$copy = new Tour();
$copy->setOwner($source->getOwner());
$copy->setLabel($source->getLabel());
$copy->setTourDate($tourDate);
$copy->setDepartureTime($source->getDepartureTime());
$copy->setStartLatitude($source->getStartLatitude());
$copy->setStartLongitude($source->getStartLongitude());
$copy->setStartLabel($source->getStartLabel());
$copy->setDefaultVisitMinutes($source->getDefaultVisitMinutes());
// Toute copie repart en draft, quel que soit l'etat de la source.
$copy->setStatus(TourStatus::Draft->value);
foreach ($source->getStops() as $stop) {
$copy->addStop($this->duplicateStop($stop));
}
return $copy;
}
/**
* Copie d'une etape SANS les champs calcules (eta / legs), conformement a
* RG-6.13 : ils seront regeneres par /compute.
*/
private function duplicateStop(TourStop $source): TourStop
{
$copy = new TourStop();
$copy->setTierType($source->getTierType());
$copy->setTierId($source->getTierId());
$copy->setAddressId($source->getAddressId());
$copy->setCustomLabel($source->getCustomLabel());
$copy->setCustomAddress($source->getCustomAddress());
$copy->setCustomLatitude($source->getCustomLatitude());
$copy->setCustomLongitude($source->getCustomLongitude());
$copy->setPosition($source->getPosition());
$copy->setVisitMinutes($source->getVisitMinutes());
return $copy;
}
}
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Application\Route;
use App\Module\FieldSales\Domain\Entity\Tour;
use App\Module\FieldSales\Domain\Entity\TourStop;
use App\Module\FieldSales\Domain\Route\RouteEngineInterface;
use App\Module\FieldSales\Domain\Route\RoutePoint;
use App\Module\FieldSales\Infrastructure\Tier\TierAddressResolver;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
/**
* Orchestration du calcul de trajet d'une tournee (M6 § 3.4, § 5 /compute +
* /optimize). Fait le pont entre les entites (Tour / TourStop) et le moteur
* geometrique {@see RouteEngineInterface}, qui lui ignore tout du metier :
*
* 1. resout les coordonnees de chaque etape (point libre `custom` -> coords
* portees par l'etape ; Tiers referentiel -> coords de l'adresse, ERP-122) ;
* 2. exclut les etapes sans coordonnees (RG-6.05) : leurs legs/eta sont remis a
* null (signalement « a geolocaliser » cote front) ;
* 3. calcule, pour les etapes geolocalisees, les segments (leg_distance_m /
* leg_duration_s) et l'heure d'arrivee estimee (eta, RG-6.11 : depart + Σ
* trajets precedents + Σ durees de visite precedentes) ;
* 4. met a jour les totaux de la tournee (total_distance_m / total_duration_s).
*
* `compute()` respecte l'ordre courant des etapes (position) ; `optimize()`
* reordonne d'abord via le moteur (plus proche voisin) puis recompute.
*/
final class TourRouteCalculator
{
public function __construct(
private readonly RouteEngineInterface $routeEngine,
private readonly TierAddressResolver $tierAddressResolver,
private readonly EntityManagerInterface $em,
) {}
/**
* Recalcule legs + eta + totaux de la tournee, dans l'ordre courant des
* etapes. Mutation en place des entites (le flush est a la charge du
* processor appelant).
*/
public function compute(Tour $tour): void
{
$stops = $this->orderedStops($tour);
// RG-6.05 : on partitionne les etapes geolocalisees (entrent dans le
// calcul) des autres (legs/eta remis a null = « a geolocaliser »).
$routedStops = [];
$points = [];
foreach ($stops as $stop) {
$coords = $this->resolveCoordinates($stop);
if (null === $coords) {
$this->resetStop($stop);
continue;
}
$routedStops[] = $stop;
$points[] = new RoutePoint($stop->getId() ?? spl_object_id($stop), $coords['lat'], $coords['lng']);
}
if ([] === $routedStops) {
$tour->setTotalDistanceM(null);
$tour->setTotalDurationS(null);
return;
}
$start = $this->startPoint($tour);
$legs = $this->routeEngine->estimateLegDurations($start, $points);
$departureSeconds = $this->secondsOfDay($tour->getDepartureTime());
$elapsedSeconds = 0; // secondes ecoulees depuis le depart
$totalDistance = 0;
foreach ($routedStops as $index => $stop) {
$leg = $legs[$index];
// Trajet pour atteindre cette etape puis heure d'arrivee estimee.
$elapsedSeconds += $leg->durationSeconds;
$totalDistance += $leg->distanceMeters;
$stop->setLegDistanceM($leg->distanceMeters);
$stop->setLegDurationS($leg->durationSeconds);
$stop->setEta($tour->getDepartureTime()->setTime(0, 0)->modify(
sprintf('+%d seconds', $departureSeconds + $elapsedSeconds),
));
// La visite a cette etape repousse l'arrivee a l'etape suivante.
$elapsedSeconds += $this->visitSeconds($tour, $stop);
}
$tour->setTotalDistanceM($totalDistance);
// Duree totale = trajets + visites (du depart a la fin de la derniere visite).
$tour->setTotalDurationS($elapsedSeconds);
}
/**
* Reordonne les etapes geolocalisees selon le plus proche voisin
* (RouteEngine::optimizeOrder) puis recompute. Les etapes sans coordonnees
* (RG-6.05) restent rejetees en fin de tournee, ordre relatif preserve.
*
* Persiste les nouvelles positions en DEUX temps pour ne pas heurter l'unique
* (tour_id, position) en cours de flush : d'abord un offset temporaire hors
* plage, puis les positions finales 0..n-1.
*/
public function optimize(Tour $tour): void
{
$stops = $this->orderedStops($tour);
$routedStops = [];
$points = [];
$unroutedStops = [];
$stopByRef = [];
foreach ($stops as $stop) {
$coords = $this->resolveCoordinates($stop);
if (null === $coords) {
$unroutedStops[] = $stop;
continue;
}
$ref = $stop->getId() ?? spl_object_id($stop);
$stopByRef[$ref] = $stop;
$routedStops[] = $stop;
$points[] = new RoutePoint($ref, $coords['lat'], $coords['lng']);
}
// Rien a reordonner (0 ou 1 etape geolocalisee) : on recompute seulement.
if (count($routedStops) > 1) {
$orderedPoints = $this->routeEngine->optimizeOrder($this->startPoint($tour), $points);
// Etapes geolocalisees dans le nouvel ordre, puis les non geolocalisees.
$orderedStops = array_map(static fn (RoutePoint $p) => $stopByRef[$p->ref], $orderedPoints);
$orderedStops = [...$orderedStops, ...$unroutedStops];
$this->reassignPositions($orderedStops);
}
$this->compute($tour);
}
/**
* Reattribue les positions 0..n-1 dans l'ordre fourni, en deux flushes pour
* eviter toute collision transitoire avec l'unique (tour_id, position).
*
* @param list<TourStop> $orderedStops
*/
private function reassignPositions(array $orderedStops): void
{
// Phase 1 : positions temporaires hors plage (offset > nb d'etapes
// possibles), garanties uniques entre elles.
foreach ($orderedStops as $index => $stop) {
$stop->setPosition(10_000 + $index);
}
$this->em->flush();
// Phase 2 : positions finales contiguës a partir de 0.
foreach ($orderedStops as $index => $stop) {
$stop->setPosition($index);
}
$this->em->flush();
}
/**
* Etapes de la tournee triees par position croissante.
*
* @return list<TourStop>
*/
private function orderedStops(Tour $tour): array
{
$stops = array_values($tour->getStops()->toArray());
usort($stops, static fn (TourStop $a, TourStop $b) => $a->getPosition() <=> $b->getPosition());
return $stops;
}
/**
* Coordonnees d'une etape : point libre -> coords saisies sur l'etape ; Tiers
* referentiel -> coords de l'adresse visee. Null si non geolocalisable.
*
* @return null|array{lat: float, lng: float}
*/
private function resolveCoordinates(TourStop $stop): ?array
{
if (TourStop::TIER_TYPE_CUSTOM === $stop->getTierType()) {
$lat = $stop->getCustomLatitude();
$lng = $stop->getCustomLongitude();
return null === $lat || null === $lng ? null : ['lat' => (float) $lat, 'lng' => (float) $lng];
}
$tierType = $stop->getTierType();
$addressId = $stop->getAddressId();
if (null === $tierType || null === $addressId) {
return null;
}
return $this->tierAddressResolver->findAddressCoordinates($tierType, $addressId);
}
/**
* Point de depart de la tournee : coordonnees explicites (start_*) si les deux
* sont posees, sinon null -> la 1re etape geolocalisee fait office de depart
* (cf. RouteEngine, 1er segment nul).
*/
private function startPoint(Tour $tour): ?RoutePoint
{
$lat = $tour->getStartLatitude();
$lng = $tour->getStartLongitude();
if (null === $lat || null === $lng) {
return null;
}
return new RoutePoint('start', (float) $lat, (float) $lng);
}
/**
* Duree de visite d'une etape en secondes : valeur specifique de l'etape
* sinon la duree par defaut de la tournee.
*/
private function visitSeconds(Tour $tour, TourStop $stop): int
{
return ($stop->getVisitMinutes() ?? $tour->getDefaultVisitMinutes()) * 60;
}
/**
* Nombre de secondes ecoulees depuis minuit pour une heure donnee.
*/
private function secondsOfDay(DateTimeImmutable $time): int
{
return (int) $time->format('H') * 3600
+ (int) $time->format('i') * 60
+ (int) $time->format('s');
}
/**
* Remet a null les resultats calcules d'une etape exclue du trajet (RG-6.05).
*/
private function resetStop(TourStop $stop): void
{
$stop->setLegDistanceM(null);
$stop->setLegDurationS(null);
$stop->setEta(null);
}
}
@@ -11,6 +11,9 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\FieldSales\Domain\Enum\TourStatus;
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor\TourComputeProcessor;
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor\TourDuplicateProcessor;
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor\TourOptimizeProcessor;
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor\TourProcessor;
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Provider\TourProvider;
use App\Module\FieldSales\Infrastructure\Doctrine\DoctrineTourRepository;
@@ -84,6 +87,45 @@ use Symfony\Component\Validator\Constraints as Assert;
provider: TourProvider::class,
processor: TourProcessor::class,
),
// Recalcule legs + ETA + totaux (HaversineRouteEngine). Sans corps :
// deserialize:false / validate:false ; la tournee est chargee par le
// provider (RG-6.01). Reponse = la tournee + ses etapes recalculees.
new Post(
uriTemplate: '/tours/{id}/compute',
status: 200,
security: "is_granted('field_sales.tours.manage')",
deserialize: false,
validate: false,
read: true,
normalizationContext: ['groups' => ['tour:read', 'tour:item:read', 'tour_stop:read', 'default:read']],
provider: TourProvider::class,
processor: TourComputeProcessor::class,
),
// Reordonne les etapes (plus proche voisin) puis recompute.
new Post(
uriTemplate: '/tours/{id}/optimize',
status: 200,
security: "is_granted('field_sales.tours.manage')",
deserialize: false,
validate: false,
read: true,
normalizationContext: ['groups' => ['tour:read', 'tour:item:read', 'tour_stop:read', 'default:read']],
provider: TourProvider::class,
processor: TourOptimizeProcessor::class,
),
// Duplique depart + etapes a une nouvelle date (corps {tourDate}), sans
// calculs (RG-6.13). deserialize:false : le processor lit tourDate puis
// construit une copie draft via TourDuplicator. Reponse 201 = la copie.
new Post(
uriTemplate: '/tours/{id}/duplicate',
security: "is_granted('field_sales.tours.manage')",
deserialize: false,
validate: false,
read: true,
normalizationContext: ['groups' => ['tour:read', 'tour:item:read', 'tour_stop:read', 'default:read']],
provider: TourProvider::class,
processor: TourDuplicateProcessor::class,
),
],
)]
#[ORM\Entity(repositoryClass: DoctrineTourRepository::class)]
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Domain\Route;
/**
* Contrat du moteur de calcul de trajet (M6 § 3.4). Pose des la V1 pour brancher
* un fournisseur routier reel (OrsRouteEngine) en V2 SANS toucher au reste du
* module : « on n'ecrit jamais l'algo routier, on branche un fournisseur ».
*
* - V1 : HaversineRouteEngine — distance a vol d'oiseau, vitesse moyenne
* parametrable, ordre « plus proche voisin » depuis le depart (heuristique
* gratuite, RG-6.05 / RG-6.11).
* - V2 : OrsRouteEngine — matrice de temps routiers reels + optimisation TSP.
*
* Le contrat est purement geometrique : il opere sur des {@see RoutePoint} et ne
* connait aucune entite metier (Tour / TourStop). L'orchestration (resolution des
* coordonnees des etapes, ecriture des resultats, ETA) vit dans le service
* applicatif TourRouteCalculator.
*/
interface RouteEngineInterface
{
/**
* Matrice (symetrique, diagonale nulle) des distances en metres entre tous
* les points fournis. `$matrix[$i][$j]` = distance de `$points[$i]` a
* `$points[$j]`.
*
* @param list<RoutePoint> $points
*
* @return array<int, array<int, int>>
*/
public function computeMatrix(array $points): array;
/**
* Reordonne les points selon l'heuristique du plus proche voisin :
* - si `$start` est fourni, on part de `$start` et on enchaine a chaque etape
* le point restant le plus proche ;
* - si `$start` est null, le premier point de `$points` est considere comme
* le depart (il reste en tete) et seuls les suivants sont reordonnes.
*
* @param list<RoutePoint> $points
*
* @return list<RoutePoint> les memes points, dans le nouvel ordre
*/
public function optimizeOrder(?RoutePoint $start, array $points): array;
/**
* Distance + duree de chaque segment de l'itineraire
* `depart -> points[0] -> points[1] -> ...`. Retourne un {@see RouteLeg} par
* point : `$legs[$i]` est le trajet pour atteindre `$points[$i]`.
*
* Si `$start` est null, le premier point est le depart : `$legs[0]` est alors
* un segment nul (distance/duree = 0).
*
* @param list<RoutePoint> $points points DEJA ordonnes
*
* @return list<RouteLeg>
*/
public function estimateLegDurations(?RoutePoint $start, array $points): array;
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Domain\Route;
/**
* Segment d'itineraire calcule par le moteur de trajet : distance et duree pour
* rejoindre un point depuis le precedent (ou depuis le point de depart pour le
* premier segment). Objet valeur immuable.
*
* Alimente `tour_stop.leg_distance_m` / `leg_duration_s` (M6 § 4.3).
*/
final readonly class RouteLeg
{
public function __construct(
public int $distanceMeters,
public int $durationSeconds,
) {}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Domain\Route;
/**
* Point geographique manipule par le moteur de trajet (M6 § 3.4). Objet valeur
* immuable, purement geometrique : il ne connait ni l'entite Tour ni TourStop.
*
* `$ref` identifie le point cote appelant (ex: id d'une etape, ou un marqueur de
* depart) pour reassocier le resultat du moteur a l'etape correspondante apres
* reordonnancement. Le moteur ne l'interprete jamais.
*/
final readonly class RoutePoint
{
public function __construct(
public int|string $ref,
public float $latitude,
public float $longitude,
) {}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\FieldSales\Application\Route\TourRouteCalculator;
use App\Module\FieldSales\Domain\Entity\Tour;
use Doctrine\ORM\EntityManagerInterface;
use function assert;
/**
* Processor de l'operation POST /api/tours/{id}/compute (M6 § 5).
*
* La tournee est chargee en amont par TourProvider (controle RG-6.01 + soft
* delete). L'operation ne porte pas de corps : on recalcule simplement legs +
* eta + totaux (HaversineRouteEngine via TourRouteCalculator) puis on persiste.
*
* @implements ProcessorInterface<Tour, Tour>
*/
final class TourComputeProcessor implements ProcessorInterface
{
public function __construct(
private readonly TourRouteCalculator $calculator,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Tour
{
assert($data instanceof Tour);
$this->calculator->compute($data);
$this->em->flush();
return $data;
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\FieldSales\Application\Duplication\TourDuplicator;
use App\Module\FieldSales\Domain\Entity\Tour;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use function assert;
/**
* Processor de l'operation POST /api/tours/{id}/duplicate (M6 § 5, RG-6.13).
*
* La tournee source est chargee par TourProvider (RG-6.01). Le corps porte la
* nouvelle date (`tourDate`). On delegue la copie a {@see TourDuplicator} (sans
* calculs), on persiste la copie et on la retourne (201).
*
* Operation deserialize:false : le corps n'est pas mappe sur la source, on lit
* `tourDate` manuellement et on leve une 422 (propertyPath `tourDate`) si elle
* est absente ou invalide — consommable par useFormErrors cote front.
*
* @implements ProcessorInterface<Tour, Tour>
*/
final class TourDuplicateProcessor implements ProcessorInterface
{
public function __construct(
private readonly TourDuplicator $duplicator,
private readonly EntityManagerInterface $em,
private readonly RequestStack $requestStack,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Tour
{
assert($data instanceof Tour);
$tourDate = $this->readTourDate();
$copy = $this->duplicator->duplicate($data, $tourDate);
$this->em->persist($copy);
$this->em->flush();
return $copy;
}
/**
* Lit et valide `tourDate` depuis le corps JSON de la requete. Format attendu
* `Y-m-d`. Leve une 422 portee sur `tourDate` si absente ou invalide.
*/
private function readTourDate(): DateTimeImmutable
{
$request = $this->requestStack->getCurrentRequest();
$payload = null !== $request ? json_decode($request->getContent(), true) : null;
$raw = is_array($payload) ? ($payload['tourDate'] ?? null) : null;
if (is_string($raw) && '' !== trim($raw)) {
$date = DateTimeImmutable::createFromFormat('!Y-m-d', trim($raw));
if (false !== $date) {
return $date;
}
}
$this->throwTourDateViolation(
is_string($raw) && '' !== trim($raw)
? 'La date de la tournée doit être au format AAAA-MM-JJ.'
: 'La date de la tournée dupliquée est obligatoire.',
);
}
/**
* @return never
*/
private function throwTourDateViolation(string $message): void
{
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation($message, null, [], null, 'tourDate', null));
throw new ValidationException($violations);
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\FieldSales\Application\Route\TourRouteCalculator;
use App\Module\FieldSales\Domain\Entity\Tour;
use Doctrine\ORM\EntityManagerInterface;
use function assert;
/**
* Processor de l'operation POST /api/tours/{id}/optimize (M6 § 5).
*
* Reordonne les etapes via le moteur (plus proche voisin) puis recompute legs +
* eta + totaux. La tournee est chargee en amont par TourProvider (RG-6.01).
*
* @implements ProcessorInterface<Tour, Tour>
*/
final class TourOptimizeProcessor implements ProcessorInterface
{
public function __construct(
private readonly TourRouteCalculator $calculator,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Tour
{
assert($data instanceof Tour);
$this->calculator->optimize($data);
$this->em->flush();
return $data;
}
}
@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\Controller;
use App\Module\FieldSales\Domain\Entity\Tour;
use App\Module\FieldSales\Domain\Entity\TourStop;
use App\Module\FieldSales\Domain\Repository\TourRepositoryInterface;
use App\Module\FieldSales\Infrastructure\Tier\TierAddressResolver;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Contract\PdfRendererInterface;
use App\Shared\Domain\Security\BusinessRoles;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Twig\Environment;
/**
* Feuille de route PDF d'une tournee (M6 § 5 — GET /api/tours/{id}/roadbook.pdf).
*
* Controller Symfony custom (et non operation API Platform) car il produit un
* binaire de fichier, pas une representation Hydra — meme motif que les exports
* XLSX (ClientExportController). `priority: 1` est OBLIGATOIRE sur la route :
* sans cela API Platform capterait `/api/tours/{id}/roadbook.pdf` comme l'item
* `GET /api/tours/{id}.{_format}`.
*
* Separation des responsabilites :
* - le COMMENT (HTML -> PDF) est delegue au service Shared {@see PdfRendererInterface} ;
* - le rendu HTML est un template Twig (field_sales/roadbook.html.twig) ;
* - le QUOI vit ICI : controle d'acces RG-6.01, mapping des etapes en lignes.
*
* Acces : `field_sales.tours.view` (IsGranted) + RG-6.01 (la Commerciale ne voit
* que ses tournees ; admin / Bureau voient tout) — meme regle que TourProvider.
*/
#[AsController]
final class TourRoadbookController
{
public function __construct(
#[Autowire(service: 'App\Module\FieldSales\Infrastructure\Doctrine\DoctrineTourRepository')]
private readonly TourRepositoryInterface $repository,
private readonly TierAddressResolver $tierAddressResolver,
private readonly PdfRendererInterface $pdfRenderer,
private readonly Environment $twig,
private readonly Security $security,
) {}
#[Route('/api/tours/{id}/roadbook.pdf', name: 'field_sales_tour_roadbook_pdf', requirements: ['id' => '\d+'], methods: ['GET'], priority: 1)]
#[IsGranted('field_sales.tours.view')]
public function __invoke(int $id): Response
{
$tour = $this->repository->findById($id);
if (null === $tour || null !== $tour->getDeletedAt() || !$this->canView($tour)) {
throw new NotFoundHttpException('Tournée introuvable.');
}
$html = $this->twig->render('field_sales/roadbook.html.twig', [
'tour' => $this->mapTour($tour),
'stops' => $this->mapStops($tour),
]);
return $this->buildResponse($this->pdfRenderer->renderHtml($html), $tour);
}
/**
* RG-6.01 : admin (ROLE_ADMIN) et role metier Bureau voient toutes les
* tournees ; sinon seul le proprietaire (meme logique que TourProvider).
*/
private function canView(Tour $tour): bool
{
if ($this->security->isGranted('ROLE_ADMIN')) {
return true;
}
$user = $this->security->getUser();
if ($user instanceof BusinessRoleAwareInterface && $user->hasBusinessRole(BusinessRoles::BUREAU)) {
return true;
}
return $tour->getOwner() === $user;
}
/**
* En-tete de la feuille de route.
*
* @return array<string, null|int|string>
*/
private function mapTour(Tour $tour): array
{
return [
'label' => $tour->getLabel(),
'date' => $tour->getTourDate()?->format('d/m/Y'),
'commercial' => $tour->getOwner()?->getUserIdentifier(),
'departureTime' => $tour->getDepartureTime()->format('H\hi'),
'startLabel' => $tour->getStartLabel(),
'totalDistance' => $this->formatDistance($tour->getTotalDistanceM()),
'totalDuration' => $this->formatDuration($tour->getTotalDurationS()),
'stopCount' => $tour->getStops()->count(),
];
}
/**
* Une ligne par etape (n°, ETA, duree de visite, Tiers/libelle, adresse,
* temps + distance depuis l'etape precedente).
*
* @return list<array<string, null|int|string>>
*/
private function mapStops(Tour $tour): array
{
$stops = $tour->getStops()->toArray();
usort($stops, static fn (TourStop $a, TourStop $b) => $a->getPosition() <=> $b->getPosition());
$rows = [];
$number = 1;
foreach ($stops as $stop) {
[$name, $address] = $this->resolveStopDisplay($stop);
$rows[] = [
'number' => $number++,
'eta' => $stop->getEta()?->format('H\hi') ?? '—',
'visitMinutes' => $stop->getVisitMinutes() ?? $tour->getDefaultVisitMinutes(),
'name' => $name,
'address' => $address,
'legDistance' => null !== $stop->getLegDistanceM() ? $this->formatDistance($stop->getLegDistanceM()) : '—',
'legDuration' => null !== $stop->getLegDurationS() ? $this->formatDuration($stop->getLegDurationS()) : '—',
];
}
return $rows;
}
/**
* Nom affiche + adresse complete d'une etape : point libre -> libelle/adresse
* saisis ; Tiers referentiel -> nom du Tiers + adresse resolue (DBAL).
*
* @return array{0: string, 1: string}
*/
private function resolveStopDisplay(TourStop $stop): array
{
if (TourStop::TIER_TYPE_CUSTOM === $stop->getTierType()) {
return [
$stop->getCustomLabel() ?? 'Point libre',
$stop->getCustomAddress() ?? '',
];
}
$tierType = $stop->getTierType();
$addressId = $stop->getAddressId();
if (null === $tierType || null === $addressId) {
return ['Étape', ''];
}
$location = $this->tierAddressResolver->findStopLocation($tierType, $addressId);
if (null === $location) {
return ['Tiers #'.(string) $stop->getTierId(), ''];
}
return [$location['tierName'], $this->formatAddress($location)];
}
/**
* Concatene les composantes d'adresse en une ligne lisible.
*
* @param array{street: ?string, streetComplement: ?string, postalCode: ?string, city: ?string} $location
*/
private function formatAddress(array $location): string
{
$street = trim(($location['street'] ?? '').' '.($location['streetComplement'] ?? ''));
$city = trim(($location['postalCode'] ?? '').' '.($location['city'] ?? ''));
return trim(implode(', ', array_filter([$street, $city], static fn (string $p) => '' !== $p)));
}
/**
* Distance en metres -> texte « X,Y km » (ou « — » si inconnue).
*/
private function formatDistance(?int $meters): string
{
if (null === $meters) {
return '—';
}
return number_format($meters / 1000, 1, ',', ' ').' km';
}
/**
* Duree en secondes -> texte « XhYY » / « YY min » (ou « — » si inconnue).
*/
private function formatDuration(?int $seconds): string
{
if (null === $seconds) {
return '—';
}
$minutes = (int) round($seconds / 60);
if ($minutes < 60) {
return $minutes.' min';
}
return sprintf('%dh%02d', intdiv($minutes, 60), $minutes % 60);
}
private function buildResponse(string $binary, Tour $tour): Response
{
$filename = sprintf('feuille-de-route-%d-%s.pdf', $tour->getId(), $tour->getTourDate()?->format('Ymd') ?? 'tour');
$response = new Response($binary);
$response->headers->set('Content-Type', 'application/pdf');
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
return $response;
}
}
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\Route;
use App\Module\FieldSales\Domain\Route\RouteEngineInterface;
use App\Module\FieldSales\Domain\Route\RouteLeg;
use App\Module\FieldSales\Domain\Route\RoutePoint;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Moteur de trajet V1 (M6 § 3.4) : « heuristique gratuite ».
*
* - Distance = formule de Haversine (vol d'oiseau, en metres).
* - Duree = distance / vitesse moyenne (km/h parametrable, defaut 50).
* - Ordre = plus proche voisin glouton depuis le point de depart.
*
* Aucune dependance reseau ni cout : la V2 (OrsRouteEngine) remplacera cette impl
* derriere {@see RouteEngineInterface} sans toucher au calculateur ni au front.
*/
final class HaversineRouteEngine implements RouteEngineInterface
{
/** Rayon moyen de la Terre en metres (modele spherique WGS84). */
private const float EARTH_RADIUS_M = 6_371_000.0;
public function __construct(
// Vitesse moyenne parametrable (config field_sales.route_average_speed_kmh).
#[Autowire(param: 'field_sales.route_average_speed_kmh')]
private readonly float $averageSpeedKmh = 50.0,
) {}
public function computeMatrix(array $points): array
{
$matrix = [];
$count = count($points);
for ($i = 0; $i < $count; ++$i) {
for ($j = 0; $j < $count; ++$j) {
// Symetrie : on ne calcule que le triangle superieur, on recopie.
if ($j < $i) {
$matrix[$i][$j] = $matrix[$j][$i];
continue;
}
$matrix[$i][$j] = $i === $j ? 0 : $this->haversineMeters($points[$i], $points[$j]);
}
}
return $matrix;
}
public function optimizeOrder(?RoutePoint $start, array $points): array
{
if (count($points) < 2) {
return array_values($points);
}
// Sans depart explicite, le 1er point est le depart : il reste en tete et
// sert de point de reference initial pour reordonner les suivants.
if (null === $start) {
$remaining = array_values($points);
$first = array_shift($remaining);
$ordered = [$first];
$current = $first;
} else {
$remaining = array_values($points);
$ordered = [];
$current = $start;
}
// Plus proche voisin glouton : a chaque pas, on rattache le point restant
// le plus proche du dernier point retenu.
while ([] !== $remaining) {
$nearestIndex = 0;
$nearestDistance = $this->haversineMeters($current, $remaining[0]);
foreach ($remaining as $index => $candidate) {
$distance = $this->haversineMeters($current, $candidate);
if ($distance < $nearestDistance) {
$nearestDistance = $distance;
$nearestIndex = $index;
}
}
$current = $remaining[$nearestIndex];
$ordered[] = $current;
array_splice($remaining, $nearestIndex, 1);
}
return $ordered;
}
public function estimateLegDurations(?RoutePoint $start, array $points): array
{
$legs = [];
$previous = $start;
foreach ($points as $point) {
// 1er point sans depart explicite : aucun trajet a parcourir.
if (null === $previous) {
$legs[] = new RouteLeg(0, 0);
$previous = $point;
continue;
}
$distance = $this->haversineMeters($previous, $point);
$legs[] = new RouteLeg($distance, $this->metersToSeconds($distance));
$previous = $point;
}
return $legs;
}
/**
* Distance de Haversine entre deux points, arrondie au metre.
*/
private function haversineMeters(RoutePoint $from, RoutePoint $to): int
{
$lat1 = deg2rad($from->latitude);
$lat2 = deg2rad($to->latitude);
$dLat = $lat2 - $lat1;
$dLng = deg2rad($to->longitude - $from->longitude);
$a = sin($dLat / 2) ** 2
+ cos($lat1) * cos($lat2) * sin($dLng / 2) ** 2;
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
return (int) round(self::EARTH_RADIUS_M * $c);
}
/**
* Convertit une distance (metres) en duree (secondes) a la vitesse moyenne.
* Garde-fou : une vitesse nulle/negative donnerait une duree infinie -> 0.
*/
private function metersToSeconds(int $distanceMeters): int
{
if ($this->averageSpeedKmh <= 0.0) {
return 0;
}
$metersPerSecond = $this->averageSpeedKmh * 1000.0 / 3600.0;
return (int) round($distanceMeters / $metersPerSecond);
}
}
@@ -28,11 +28,11 @@ final class TierAddressResolver
* (supplier_address.supplier_id). Les identifiants sont des constantes
* statiques (jamais d'entree utilisateur) -> pas de risque d'injection.
*
* @var array<string, array{table: string, ownerColumn: string}>
* @var array<string, array{table: string, ownerColumn: string, tierTable: string}>
*/
private const array ADDRESS_TABLES = [
'client' => ['table' => 'client_address', 'ownerColumn' => 'client_id'],
'supplier' => ['table' => 'supplier_address', 'ownerColumn' => 'supplier_id'],
'client' => ['table' => 'client_address', 'ownerColumn' => 'client_id', 'tierTable' => 'client'],
'supplier' => ['table' => 'supplier_address', 'ownerColumn' => 'supplier_id', 'tierTable' => 'supplier'],
];
public function __construct(private readonly Connection $connection) {}
@@ -73,4 +73,71 @@ final class TierAddressResolver
{
return isset(self::ADDRESS_TABLES[$tierType]);
}
/**
* Coordonnees (lat/lng) d'une adresse de Tiers referentiel, posees au
* geocodage (ERP-122). Retourne null si le type n'est pas resoluble, si
* l'adresse n'existe pas, ou si elle n'est pas encore geolocalisee (une etape
* sans coordonnees est exclue du calcul de trajet — RG-6.05).
*
* @return null|array{lat: float, lng: float}
*/
public function findAddressCoordinates(string $tierType, int $addressId): ?array
{
$mapping = self::ADDRESS_TABLES[$tierType] ?? null;
if (null === $mapping) {
return null;
}
$sql = sprintf(
'SELECT latitude, longitude FROM %s WHERE id = :addressId',
$mapping['table'],
);
$row = $this->connection->fetchAssociative($sql, ['addressId' => $addressId]);
if (false === $row || null === $row['latitude'] || null === $row['longitude']) {
return null;
}
return ['lat' => (float) $row['latitude'], 'lng' => (float) $row['longitude']];
}
/**
* Donnees d'affichage d'une etape sur Tiers referentiel pour la feuille de
* route PDF (M6 § 5) : nom du Tiers + composantes de l'adresse. Retourne null
* si le type n'est pas resoluble ou si l'adresse n'existe pas (le point libre
* `custom` porte ses propres libelle/adresse sur l'etape).
*
* @return null|array{tierName: string, street: ?string, streetComplement: ?string, postalCode: ?string, city: ?string}
*/
public function findStopLocation(string $tierType, int $addressId): ?array
{
$mapping = self::ADDRESS_TABLES[$tierType] ?? null;
if (null === $mapping) {
return null;
}
// Noms de table/colonne issus de la whitelist de constantes (jamais de
// l'entree utilisateur) ; seul l'id est parametre.
$sql = sprintf(
'SELECT t.company_name AS tier_name, a.street, a.street_complement, a.postal_code, a.city '
.'FROM %s a JOIN %s t ON t.id = a.%s WHERE a.id = :addressId',
$mapping['table'],
$mapping['tierTable'],
$mapping['ownerColumn'],
);
$row = $this->connection->fetchAssociative($sql, ['addressId' => $addressId]);
if (false === $row) {
return null;
}
return [
'tierName' => (string) $row['tier_name'],
'street' => null !== $row['street'] ? (string) $row['street'] : null,
'streetComplement' => null !== $row['street_complement'] ? (string) $row['street_complement'] : null,
'postalCode' => null !== $row['postal_code'] ? (string) $row['postal_code'] : null,
'city' => null !== $row['city'] ? (string) $row['city'] : null,
];
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat de rendu d'un document HTML en PDF binaire.
*
* Service GENERIQUE et reutilisable : il ne connait aucune entite metier. Le
* module appelant decide QUOI mettre dans le document (HTML deja rendu, ex: via
* Twig) ; cette interface decrit seulement COMMENT produire le binaire PDF. On
* depend de ce contrat (dans Shared), jamais de l'implementation concrete (regle
* ABSOLUE n°1).
*
* Implementee par App\Shared\Infrastructure\Pdf\DompdfRenderer (non referencee
* via @see pour ne pas creer d'import Domain -> Infra).
*/
interface PdfRendererInterface
{
/**
* Rend un fragment HTML complet en PDF et retourne son contenu binaire.
*
* @param string $html document HTML (avec ses styles CSS inline / <style>)
* @param string $paperSize format papier (ex: 'A4', 'Letter')
* @param string $orientation 'portrait' ou 'landscape'
*
* @return string contenu binaire du fichier PDF
*/
public function renderHtml(string $html, string $paperSize = 'A4', string $orientation = 'portrait'): string;
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Pdf;
use App\Shared\Domain\Contract\PdfRendererInterface;
use Dompdf\Dompdf;
use Dompdf\Options;
/**
* Implementation du rendu PDF via Dompdf (standard MALIO, cf. Ferme).
*
* Securite : l'acces aux ressources distantes est DESACTIVE (isRemoteEnabled =
* false) — un PDF de feuille de route ne charge aucune URL externe, ce qui ferme
* la porte aux SSRF via du HTML/CSS injecte. Les polices systeme suffisent.
*/
final class DompdfRenderer implements PdfRendererInterface
{
public function renderHtml(string $html, string $paperSize = 'A4', string $orientation = 'portrait'): string
{
$options = new Options();
$options->set('isRemoteEnabled', false);
$options->set('defaultFont', 'DejaVu Sans'); // gere correctement l'UTF-8 / accents FR
$dompdf = new Dompdf($options);
$dompdf->loadHtml($html, 'UTF-8');
$dompdf->setPaper($paperSize, $orientation);
$dompdf->render();
return (string) $dompdf->output();
}
}
+88
View File
@@ -0,0 +1,88 @@
{# Feuille de route PDF d'une tournee (M6.4). Template autonome (Dompdf) : styles
inline / <style>, pas d'heritage de base.html.twig ni de ressource distante. #}
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<style>
@page { margin: 24px 28px; }
* { font-family: "DejaVu Sans", sans-serif; }
body { color: #1f2937; font-size: 11px; margin: 0; }
.header { border-bottom: 2px solid #111827; padding-bottom: 10px; margin-bottom: 14px; }
.header h1 { font-size: 18px; margin: 0 0 6px; color: #111827; }
.meta { width: 100%; }
.meta td { font-size: 11px; padding: 1px 0; vertical-align: top; }
.meta .label { color: #6b7280; width: 90px; }
.totals { margin: 0 0 14px; }
.totals td { background: #f3f4f6; border: 1px solid #e5e7eb; padding: 6px 10px; font-size: 11px; }
.totals .value { font-size: 14px; font-weight: bold; color: #111827; }
table.stops { width: 100%; border-collapse: collapse; }
table.stops th { background: #111827; color: #fff; font-size: 10px; text-align: left; padding: 6px 7px; }
table.stops td { border-bottom: 1px solid #e5e7eb; padding: 6px 7px; font-size: 10px; vertical-align: top; }
table.stops tr:nth-child(even) td { background: #f9fafb; }
.num { text-align: center; font-weight: bold; width: 22px; }
.nowrap { white-space: nowrap; }
.muted { color: #6b7280; }
.notes { width: 130px; }
.notes-box { border: 1px dashed #9ca3af; height: 26px; }
.footer { margin-top: 16px; font-size: 9px; color: #9ca3af; text-align: center; }
</style>
</head>
<body>
<div class="header">
<h1>Feuille de route — {{ tour.label }}</h1>
<table class="meta">
<tr>
<td class="label">Date</td><td>{{ tour.date }}</td>
<td class="label">Commercial</td><td>{{ tour.commercial }}</td>
</tr>
<tr>
<td class="label">Départ</td><td>{{ tour.departureTime }}{% if tour.startLabel %}{{ tour.startLabel }}{% endif %}</td>
<td class="label">Étapes</td><td>{{ tour.stopCount }}</td>
</tr>
</table>
</div>
<table class="totals">
<tr>
<td>Distance totale<br><span class="value">{{ tour.totalDistance }}</span></td>
<td>Durée totale<br><span class="value">{{ tour.totalDuration }}</span></td>
</tr>
</table>
<table class="stops">
<thead>
<tr>
<th class="num">#</th>
<th class="nowrap">ETA</th>
<th class="nowrap">Visite</th>
<th>Tiers / Point</th>
<th>Adresse</th>
<th class="nowrap">Trajet précédent</th>
<th class="notes">Notes</th>
</tr>
</thead>
<tbody>
{% for stop in stops %}
<tr>
<td class="num">{{ stop.number }}</td>
<td class="nowrap">{{ stop.eta }}</td>
<td class="nowrap">{{ stop.visitMinutes }} min</td>
<td>{{ stop.name }}</td>
<td>{{ stop.address|default('—') }}</td>
<td class="nowrap muted">{{ stop.legDuration }} · {{ stop.legDistance }}</td>
<td class="notes"><div class="notes-box"></div></td>
</tr>
{% else %}
<tr><td colspan="7" class="muted">Aucune étape dans cette tournée.</td></tr>
{% endfor %}
</tbody>
</table>
<div class="footer">Feuille de route générée le {{ "now"|date("d/m/Y") }} — Starseed</div>
</body>
</html>
@@ -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);
}
}