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)
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
+40
@@ -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;
|
||||
}
|
||||
}
|
||||
+88
@@ -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);
|
||||
}
|
||||
}
|
||||
+39
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user