From f8f7571cc0e599558a63a19473482b819d02e00c Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 11 Jun 2026 16:46:49 +0200 Subject: [PATCH] feat(field_sales) : calcul de trajet, optimisation, duplication & roadbook PDF (ERP-125) - 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) --- composer.json | 1 + composer.lock | 447 +++++++++++++++++- config/services.yaml | 11 + .../Duplication/TourDuplicator.php | 68 +++ .../Application/Route/TourRouteCalculator.php | 250 ++++++++++ src/Module/FieldSales/Domain/Entity/Tour.php | 42 ++ .../Domain/Route/RouteEngineInterface.php | 61 +++ .../FieldSales/Domain/Route/RouteLeg.php | 20 + .../FieldSales/Domain/Route/RoutePoint.php | 22 + .../State/Processor/TourComputeProcessor.php | 40 ++ .../Processor/TourDuplicateProcessor.php | 88 ++++ .../State/Processor/TourOptimizeProcessor.php | 39 ++ .../Controller/TourRoadbookController.php | 217 +++++++++ .../Route/HaversineRouteEngine.php | 149 ++++++ .../Tier/TierAddressResolver.php | 73 ++- .../Domain/Contract/PdfRendererInterface.php | 31 ++ .../Infrastructure/Pdf/DompdfRenderer.php | 33 ++ templates/field_sales/roadbook.html.twig | 88 ++++ .../FieldSales/Api/TourRoadbookApiTest.php | 101 ++++ .../FieldSales/Api/TourRouteApiTest.php | 227 +++++++++ .../Domain/Route/HaversineRouteEngineTest.php | 116 +++++ 21 files changed, 2120 insertions(+), 4 deletions(-) create mode 100644 src/Module/FieldSales/Application/Duplication/TourDuplicator.php create mode 100644 src/Module/FieldSales/Application/Route/TourRouteCalculator.php create mode 100644 src/Module/FieldSales/Domain/Route/RouteEngineInterface.php create mode 100644 src/Module/FieldSales/Domain/Route/RouteLeg.php create mode 100644 src/Module/FieldSales/Domain/Route/RoutePoint.php create mode 100644 src/Module/FieldSales/Infrastructure/ApiPlatform/State/Processor/TourComputeProcessor.php create mode 100644 src/Module/FieldSales/Infrastructure/ApiPlatform/State/Processor/TourDuplicateProcessor.php create mode 100644 src/Module/FieldSales/Infrastructure/ApiPlatform/State/Processor/TourOptimizeProcessor.php create mode 100644 src/Module/FieldSales/Infrastructure/Controller/TourRoadbookController.php create mode 100644 src/Module/FieldSales/Infrastructure/Route/HaversineRouteEngine.php create mode 100644 src/Shared/Domain/Contract/PdfRendererInterface.php create mode 100644 src/Shared/Infrastructure/Pdf/DompdfRenderer.php create mode 100644 templates/field_sales/roadbook.html.twig create mode 100644 tests/Module/FieldSales/Api/TourRoadbookApiTest.php create mode 100644 tests/Module/FieldSales/Api/TourRouteApiTest.php create mode 100644 tests/Module/FieldSales/Domain/Route/HaversineRouteEngineTest.php diff --git a/composer.json b/composer.json index 0e5eb29..bf7a5cf 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index f5fba04..9f556bc 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/services.yaml b/config/services.yaml index 012de5a..3bc3a3a 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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: diff --git a/src/Module/FieldSales/Application/Duplication/TourDuplicator.php b/src/Module/FieldSales/Application/Duplication/TourDuplicator.php new file mode 100644 index 0000000..b10ce80 --- /dev/null +++ b/src/Module/FieldSales/Application/Duplication/TourDuplicator.php @@ -0,0 +1,68 @@ +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; + } +} diff --git a/src/Module/FieldSales/Application/Route/TourRouteCalculator.php b/src/Module/FieldSales/Application/Route/TourRouteCalculator.php new file mode 100644 index 0000000..692abc5 --- /dev/null +++ b/src/Module/FieldSales/Application/Route/TourRouteCalculator.php @@ -0,0 +1,250 @@ + 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 $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 + */ + 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); + } +} diff --git a/src/Module/FieldSales/Domain/Entity/Tour.php b/src/Module/FieldSales/Domain/Entity/Tour.php index 23244c0..1621bd8 100644 --- a/src/Module/FieldSales/Domain/Entity/Tour.php +++ b/src/Module/FieldSales/Domain/Entity/Tour.php @@ -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)] diff --git a/src/Module/FieldSales/Domain/Route/RouteEngineInterface.php b/src/Module/FieldSales/Domain/Route/RouteEngineInterface.php new file mode 100644 index 0000000..dcf1f71 --- /dev/null +++ b/src/Module/FieldSales/Domain/Route/RouteEngineInterface.php @@ -0,0 +1,61 @@ + $points + * + * @return array> + */ + 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 $points + * + * @return list 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 $points points DEJA ordonnes + * + * @return list + */ + public function estimateLegDurations(?RoutePoint $start, array $points): array; +} diff --git a/src/Module/FieldSales/Domain/Route/RouteLeg.php b/src/Module/FieldSales/Domain/Route/RouteLeg.php new file mode 100644 index 0000000..5004c18 --- /dev/null +++ b/src/Module/FieldSales/Domain/Route/RouteLeg.php @@ -0,0 +1,20 @@ + + */ +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; + } +} diff --git a/src/Module/FieldSales/Infrastructure/ApiPlatform/State/Processor/TourDuplicateProcessor.php b/src/Module/FieldSales/Infrastructure/ApiPlatform/State/Processor/TourDuplicateProcessor.php new file mode 100644 index 0000000..c4c7848 --- /dev/null +++ b/src/Module/FieldSales/Infrastructure/ApiPlatform/State/Processor/TourDuplicateProcessor.php @@ -0,0 +1,88 @@ + + */ +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); + } +} diff --git a/src/Module/FieldSales/Infrastructure/ApiPlatform/State/Processor/TourOptimizeProcessor.php b/src/Module/FieldSales/Infrastructure/ApiPlatform/State/Processor/TourOptimizeProcessor.php new file mode 100644 index 0000000..eb78df6 --- /dev/null +++ b/src/Module/FieldSales/Infrastructure/ApiPlatform/State/Processor/TourOptimizeProcessor.php @@ -0,0 +1,39 @@ + + */ +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; + } +} diff --git a/src/Module/FieldSales/Infrastructure/Controller/TourRoadbookController.php b/src/Module/FieldSales/Infrastructure/Controller/TourRoadbookController.php new file mode 100644 index 0000000..5f15431 --- /dev/null +++ b/src/Module/FieldSales/Infrastructure/Controller/TourRoadbookController.php @@ -0,0 +1,217 @@ + 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 + */ + 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> + */ + 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; + } +} diff --git a/src/Module/FieldSales/Infrastructure/Route/HaversineRouteEngine.php b/src/Module/FieldSales/Infrastructure/Route/HaversineRouteEngine.php new file mode 100644 index 0000000..f77081a --- /dev/null +++ b/src/Module/FieldSales/Infrastructure/Route/HaversineRouteEngine.php @@ -0,0 +1,149 @@ +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); + } +} diff --git a/src/Module/FieldSales/Infrastructure/Tier/TierAddressResolver.php b/src/Module/FieldSales/Infrastructure/Tier/TierAddressResolver.php index 0ad16d2..b4a61a2 100644 --- a/src/Module/FieldSales/Infrastructure/Tier/TierAddressResolver.php +++ b/src/Module/FieldSales/Infrastructure/Tier/TierAddressResolver.php @@ -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 + * @var array */ 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, + ]; + } } diff --git a/src/Shared/Domain/Contract/PdfRendererInterface.php b/src/Shared/Domain/Contract/PdfRendererInterface.php new file mode 100644 index 0000000..22321eb --- /dev/null +++ b/src/Shared/Domain/Contract/PdfRendererInterface.php @@ -0,0 +1,31 @@ + 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 / + + +
+

Feuille de route — {{ tour.label }}

+ + + + + + + + + +
Date{{ tour.date }}Commercial{{ tour.commercial }}
Départ{{ tour.departureTime }}{% if tour.startLabel %} — {{ tour.startLabel }}{% endif %}Étapes{{ tour.stopCount }}
+
+ + + + + + +
Distance totale
{{ tour.totalDistance }}
Durée totale
{{ tour.totalDuration }}
+ + + + + + + + + + + + + + + {% for stop in stops %} + + + + + + + + + + {% else %} + + {% endfor %} + +
#ETAVisiteTiers / PointAdresseTrajet précédentNotes
{{ stop.number }}{{ stop.eta }}{{ stop.visitMinutes }} min{{ stop.name }}{{ stop.address|default('—') }}{{ stop.legDuration }} · {{ stop.legDistance }}
Aucune étape dans cette tournée.
+ + + + diff --git a/tests/Module/FieldSales/Api/TourRoadbookApiTest.php b/tests/Module/FieldSales/Api/TourRoadbookApiTest.php new file mode 100644 index 0000000..c748e2f --- /dev/null +++ b/tests/Module/FieldSales/Api/TourRoadbookApiTest.php @@ -0,0 +1,101 @@ +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; + } +} diff --git a/tests/Module/FieldSales/Api/TourRouteApiTest.php b/tests/Module/FieldSales/Api/TourRouteApiTest.php new file mode 100644 index 0000000..c35da7d --- /dev/null +++ b/tests/Module/FieldSales/Api/TourRouteApiTest.php @@ -0,0 +1,227 @@ +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 $body + * + * @return list> + */ + 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); + } +} diff --git a/tests/Module/FieldSales/Domain/Route/HaversineRouteEngineTest.php b/tests/Module/FieldSales/Domain/Route/HaversineRouteEngineTest.php new file mode 100644 index 0000000..ae79566 --- /dev/null +++ b/tests/Module/FieldSales/Domain/Route/HaversineRouteEngineTest.php @@ -0,0 +1,116 @@ +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); + } +}