Compare commits

..

2 Commits

Author SHA1 Message Date
gitea-actions b36520d3b1 chore: bump version to v0.1.110
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 1m17s
2026-06-12 08:45:47 +00:00
tristan a340d8139a feat(commercial) : amélioration et validation stricte des champs date (ERP-148) (#92)
Auto Tag Develop / tag (push) Successful in 8s
## Contexte
ERP-148 — mise à jour @malio/layer-ui et amélioration des champs date (onglet Information, Client & Fournisseur).

## Changements
- **MalioDate v1.7.10** : le composant expose désormais son état de validité (`@update:valid`) et la saisie brute invalide (`@update:rawValue`).
- **Validation back-autoritaire du format** : `foundedAt` n'accepte plus que l'ISO strict `Y-m-d` (`#[Context]` DateTimeNormalizer) + `collectDenormalizationErrors` sur `Client` et `Supplier`. Toute saisie non-ISO renvoie un **422 porté sur le champ**.
  - Corrige un cas piège : `12/25/2026` (invalide en JJ/MM/AAAA côté front) était auparavant accepté par PHP en M/J/AAAA → 25 décembre. Désormais rejeté.
- **Front** : la saisie invalide est transmise au back ; le message technique de type-error est surchargé par une clé i18n via le **code de violation** (`resolveViolationMessage` / `VIOLATION_MESSAGE_I18N`), affiché inline par `useFormErrors`.
- Réorganisation des utils de formulaire sous `utils/forms/`.

## Tests
- Back : `ClientFoundedAtFormatTest` / `SupplierFoundedAtFormatTest` (dont le cas piège `12/25/2026`).
- Front : résolveur i18n (`api.test.ts`, `useFormErrors.test.ts`) + payloads (`clientEdit`/`supplierEdit` specs).
- Suite Commercial verte ; vérifié bout-en-bout en navigateur (PATCH → 422, erreur inline, submit bloqué).

## Note
Échecs JWT aléatoires connus du hook pre-commit (401/500 sur tests d'auth sans rapport) ; tous verts en isolation.

Reviewed-on: #92
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-12 08:45:38 +00:00
119 changed files with 559 additions and 9813 deletions
+1
View File
@@ -79,6 +79,7 @@ Regles :
- **Toujours `{ toast: false }`** sur l'appel API qui veut un mapping inline (sinon le toast natif d'`useApi` masque le fin).
- **Cas metier specifique** (ex: 409 doublon) : `setError('champ', message)` + toast explicite **avant** de deleguer le reste a `handleApiError`. Cf. `useCategoryForm` (doublon RG-1.07).
- **Collections** (listes de sous-entites sauvees par un appel par ligne) : une erreur PAR LIGNE via un tableau `ref<Record<string, string>[]>` aligne sur l'index, peuple par `mapViolationsToRecord(error.response._data)` (util pur de `shared/utils/api.ts`). Le composant de ligne expose une prop `:errors` (`Record<string, string>`) bindee sur le `:error` de chaque champ. Cf. `ClientContactBlock` / `ClientAddressBlock` et les submits de `clients/new.vue` / `clients/[id]/edit.vue`.
- **Message back technique → surcharge i18n par code** : la plupart des contraintes back portent un message FR explicite (affiche tel quel). Mais une 422 peut porter un message TECHNIQUE non montrable (ex. erreur de type API Platform sur une date non parsable : « Cette valeur doit être de type DateTimeImmutable|null. », voire en anglais selon la negociation). On le surcharge **cote front** via le `code` de violation (UUID Symfony fige, robuste — pas un match sur le texte) : table `VIOLATION_MESSAGE_I18N` + `resolveViolationMessage` dans `shared/utils/api.ts`, appliquee par `useFormErrors`. Ajouter un cas = une entree `code -> cle i18n`. Cas reference : date invalide (MalioDate forwarde la saisie brute via `@update:rawValue`, le back renvoie 422 sur `foundedAt` grace a `collectDenormalizationErrors`, le front affiche `errors.validation.invalidDate`).
**Interdit** : se contenter d'un toast global sur une 422 quand le back identifie les champs fautifs (`propertyPath`). Reimplementer un mapping `if/else` par champ a la main au lieu d'`useFormErrors` / `mapViolationsToRecord`.
+2 -3
View File
@@ -12,7 +12,6 @@
"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",
@@ -25,7 +24,6 @@
"symfony/expression-language": "8.0.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*",
"symfony/intl": "8.0.*",
"symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0",
@@ -97,6 +95,7 @@
"doctrine/doctrine-fixtures-bundle": "^4.3",
"friendsofphp/php-cs-fixer": "^3.94",
"phpunit/phpunit": "^13.0",
"symfony/browser-kit": "8.0.*"
"symfony/browser-kit": "8.0.*",
"symfony/http-client": "8.0.*"
}
}
Generated
+175 -620
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "b9a204bab17aa0371f8419362f3bee0c",
"content-hash": "2dc5db01e7f5d6aecd5956749b21a092",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -2520,161 +2520,6 @@
},
"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",
@@ -3049,73 +2894,6 @@
},
"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",
@@ -4159,86 +3937,6 @@
},
"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",
@@ -5714,180 +5412,6 @@
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/http-client",
"version": "v8.0.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "c7f40f9103233630167c25c9a4570acf805fdade"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/c7f40f9103233630167c25c9a4570acf805fdade",
"reference": "c7f40f9103233630167c25c9a4570acf805fdade",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/log": "^1|^2|^3",
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"amphp/amp": "<3",
"php-http/discovery": "<1.15"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
},
"require-dev": {
"amphp/http-client": "^5.3.2",
"amphp/http-tunnel": "^2.0",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/cache": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/rate-limiter": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"homepage": "https://symfony.com",
"keywords": [
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v8.0.13"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-24T09:58:02+00:00"
},
{
"name": "symfony/http-client-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to HTTP clients",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-04-29T11:18:49+00:00"
},
{
"name": "symfony/http-foundation",
"version": "v8.0.8",
@@ -9081,149 +8605,6 @@
],
"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",
@@ -12404,6 +11785,180 @@
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/http-client",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e",
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/log": "^1|^2|^3",
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"amphp/amp": "<3",
"php-http/discovery": "<1.15"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
},
"require-dev": {
"amphp/http-client": "^5.3.2",
"amphp/http-tunnel": "^2.0",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/cache": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/rate-limiter": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"homepage": "https://symfony.com",
"keywords": [
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/http-client-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to HTTP clients",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-04-29T11:18:49+00:00"
},
{
"name": "symfony/process",
"version": "v8.0.8",
-2
View File
@@ -4,7 +4,6 @@ declare(strict_types=1);
use App\Module\Catalog\CatalogModule;
use App\Module\Commercial\CommercialModule;
use App\Module\Core\CoreModule;
use App\Module\FieldSales\FieldSalesModule;
use App\Module\Sites\SitesModule;
return [
@@ -12,5 +11,4 @@ return [
CommercialModule::class,
SitesModule::class,
CatalogModule::class,
FieldSalesModule::class,
];
-5
View File
@@ -12,11 +12,6 @@ api_platform:
# Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource]
# en dehors de Domain/Entity : AuditLogResource, etc.
- '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource'
# Module FieldSales (M6) : entites ApiResource Tour / TourStop.
- '%kernel.project_dir%/src/Module/FieldSales/Domain/Entity'
# Module FieldSales (M6) : resources virtuelles sans entite Doctrine
# (VisitableTierResource — pins de la carte, lecture DBAL).
- '%kernel.project_dir%/src/Module/FieldSales/Infrastructure/ApiPlatform/Resource'
formats:
jsonld: ['application/ld+json']
json: ['application/json']
-17
View File
@@ -41,13 +41,6 @@ doctrine:
# Permet au module Commercial de referencer une Category via le contrat
# Shared sans importer la classe concrete du module Catalog (regle n°1).
App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category
# NOTE (M6 / VisitableInterface) : VisitableInterface n'apparait PAS ici.
# resolve_target_entities mappe un contrat -> UNE seule classe concrete,
# or ce contrat a plusieurs implementations (Client M1, Supplier M2, et
# Prestataire a venir). FieldSales ne reference donc pas un Tiers via une
# association Doctrine mais via le couple polymorphe (tier_type, tier_id)
# de tour_stop, resolu par un service a partir de getVisitableType()
# (ERP-124). Aucune ligne resolve_target_entities n'est requise/possible.
mappings:
Core:
type: attribute
@@ -87,16 +80,6 @@ doctrine:
dir: '%kernel.project_dir%/src/Module/Commercial/Domain/Entity'
prefix: 'App\Module\Commercial\Domain\Entity'
alias: Commercial
# Mapping inconditionnel du module FieldSales (M6 — meme logique que
# Commercial) : les tables tour / tour_stop creees par la migration
# M6.3 (Version20260611140000) doivent etre connues de l'ORM.
# L'activation fonctionnelle passe par config/modules.php.
FieldSales:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/FieldSales/Domain/Entity'
prefix: 'App\Module\FieldSales\Domain\Entity'
alias: FieldSales
controller_resolver:
auto_mapping: false
-24
View File
@@ -1,8 +1,6 @@
# 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 }
@@ -35,25 +33,3 @@ services:
App\Module\Sites\Application\Service\CurrentSiteProviderInterface:
alias: App\Module\Sites\Application\Service\CurrentSiteProvider
# Geocodage des adresses Tiers (M6.1) : BAN api-adresse.data.gouv.fr.
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:
services:
App\Tests\Fixtures\Geocoding\InMemoryGeocoder: ~
App\Shared\Domain\Contract\GeocoderInterface:
alias: App\Tests\Fixtures\Geocoding\InMemoryGeocoder
-17
View File
@@ -61,23 +61,6 @@ return [
],
],
],
// Section "Tournées" (module field_sales, M6) : planification de tournees
// commerciales terrain. Transverse Clients/Fournisseurs. Masquee si le module
// field_sales est desactivee (cle `module`) ou si l'user n'a pas la
// permission field_sales.tours.view.
[
'label' => 'sidebar.field_sales.section',
'icon' => 'mdi:map-marker-path',
'items' => [
[
'label' => 'sidebar.field_sales.tours',
'to' => '/tours',
'icon' => 'mdi:map-marker-path',
'module' => 'field_sales',
'permission' => 'field_sales.tours.view',
],
],
],
// Section "Administration" : regroupe toutes les pages de configuration
// applicative (RBAC, users, sites, audit log).
//
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.109'
app.version: '0.1.110'
-322
View File
@@ -1,322 +0,0 @@
---
# === IDENTITÉ ===
module: M6
nom: "Tournées commerciales terrain"
ecran: tournees-terrain
owner_spec: Matthieu
backup_spec: ""
version: V0.2
# Historique :
# V0.2 (2026-06-11) — RÉDUCTION DE SCOPE : suppression du volet « rapport de visite »
# (entité VisitReport, fichiers, offres de prix, note /5, saisie vocale, historique des
# visites) et du mode terrain mobile dédié. Périmètre recentré sur : géolocalisation,
# carte interactive, planification de tournées, et onglet « Carte » dans les fiches Tiers.
# V0.1 (2026-06-11) — Rédaction initiale (inspirée de Badger Maps, SPOTIO, Portatour, Nomadia).
date_redaction: 2026-06-11
# === LIENS ===
spec_front: ./spec-front.md
maquette_figma: ""
# === LIEN LESSTIME ===
lesstime_taskgroup_id: 28 # M6 — Tournées commerciales terrain (projet STARSEED #6)
lesstime_project_id: 6
statut_global: en_dev
# === DÉPENDANCES AMONT ===
depend_de:
- M1-clients # Client / ClientAddress (cible de visite + onglet Carte)
- M2-suppliers # Supplier / SupplierAddress (cible de visite + onglet Carte)
- Sites # rattachement site d'une adresse (déjà en place)
- Core # User (commercial), Role, Permission, JWT
- Shared # TimestampableBlamableTrait + contrats inter-modules
---
# Spec — Module 6 : Tournées commerciales terrain (`field_sales`)
> **Périmètre V0.2 (réduit)** : géolocalisation des adresses Tiers, carte interactive, planification
> de tournées (étapes, optimisation, navigation Waze/Maps, feuille de route PDF) et onglet « Carte »
> dans les fiches Client/Fournisseur. **Hors scope : tout rapport de visite** (compte-rendu, note,
> offres de prix, fichiers, saisie vocale) et le mode terrain mobile dédié.
## 1. Contexte & objectif
Donner aux commerciaux terrain (technico-commerciaux agricoles : visites d'exploitations, coopératives,
négoces) un outil de **planification de tournées** intégré à Starseed, reposant sur le référentiel Tiers
existant (Clients M1 + Fournisseurs M2). Fonctionne sur **desktop et mobile/tablette** (responsive, pas
d'offline en V1).
Le commercial doit pouvoir :
1. Voir ses Tiers sur une **carte interactive** (pins colorés par type : client / fournisseur / prospect / custom).
2. **Construire une tournée lui-même** : ajouter des étapes (une étape = une adresse précise d'un Tiers ou un
point libre), les **réordonner en drag & drop**, fixer une **heure de départ**.
3. Obtenir le **temps total** et le **temps entre chaque étape** (calcul auto), avec heure d'arrivée estimée.
4. Cliquer **« Trajet logique »** (V1, heuristique gratuite) puis **« Optimiser »** (V2, routier réel) pour
ordonner les étapes au mieux.
5. **Lancer la navigation** (Waze / Google Maps / Plan) vers une étape en un tap.
6. **Dupliquer** une tournée et **exporter une feuille de route PDF**.
7. Consulter un **onglet « Carte »** dans la fiche Client/Fournisseur affichant les adresses géolocalisées du Tiers.
## 2. Inspiration — logiciels de tournée de référence
| Logiciel | Pattern repris | Application Starseed |
|---|---|---|
| **Badger Maps** | *Lasso tool* : on entoure des Tiers sur la carte → route optimisée auto. Pins colorés par type. | Sélection lasso/rectangle sur la carte pour bâtir la tournée. Pins par type. |
| **SPOTIO** | Réordonnancement **drag & drop**, lieu de départ + bouton *Optimize*. | Liste d'étapes draggable, point de départ paramétrable, boutons « Trajet logique » / « Optimiser ». |
| **Portatour** | Optimisation en quelques secondes, temps entre RDV. | Durée de visite paramétrable par étape, intégrée au temps total. |
| **Nomadia Field Sales** | Carte + tournée dans un outil mobile responsive. | Écran de planification responsive desktop + mobile. |
## 3. Décisions d'architecture
### 3.1 Nouveau module `field_sales`
La tournée est **transverse** : elle vise aussi bien des Clients (M1) que des Fournisseurs (M2). Module dédié
`src/Module/FieldSales/` (ID `field_sales`, label « Tournées »), `REQUIRED = false` (activable via
`config/modules.php`).
**Règle ABSOLUE n°1 respectée** : `FieldSales` n'importe **aucune** classe de `Commercial`. Il référence les
Tiers visités via un **contrat partagé** `App\Shared\Domain\Contract\VisitableInterface` (`getId()`,
`getDisplayName()`, `getVisitableType()` = `client|supplier`) résolu par `resolve_target_entities`, comme
`ClientAddress` référence `SiteInterface` / `CategoryInterface`.
### 3.1.bis Une étape vise tout Tiers — et même un point libre
Une étape n'est pas limitée à Client/Fournisseur : elle vise **tout type de Tiers** (Client, Fournisseur,
**Prestataire** à venir) via `VisitableInterface` (extensible sans toucher au module FieldSales), **ou un point
`custom`** (prospect/RDV sans fiche : libellé + adresse + coordonnées saisis à la main). L'enum `tier_type` est
volontairement **ouvert** (string + Assert\Choice = types Visitable enregistrés + `custom`).
### 3.2 Géolocalisation portée par l'adresse Tiers — **FAIT (ticket M6.1 / ERP-122)**
`latitude` / `longitude` / `geo_manual` / `geocoded_at` sur `client_address` et `supplier_address`, géocodage
api-adresse.data.gouv.fr + pin ajustable. Prérequis routage : une étape sans coordonnées reste utilisable mais
**exclue du calcul de trajet** (badge « à géolocaliser »).
### 3.3 Carte interactive — Leaflet + OpenStreetMap (pas Google Maps JS)
Pour l'**affichage** carte/pins : **Leaflet** + tuiles **OpenStreetMap** (ou IGN). Gratuit, RGPD-friendly, pas
de clé facturée pour le rendu. Composant carte encapsulé dans `frontend/modules/field-sales/` ; côté
formulaire/filtre on reste sur les composants `Malio*`. La carte est une **exception documentée** à
`@malio/layer-ui` (type non couvert). Le **routing réel** (matrice de temps) est un service distinct (§ 3.4).
### 3.4 Stratégie de calcul de trajet — phasée
| Phase | Bouton | Moteur | Coût |
|---|---|---|---|
| **V1** | « Trajet logique » | Heuristique maison **plus proche voisin** (Haversine), départ fixé. Temps estimé = distance × vitesse moyenne paramétrable. | 0 € |
| **V2** | « Optimiser » | **Matrix API** (temps routiers réels) + optimisation TSP (OpenRouteService / OSRM / Mapbox). | Par appel (cache, debounce) |
Contrat `RouteEngineInterface` (`computeMatrix`, `optimizeOrder`, `estimateLegDurations`) posé dès la V1 avec
`HaversineRouteEngine`. La V2 ajoute `OrsRouteEngine` sans toucher au front. **On n'écrit jamais l'algo routier
— on branche un fournisseur.**
### 3.5 IDs entier auto-increment, Audit, Timestampable/Blamable
Cohérent avec M0/M1/M2. Toutes les entités métier : `#[Auditable]`, `implements TimestampableInterface,
BlamableInterface` + `use TimestampableBlamableTrait`. Entités auditées : `Tour`, `TourStop`.
## 4. Modèle de données
### 4.1 Adresses Tiers (M1 + M2) — **FAIT (ERP-122)**
Colonnes `latitude` NUMERIC(10,7), `longitude` NUMERIC(10,7), `geo_manual` BOOLEAN, `geocoded_at` TIMESTAMPTZ
sur `client_address` et `supplier_address` ; contrat `GeolocatableAddressInterface` côté `Shared`.
### 4.2 `Tour` (tournée) — `tour`
| Champ | Type | Règle |
|---|---|---|
| `id` | int PK | |
| `owner_id` | FK User | Commercial propriétaire. Tournée **personnelle** (RG-6.01). |
| `label` | varchar(120) | Nom libre. NotBlank. |
| `tour_date` | date | Date de réalisation. NotBlank. |
| `departure_time` | time | Heure de départ (alimente les ETA). Défaut 08:00. |
| `start_latitude` / `start_longitude` | numeric null | Point de départ (site commercial ou adresse libre). NULL → départ = 1re étape. |
| `start_label` | varchar(180) null | Libellé du point de départ. |
| `default_visit_minutes` | smallint default 30 | Durée de visite par défaut (temps total). |
| `status` | enum `draft\|planned\|in_progress\|done` | Cycle de vie (RG-6.02). |
| `total_distance_m` / `total_duration_s` | int null | Derniers totaux calculés (cache d'affichage). |
`#[Auditable]`, Timestampable/Blamable, soft delete (`deleted_at`). `GetCollection` paginée, filtrée par owner.
### 4.3 `TourStop` (étape) — `tour_stop`
| Champ | Type | Règle |
|---|---|---|
| `id` | int PK | |
| `tour_id` | FK Tour | onDelete CASCADE. |
| `tier_type` | string `client\|supplier\|…\|custom` | Cible (résolue via `VisitableInterface`). `custom` = point libre. |
| `tier_id` | int null | ID du Tiers référentiel. NULL si `custom`. |
| `address_id` | int null | Adresse précise visitée (un Tiers a plusieurs adresses — RG-6.03). NULL si `custom`. |
| `custom_label` | varchar(180) null | Libellé du point libre (obligatoire ssi `custom`). |
| `custom_address` | varchar(255) null | Adresse texte du point libre (ssi `custom`), géocodée. |
| `custom_latitude` / `custom_longitude` | numeric null | Coordonnées du point libre (pin ajustable). |
| `position` | smallint | Ordre dans la tournée (drag & drop). |
| `visit_minutes` | smallint null | Durée de visite spécifique (sinon `tour.default_visit_minutes`). |
| `leg_distance_m` / `leg_duration_s` | int null | Distance/temps **depuis l'étape précédente** (calculés). |
| `eta` | time null | Heure d'arrivée estimée. |
`#[Auditable]`, Timestampable/Blamable. Unicité `(tour_id, position)`. **Pas** de rapport rattaché (scope réduit).
> Deux étapes peuvent viser le même Tiers (RG-6.07) — pas d'unicité sur `tier_id`.
## 5. API (API Platform — providers/processors, jamais de controller)
| Méthode | Endpoint | Sécurité | Note |
|---|---|---|---|
| GET | `/api/tours` | `field_sales.tours.view` | Paginé, filtré sur `owner` courant (admin/bureau voient tout). |
| POST | `/api/tours` | `field_sales.tours.manage` | Crée une tournée draft. |
| GET/PATCH/DELETE | `/api/tours/{id}` | view / manage | DELETE = soft delete. |
| POST | `/api/tours/{tourId}/stops` | `field_sales.tours.manage` | Sous-ressource (Link toProperty `tour`, pattern ClientAddress). |
| PATCH/DELETE | `/api/tour_stops/{id}` | `field_sales.tours.manage` | PATCH `position` = drag & drop. |
| POST | `/api/tours/{id}/compute` | `field_sales.tours.manage` | Recalcule legs + ETA + totaux (`HaversineRouteEngine`). |
| POST | `/api/tours/{id}/optimize` | `field_sales.tours.manage` | Réordonne via `optimizeOrder()` puis recompute. |
| POST | `/api/tours/{id}/duplicate` | `field_sales.tours.manage` | Duplique étapes + départ à une nouvelle `tourDate` (RG-6.13). |
| GET | `/api/tours/{id}/roadbook.pdf` | `field_sales.tours.view` | Feuille de route PDF (skill `pdf`). |
| GET | `/api/visitable_tiers?bbox=...&q=...&type=client,supplier` | `field_sales.tours.view` | Pins dans la zone visible (carte). Paginé / `?pagination=false`. |
Toutes les collections sont **paginées** (règle ABSOLUE n°13). `/api/visitable_tiers` retourne un Paginator,
borné par `bbox`.
## 6. Écrans
### 6.1 Planification de tournée (carte interactive — responsive desktop + mobile)
Layout **split** inspiré de Badger/SPOTIO :
- **Carte interactive Leaflet** : pins des Tiers de la zone (couleur par type, filtrables). Sélection
**lasso/rectangle** → ajoute les Tiers entourés comme étapes. Clic pin → popup (nom, adresse, « + Ajouter »).
Tracé de la tournée dessiné par-dessus (polyline numérotée).
- **Panneau tournée** : nom, date, **heure de départ**, point de départ, liste d'**étapes draggable**
(n° + nom + adresse + ETA + temps depuis étape précédente), totaux (distance / durée / nb visites).
Boutons **« Trajet logique »**, **« Optimiser »**, **« Dupliquer »**, **« PDF »**.
- Chaque étape : menu **« Y aller »** (Waze / Google Maps / Plan via deep links), « Voir le Tiers ».
- Ajout d'un **point libre `custom`** (libellé + adresse + pin).
- En mobile, layout empilé : la navigation se fait via le bouton « Y aller » de chaque étape (pas de mode
terrain dédié).
### 6.2 Onglet « Carte » dans la fiche Client / Fournisseur
Nouvel onglet **« Carte »** dans la fiche **Client (M1)** et **Fournisseur (M2)** : **mini-carte Leaflet**
affichant **toutes les adresses géolocalisées du Tiers** (un marqueur par adresse, popup avec le libellé de
l'adresse). Vue d'ensemble des implantations du Tiers. Le **pin reste ajustable** par adresse (réutilise le
composant de l'onglet Adresse, déjà livré en ERP-122). Adresses sans coordonnées listées comme
« à géolocaliser ». Onglet visible sous `field_sales.tours.view` ; masqué si le module `field_sales` est désactivé.
### 6.3 Ajustement du pin (fiche adresse) — **FAIT (ERP-122)**
Mini-carte Leaflet avec marqueur déplaçable dans le bloc adresse M1/M2 ; drag → `latitude/longitude` +
`geo_manual = true` ; bouton « Re-géocoder depuis l'adresse ».
## 7. Géocodage des adresses — **FAIT (ERP-122)**
Service `GeocoderInterface` / `BanGeocoder` (api-adresse.data.gouv.fr). Correction manuelle systématique via le
pin (`geo_manual = true` fige — RG-6.08).
## 8. RBAC — 3 miroirs obligatoires
Permissions du module `field_sales` (méthode `permissions()` de `FieldSalesModule.php`) — **uniquement les
tournées** (plus de permissions `reports.*` depuis la réduction de scope) :
| Permission | Sens | Admin | Commerciale | Bureau |
|---|---|---|---|---|
| `field_sales.tours.view` | Voir les tournées + l'onglet Carte. | ✅ (toutes) | ✅ (les siennes) | ✅ (consultation) |
| `field_sales.tours.manage` | Créer/éditer/optimiser/dupliquer/supprimer une tournée. | ✅ | ✅ | ❌ |
Attribution : **Commerciale + Admin** = manage ; **Bureau** = view ; Compta exclue. À synchroniser dans les
**3 miroirs** (règle ABSOLUE n°8) : `config/sidebar.php` (section « Tournées » : item `tours` + i18n
`sidebar.field_sales.*`), `frontend/tests/e2e/_fixtures/personas.ts`, `SeedE2ECommand.php`. Sync :
`app:sync-permissions`.
## 9. Conventions & garde-fous (rappel)
- `declare(strict_types=1);` partout ; commentaires FR, code EN.
- Entités métier : `#[Auditable]` + Timestampable/Blamable. Libellés i18n `audit.entity.field_sales_tour` /
`_tourstop` dans `fr.json` (sinon `AuditableEntitiesHaveI18nLabelTest` casse `make test`).
- Migration modulaire : `COMMENT ON COLUMN` sur **chaque** colonne (FR ≤ 200 car.) + helper
`addStandardTimestampableBlamableComments()`.
- Toute collection paginée (`CollectionsArePaginatedTest`).
- Front : `useApi()` uniquement, composants `Malio*`, `MalioDataTable` + `usePaginatedList` pour les listes,
**pas d'état de tableau dans l'URL**. Carte Leaflet = exception documentée.
## 10. Règles de gestion (RG)
| RG | Règle | Garde-fou |
|---|---|---|
| **RG-6.01** | Tournée **personnelle** (`owner`). Commerciale ne voit/édite que les siennes ; Admin/Bureau voient tout en lecture. | Filtre Provider sur `owner` + RBAC. |
| **RG-6.02** | Cycle de vie `draft → planned → in_progress → done` (transitions libres en V1). | Enum + Assert\Choice. |
| **RG-6.03** | Une étape sur Tiers référentiel vise une adresse de ce Tiers (qui en a plusieurs). Ne s'applique pas aux `custom`. | Assert\Callback. |
| **RG-6.05** | Une étape n'entre dans le calcul que si son adresse a `latitude` ET `longitude`. Sinon « à géolocaliser », exclue des totaux. | `RouteEngine` + signalement front. |
| **RG-6.07** | Deux étapes peuvent viser le même Tiers (repasser plus tard). Unicité uniquement sur `(tour_id, position)`. | Index unique partiel. |
| **RG-6.08** | `geo_manual = true` fige les coordonnées (le géocodage auto ne réécrit plus). | Garde dans le géocodeur (FAIT). |
| **RG-6.11** | `eta` = `departure_time` + Σ(trajets précédents) + Σ(durées de visite précédentes). | `RouteEngine::estimateLegDurations()`. |
| **RG-6.12** | Une étape vise tout Tiers ou un point `custom`. Si `custom` : `tier_id`/`address_id` NULL, `custom_label` + coordonnées obligatoires. | Assert\Choice + Assert\Callback. |
| **RG-6.13** | Dupliquer copie départ + étapes (ordre/adresses/durées) à une nouvelle date ; ne copie pas les calculs (ETA/legs recalculés). | Service `TourDuplicator`. |
## 11. Tests à automatiser
- **Architecture (cassent `make test`)** : `ColumnsHaveSqlCommentTest`, `AuditableEntitiesHaveI18nLabelTest`
(`audit.entity.field_sales_tour` / `_tourstop`), `EntitiesAreTimestampableBlamableTest` (Tour, TourStop),
`CollectionsArePaginatedTest`, `EntityConstraintsHaveFrenchMessageTest`.
- **Back (PHPUnit)** : RG-6.03 (adresse hors Tiers → 422), RG-6.05 (étape sans coord exclue), RG-6.07 (doublon
Tiers accepté), RG-6.11 (ETA), RG-6.12 (custom cohérent), RG-6.13 (duplication sans calculs), filtre `owner`,
`HaversineRouteEngine` (ordre plus proche voisin sur un jeu de coordonnées connu).
- **Front (Vitest)** : `usePaginatedList` sur tournées, composable de planification (réordonnancement, totaux,
deep links), onglet Carte (marqueurs des adresses). **Pas de E2E** (règle d'or).
## 12. Hors-périmètre (HP)
- **HP-M6-1** : **rapport de visite** (compte-rendu, note /5, offres de prix, fichiers, catégorie, saisie
vocale, historique des visites) — **retiré du scope (V0.2)**, à réintroduire dans un module/lot ultérieur si besoin.
- **HP-M6-2** : mode terrain mobile dédié (vue du jour + check-in) — retiré ; navigation via l'écran de
planification responsive.
- **HP-M6-3** : routing routier réel + optimisation TSP (Matrix API) — V2 (V1 = heuristique Haversine).
- **HP-M6-4** : suggestion automatique des Tiers « à visiter » (façon Portatour) — V2.
- **HP-M6-5** : offline réel (PWA + synchro) — V3.
- **HP-M6-6** : partage / affectation de tournées entre commerciaux, planning d'équipe — V3.
- **HP-M6-7** : navigation multi-étapes poussée dans Waze (impossible techniquement) — navigation étape par étape.
## 13. Phasage
- **V1 (livrable)** : géoloc adresses + pin (FAIT) ; carte interactive + lasso ; tournée (création, drag & drop,
heure de départ, point de départ) sur tout Tiers + point `custom` ; **« Trajet logique »** + ETA + totaux ;
deep links Waze/Maps ; **duplication** ; **feuille de route PDF** ; **onglet Carte** dans les fiches
Client/Fournisseur ; responsive desktop + mobile.
- **V2** : bouton **« Optimiser »** (routing routier réel ORS/OSRM), temps trafic, suggestion des Tiers proches.
- **V3** : offline réel, partage/affectation de tournées.
## 14. Risques / points ouverts
- **Coût/quota routing en V2** : multi-tenant → cache, debounce, plafonds par tenant.
- **Limite Waze multi-étapes** : Waze ne prend qu'une destination → navigation étape par étape (assumé).
- **Reste à cadrer techniquement** : périmètre de visibilité Bureau (toutes les tournées vs les siennes).
---
## 📦 Tickets Lesstime (scope réduit V0.2)
TaskGroup Lesstime : **#28 — M6 Tournées commerciales terrain** (projet STARSEED #6). Tickets gros grain, chacun
avec un **prompt Fable** prêt à coller (consigne « adapte-toi à la config actuelle » incluse).
| # | Réf | Ticket | Effort | Tag | État |
|---|---|---|---|---|---|
| M6.1 | ERP-122 | Géolocaliser les adresses Tiers (lat/lng + pin) | L | Back+Front | ✅ Fait |
| M6.2 | ERP-123 | Fondations module field_sales + VisitableInterface + RBAC (tournées) | M | Back | Prêt à dev |
| M6.3 | ERP-124 | Entités & API Tournée + Étape | L | Back | Prêt à dev |
| M6.4 | ERP-125 | Calcul trajet, optimisation, duplication & roadbook PDF | L | Back | Prêt à dev |
| M6.5 | ERP-127 | Carte interactive + écran planification (responsive) | L | Front | Prêt à dev |
| M6.6 | ERP-129 | Onglet « Carte » dans les fiches Client & Fournisseur | M | Front | Prêt à dev |
| M6.7 | ERP-130 | Vérification : garde-fous archi, tests RG & golden path | M | Back+Front | Prêt à dev |
Supprimés à la réduction de scope : **ERP-126** (rapport de visite) et **ERP-128** (mode terrain mobile + formulaire rapport).
Ordre d'exécution : M6.2 → M6.3 → M6.4 → M6.5 → M6.6 → M6.7.
---
### Sources d'inspiration (logiciels de référence)
- Badger Maps — *Lasso* + carte : https://www.badgermapping.com/features/
- SPOTIO — drag & drop des étapes + optimize : https://support.spotio.com/hc/en-us/articles/360061370754-Routing-How-to-Build-and-Manage-Routes
- Portatour — multi-stop + recalcul auto : https://www.portatour.com/features/en
- Nomadia Field Sales — carte + tournée mobile : https://www.nomadia.com/ressources/blog/logiciel-commerciaux-itinerants/
+7 -116
View File
@@ -40,123 +40,15 @@
},
"catalog": {
"categories": "Gestion des catégories"
},
"field_sales": {
"section": "Tournées",
"tours": "Tournées"
}
},
"dashboard": {
"title": "Tableau de bord",
"welcome": "Bienvenue sur Starseed"
},
"field_sales": {
"tours": {
"title": "Tournées",
"add": "Nouvelle tournée",
"empty": "Aucune tournée pour l'instant.",
"column": {
"label": "Nom",
"date": "Date",
"status": "Statut",
"stops": "Étapes",
"distance": "Distance",
"duration": "Durée"
},
"status": {
"draft": "Brouillon",
"planned": "Planifiée",
"in_progress": "En cours",
"done": "Terminée"
},
"new": {
"title": "Nouvelle tournée",
"label": "Nom de la tournée",
"date": "Date",
"create": "Créer la tournée",
"cancel": "Annuler",
"error": "Impossible de créer la tournée."
}
},
"plan": {
"title": "Planification",
"back": "Retour aux tournées",
"panel": {
"title": "Tournée",
"label": "Nom de la tournée",
"date": "Date",
"departureTime": "Heure de départ",
"startLabel": "Point de départ",
"startModeSite": "Mes sites",
"startModeCustom": "Adresse libre",
"startSitePrefix": "Site de {name}",
"startSitePlaceholder": "Choisir un site…",
"startNoResults": "Adresse introuvable — saisie conservée.",
"defaultVisitMinutes": "Durée de visite (min)",
"stops": "Étapes",
"noStops": "Aucune étape. Sélectionnez des Tiers sur la carte ou ajoutez un point libre.",
"distance": "Distance",
"duration": "Durée totale",
"visits": "Visites"
},
"actions": {
"compute": "Trajet logique",
"optimize": "Optimiser",
"duplicate": "Dupliquer",
"pdf": "PDF",
"save": "Enregistrer"
},
"stop": {
"eta": "Arrivée",
"fromPrevious": "depuis l'étape précédente",
"toGeolocate": "À géolocaliser",
"goThere": "Y aller",
"viewTier": "Voir le Tiers",
"remove": "Supprimer l'étape",
"waze": "Waze",
"google": "Google Maps",
"apple": "Plan (Apple)"
},
"map": {
"typeClient": "Clients",
"typeSupplier": "Fournisseurs",
"search": "Rechercher un Tiers",
"add": "Ajouter",
"startPoint": "Point de départ",
"lassoHint": "Maintenez Maj et dessinez un rectangle pour sélectionner plusieurs Tiers."
},
"duplicateModal": {
"title": "Dupliquer la tournée",
"date": "Date de la nouvelle tournée",
"confirm": "Dupliquer",
"cancel": "Annuler"
},
"toast": {
"computeError": "Le calcul du trajet a échoué.",
"optimizeError": "L'optimisation a échoué.",
"duplicateError": "La duplication a échoué.",
"saveError": "L'enregistrement a échoué.",
"loadError": "Impossible de charger la tournée.",
"stopError": "L'opération sur l'étape a échoué.",
"duplicated": "Tournée dupliquée."
}
}
},
"commercial": {
"title": "Commercial",
"welcome": "Module Commercial",
"geo": {
"title": "Position géographique",
"toGeolocate": "À géolocaliser",
"manualPin": "Pin ajusté manuellement",
"dragHint": "Déplacez le marqueur pour ajuster la position exacte (lieu-dit, entrée de site...).",
"regeocode": "Re-géocoder depuis l'adresse",
"regeocodeFailed": "Adresse introuvable — position inchangée.",
"map": {
"noLocated": "Aucune adresse géolocalisée à afficher sur la carte.",
"missingTitle": "Adresses à géolocaliser"
}
},
"suppliers": {
"title": "Répertoire fournisseurs",
"add": "Ajouter",
@@ -197,8 +89,7 @@
"accounting": "Comptabilité",
"statistics": "Statistiques",
"reports": "Rapports",
"exchanges": "Échanges",
"carte": "Carte"
"exchanges": "Échanges"
},
"action": {
"edit": "Modifier",
@@ -331,8 +222,7 @@
"accounting": "Comptabilité",
"statistics": "Statistiques",
"reports": "Rapports",
"exchanges": "Échanges",
"carte": "Carte"
"exchanges": "Échanges"
},
"action": {
"edit": "Modifier",
@@ -496,7 +386,10 @@
},
"title": "Erreur",
"generic": "Une erreur est survenue.",
"unknown": "Erreur inconnue."
"unknown": "Erreur inconnue.",
"validation": {
"invalidDate": "Date invalide"
}
},
"sites": {
"selector": {
@@ -523,9 +416,7 @@
"commercial_supplier": "Fournisseur",
"commercial_supplieraddress": "Adresse fournisseur",
"commercial_suppliercontact": "Contact fournisseur",
"commercial_supplierrib": "RIB fournisseur",
"fieldsales_tour": "Tournée",
"fieldsales_tourstop": "Étape de tournée"
"commercial_supplierrib": "RIB fournisseur"
},
"empty": "Aucune activité enregistrée",
"no_results": "Aucun résultat pour ces filtres",
@@ -1,216 +0,0 @@
<template>
<div data-testid="geo-pin">
<div class="mb-1 flex items-center gap-2">
<span class="text-sm font-medium text-gray-700">{{ t('commercial.geo.title') }}</span>
<!-- Badge « a geolocaliser » : adresse valide mais sans coordonnees
(spec M6 § 3.2 exclue du calcul de tournee, RG-6.05). -->
<span
v-if="!hasCoords"
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800"
data-testid="geo-badge-missing"
>
{{ t('commercial.geo.toGeolocate') }}
</span>
<!-- Pin fige a la main (RG-6.08) : informatif. -->
<span
v-else-if="geoManual"
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
data-testid="geo-badge-manual"
>
{{ t('commercial.geo.manualPin') }}
</span>
</div>
<!-- Mini-carte Leaflet (exception documentee a @malio/layer-ui : carte
interactive, type non couvert par la lib cf. frontend.md
§ Composants formulaires). TODO : migrer si la lib couvre un jour
les cartes. -->
<div
v-if="hasCoords"
ref="mapEl"
class="h-48 w-full rounded border border-gray-200"
data-testid="geo-map"
/>
<p v-if="hasCoords && !readonly" class="mt-1 text-xs text-gray-500">
{{ t('commercial.geo.dragHint') }}
</p>
<div v-if="!readonly" class="mt-2 flex items-center gap-4">
<MalioButton
variant="secondary"
:label="t('commercial.geo.regeocode')"
:disabled="regeocoding || !canRegeocode"
data-testid="geo-regeocode"
@click="regeocode"
/>
<span v-if="regeocodeFailed" class="text-xs text-red-600" data-testid="geo-regeocode-failed">
{{ t('commercial.geo.regeocodeFailed') }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import type { Map as LeafletMap, Marker } from 'leaflet'
import { useAddressAutocomplete } from '~/shared/composables/useAddressAutocomplete'
/**
* Mini-carte d'ajustement du pin d'une adresse Tiers (M6.1, spec § 8.3).
*
* - Marqueur deplacable : au drag, emet les coordonnees corrigees avec
* geoManual = true (RG-6.08 : le geocodage auto ne reecrira plus). Le parent
* met a jour le brouillon ; la persistance suit le submit du formulaire
* (POST/PATCH de l'adresse), comme tous les champs du bloc.
* - « Re-geocoder depuis l'adresse » : previsualise la position BAN cote front
* et emet geoManual = false — au save, le back (BanGeocoder) refait autorite
* et pose geocodedAt.
* - Sans coordonnees : pas de carte, badge « a geolocaliser ».
*/
const props = defineProps<{
/** Latitude WGS84 (chaine decimale) ou null si non geolocalisee. */
latitude: string | null
/** Longitude WGS84 (chaine decimale) ou null si non geolocalisee. */
longitude: string | null
/** RG-6.08 : pin deja corrige a la main. */
geoManual: boolean
/** Adresse postale a re-geocoder (« rue, code postal ville »). */
geocodeQuery: string | null
readonly?: boolean
}>()
const emit = defineEmits<{
/** Nouveau positionnement du pin (drag manuel ou re-geocodage previsualise). */
'update:coords': [value: { latitude: string, longitude: string, geoManual: boolean }]
}>()
const { t } = useI18n()
const autocomplete = useAddressAutocomplete()
const mapEl = ref<HTMLElement | null>(null)
const regeocoding = ref(false)
const regeocodeFailed = ref(false)
const hasCoords = computed(() =>
props.latitude !== null && props.latitude !== ''
&& props.longitude !== null && props.longitude !== '',
)
const canRegeocode = computed(() => (props.geocodeQuery ?? '').trim().length >= 3)
// Instances Leaflet (hors reactivite Vue : un proxy sur la Map casse Leaflet).
let map: LeafletMap | null = null
let marker: Marker | null = null
/** Zoom d'affichage du pin (niveau rue). */
const PIN_ZOOM = 16
/**
* Monte la carte Leaflet dans le conteneur (import dynamique : la lib n'est
* chargee que si l'adresse a des coordonnees).
*/
async function ensureMap(): Promise<void> {
if (map !== null || mapEl.value === null || !hasCoords.value) {
return
}
const mod = await import('leaflet')
const L = mod.default ?? mod
await import('leaflet/dist/leaflet.css')
// Le conteneur peut avoir disparu pendant le chargement async (v-if).
if (mapEl.value === null) {
return
}
const position: [number, number] = [Number(props.latitude), Number(props.longitude)]
map = L.map(mapEl.value, { scrollWheelZoom: false }).setView(position, PIN_ZOOM)
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}).addTo(map)
// divIcon SVG inline : evite les assets PNG de Leaflet (chemins casses par
// le bundler Vite sans configuration dediee).
const icon = L.divIcon({
className: '',
html: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="28" height="40" fill="#2563eb" stroke="#1e40af" stroke-width="0.5"><path d="M12 0C7 0 3 4 3 9c0 6.6 9 15 9 15s9-8.4 9-15c0-5-4-9-9-9zm0 12.5A3.5 3.5 0 1 1 12 5.5a3.5 3.5 0 0 1 0 7z"/></svg>',
iconSize: [28, 40],
iconAnchor: [14, 40],
})
marker = L.marker(position, { icon, draggable: !props.readonly }).addTo(map)
marker.on('dragend', onMarkerDragEnd)
}
/** Drag du pin -> coordonnees corrigees + geoManual (RG-6.08). */
function onMarkerDragEnd(): void {
if (marker === null) {
return
}
const position = marker.getLatLng()
emit('update:coords', {
latitude: position.lat.toFixed(7),
longitude: position.lng.toFixed(7),
geoManual: true,
})
}
/**
* « Re-geocoder depuis l'adresse » : previsualisation BAN cote front. Emet
* geoManual = false — le geocodage serveur refait autorite au save.
*/
async function regeocode(): Promise<void> {
regeocodeFailed.value = false
const query = (props.geocodeQuery ?? '').trim()
if (query.length < 3) {
regeocodeFailed.value = true
return
}
regeocoding.value = true
try {
const coords = await autocomplete.geocode(query)
if (coords === null) {
regeocodeFailed.value = true
return
}
emit('update:coords', { ...coords, geoManual: false })
}
catch {
// BAN indisponible : position inchangee, message inline.
regeocodeFailed.value = true
}
finally {
regeocoding.value = false
}
}
// Coordonnees modifiees par le parent (drag deja applique, re-geocodage,
// rechargement) : recale le marqueur, ou monte la carte si elle n'existe pas
// encore (premieres coordonnees d'une adresse « a geolocaliser »).
watch(
() => [props.latitude, props.longitude] as const,
async () => {
if (!hasCoords.value) {
return
}
if (map === null) {
await nextTick()
await ensureMap()
return
}
const position: [number, number] = [Number(props.latitude), Number(props.longitude)]
marker?.setLatLng(position)
map.panTo(position)
},
)
onMounted(ensureMap)
onBeforeUnmount(() => {
map?.remove()
map = null
marker = null
})
</script>
@@ -178,19 +178,6 @@
/>
</div>
<!-- Pin geographique de l'adresse (M6.1, spec § 8.3) : mini-carte avec
marqueur ajustable, persiste au submit comme le reste du bloc. -->
<div class="col-span-4">
<AddressGeoPin
:latitude="model.latitude"
:longitude="model.longitude"
:geo-manual="model.geoManual"
:geocode-query="geocodeQuery"
:readonly="readonly"
@update:coords="onCoordsUpdate"
/>
</div>
</div>
</template>
@@ -200,7 +187,7 @@ import {
addressTypeFromFlags,
isBillingEmailRequired,
type AddressType,
} from '~/modules/commercial/utils/clientFormRules'
} from '~/modules/commercial/utils/forms/clientFormRules'
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
@@ -302,24 +289,6 @@ function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDr
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
// Adresse postale a re-geocoder (« rue, code postal ville ») — miroir du
// getDisplayLabel() serveur (le complement bruite le geocodage, exclu).
const geocodeQuery = computed<string | null>(() => {
const locality = [model.value.postalCode, model.value.city].filter(Boolean).join(' ')
const parts = [model.value.street, locality].filter(part => part && String(part).trim() !== '')
return parts.length > 0 ? parts.join(', ') : null
})
/** Pin deplace / re-geocode : repercute coordonnees + drapeau manuel (RG-6.08). */
function onCoordsUpdate(coords: { latitude: string, longitude: string, geoManual: boolean }): void {
emit('update:modelValue', {
...props.modelValue,
latitude: coords.latitude,
longitude: coords.longitude,
geoManual: coords.geoManual,
})
}
/** Revele le 2e champ email de facturation (clic sur le « + »). */
function revealSecondaryBillingEmail(): void {
emit('update:modelValue', { ...props.modelValue, hasSecondaryBillingEmail: true })
@@ -162,19 +162,6 @@
:readonly="readonly"
@update:model-value="(v: boolean) => update('triageProvider', v)"
/>
<!-- Pin geographique de l'adresse (M6.1, spec § 8.3) : mini-carte avec
marqueur ajustable, persiste au submit comme le reste du bloc. -->
<div class="col-span-4">
<AddressGeoPin
:latitude="model.latitude"
:longitude="model.longitude"
:geo-manual="model.geoManual"
:geocode-query="geocodeQuery"
:readonly="readonly"
@update:coords="onCoordsUpdate"
/>
</div>
</div>
</template>
@@ -256,24 +243,6 @@ function update<K extends keyof SupplierAddressFormDraft>(field: K, value: Suppl
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
// Adresse postale a re-geocoder (« rue, code postal ville ») — miroir du
// getDisplayLabel() serveur (le complement bruite le geocodage, exclu).
const geocodeQuery = computed<string | null>(() => {
const locality = [model.value.postalCode, model.value.city].filter(Boolean).join(' ')
const parts = [model.value.street, locality].filter(part => part && String(part).trim() !== '')
return parts.length > 0 ? parts.join(', ') : null
})
/** Pin deplace / re-geocode : repercute coordonnees + drapeau manuel (RG-6.08). */
function onCoordsUpdate(coords: { latitude: string, longitude: string, geoManual: boolean }): void {
emit('update:modelValue', {
...props.modelValue,
latitude: coords.latitude,
longitude: coords.longitude,
geoManual: coords.geoManual,
})
}
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
function notifyUnavailable(): void {
if (!unavailableNotified) {
@@ -1,230 +0,0 @@
<template>
<div data-testid="tier-address-map">
<!-- Carte d'ensemble : un marqueur par adresse geolocalisee du Tiers,
cadree sur l'ensemble (fitBounds). Pin ajustable par drag (M6.6,
spec § 6.2) : au drag, PATCH direct des coordonnees + geoManual=true
(RG-6.08), sans passer par le formulaire d'edition. -->
<div
v-if="located.length > 0"
ref="mapEl"
class="h-96 w-full rounded border border-gray-200"
data-testid="tier-map"
/>
<p v-else class="rounded border border-dashed border-gray-300 bg-gray-50 py-8 text-center text-sm text-gray-500">
{{ t('commercial.geo.map.noLocated') }}
</p>
<p v-if="located.length > 0 && editable" class="mt-1 text-xs text-gray-500">
{{ t('commercial.geo.dragHint') }}
</p>
<!-- Adresses sans coordonnees : listees a part (« a geolocaliser »),
exclues de la carte et du calcul de tournee (RG-6.05). -->
<div v-if="missing.length > 0" class="mt-6" data-testid="tier-map-missing-list">
<h3 class="mb-2 flex items-center gap-2 text-sm font-medium text-gray-700">
{{ t('commercial.geo.map.missingTitle') }}
<span class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800">
{{ missing.length }}
</span>
</h3>
<ul class="flex flex-col gap-2">
<li
v-for="address in missing"
:key="address.id"
class="rounded border border-gray-200 bg-white px-3 py-2 text-sm"
data-testid="tier-map-missing"
>
<span class="font-medium text-gray-800">{{ address.title }}</span>
<span v-if="address.typeLabel" class="ml-2 text-xs text-gray-500">{{ address.typeLabel }}</span>
</li>
</ul>
</div>
</div>
</template>
<script lang="ts">
import type { Map as LeafletMap, Marker } from 'leaflet'
/**
* Adresse normalisee pour la carte d'ensemble d'un Tiers. La page parente
* (fiche Client / Fournisseur) construit la liste : elle resout le libelle, le
* type traduit et l'endpoint PATCH des coordonnees (les modeles d'adresse
* client/fournisseur different — drapeaux vs enum).
*/
export interface TierMapAddress {
/** Id serveur de l'adresse. */
id: number
/** Latitude WGS84 (chaine decimale) ou null si non geolocalisee. */
latitude: string | null
/** Longitude WGS84 (chaine decimale) ou null si non geolocalisee. */
longitude: string | null
/** RG-6.08 : pin deja corrige a la main. */
geoManual: boolean
/** Libelle principal (rue + code postal ville). */
title: string
/** Type d'adresse traduit (Prospect / Livraison / Depart...). */
typeLabel: string
/** Endpoint PATCH des coordonnees (ex: /client_addresses/12). */
patchPath: string
}
</script>
<script setup lang="ts">
const props = withDefaults(defineProps<{
/** Toutes les adresses du Tiers (geolocalisees ou non). */
addresses: TierMapAddress[]
/** Drag du pin actif (PATCH des coordonnees) — exige le droit d'edition. */
editable?: boolean
}>(), {
editable: false,
})
const emit = defineEmits<{
/** Coordonnees d'une adresse mises a jour par drag (PATCH reussi). */
updated: [value: { id: number, latitude: string, longitude: string }]
}>()
const { t } = useI18n()
const api = useApi()
const mapEl = ref<HTMLElement | null>(null)
/** Vrai si l'adresse porte des coordonnees exploitables. */
function hasCoords(address: TierMapAddress): boolean {
return address.latitude !== null && address.latitude !== ''
&& address.longitude !== null && address.longitude !== ''
}
const located = computed(() => props.addresses.filter(hasCoords))
const missing = computed(() => props.addresses.filter(a => !hasCoords(a)))
// Instances Leaflet (hors reactivite Vue : un proxy casse l'API Leaflet).
let L: typeof import('leaflet') | null = null
let map: LeafletMap | null = null
let markers: Marker[] = []
/** Zoom max applique par fitBounds (evite un zoom excessif sur un seul pin). */
const MAX_FIT_ZOOM = 16
/** Monte la carte Leaflet (import dynamique : chargee seulement si besoin). */
async function ensureMap(): Promise<void> {
if (map !== null || mapEl.value === null || located.value.length === 0) {
return
}
const mod = await import('leaflet')
L = mod.default ?? mod
await import('leaflet/dist/leaflet.css')
// Le conteneur peut avoir disparu pendant le chargement async (v-if).
if (mapEl.value === null) {
return
}
map = L.map(mapEl.value, { scrollWheelZoom: false })
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}).addTo(map)
renderMarkers()
}
/** Pin SVG inline (evite les assets PNG Leaflet casses par Vite). */
function pinIcon() {
return L!.divIcon({
className: '',
html: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="28" height="40" fill="#2563eb" stroke="#1e40af" stroke-width="0.5"><path d="M12 0C7 0 3 4 3 9c0 6.6 9 15 9 15s9-8.4 9-15c0-5-4-9-9-9zm0 12.5A3.5 3.5 0 1 1 12 5.5a3.5 3.5 0 0 1 0 7z"/></svg>',
iconSize: [28, 40],
iconAnchor: [14, 40],
popupAnchor: [0, -36],
})
}
/** Contenu HTML du popup : libelle + type de l'adresse. */
function popupHtml(address: TierMapAddress): string {
const title = escapeHtml(address.title)
const type = address.typeLabel ? `<div class="text-gray-600">${escapeHtml(address.typeLabel)}</div>` : ''
return `<div class="text-sm"><div class="font-semibold">${title}</div>${type}</div>`
}
/** (Re)pose un marqueur par adresse geolocalisee et cadre la carte dessus. */
function renderMarkers(): void {
if (map === null || L === null) {
return
}
markers.forEach(m => m.remove())
markers = []
const points: [number, number][] = []
for (const address of located.value) {
const position: [number, number] = [Number(address.latitude), Number(address.longitude)]
const marker = L.marker(position, { icon: pinIcon(), draggable: props.editable }).addTo(map)
marker.bindPopup(popupHtml(address))
if (props.editable) {
marker.on('dragend', () => onMarkerDragEnd(address, marker))
}
markers.push(marker)
points.push(position)
}
// Cadre sur l'ensemble des marqueurs (fitBounds), borne pour un pin isole.
if (points.length > 0) {
map.fitBounds(L.latLngBounds(points), { padding: [40, 40], maxZoom: MAX_FIT_ZOOM })
}
}
/**
* Drag d'un pin -> PATCH direct des coordonnees + geoManual=true (RG-6.08).
* Contrairement au formulaire d'edition (persistance differee au submit), la
* carte d'ensemble enregistre immediatement le nouveau positionnement.
*/
async function onMarkerDragEnd(address: TierMapAddress, marker: Marker): Promise<void> {
const position = marker.getLatLng()
const latitude = position.lat.toFixed(7)
const longitude = position.lng.toFixed(7)
try {
await api.patch(address.patchPath, { latitude, longitude, geoManual: true }, { toast: false })
address.geoManual = true
address.latitude = latitude
address.longitude = longitude
emit('updated', { id: address.id, latitude, longitude })
}
catch {
// Echec d'enregistrement : on remet le pin a sa derniere position connue.
marker.setLatLng([Number(address.latitude), Number(address.longitude)])
}
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
// (Re)monte ou rafraichit la carte quand la liste des adresses geolocalisees
// change (chargement async de la fiche, ajout de coordonnees).
watch(located, async () => {
if (located.value.length === 0) {
return
}
if (map === null) {
await nextTick()
await ensureMap()
return
}
renderMarkers()
}, { deep: true })
onMounted(ensureMap)
onBeforeUnmount(() => {
map?.remove()
map = null
L = null
markers = []
})
</script>
@@ -1,151 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import AddressGeoPin from '../AddressGeoPin.vue'
// Mock Leaflet (hoisted) : capture le handler `dragend` et pilote la position
// renvoyee par getLatLng — permet de simuler un drag du marqueur sans DOM reel.
const leafletState = vi.hoisted(() => ({
dragendHandler: null as (() => void) | null,
markerPosition: { lat: 0, lng: 0 },
}))
vi.mock('leaflet', () => {
const marker = {
addTo: vi.fn().mockReturnThis(),
on: vi.fn((event: string, handler: () => void) => {
if (event === 'dragend') {
leafletState.dragendHandler = handler
}
}),
getLatLng: vi.fn(() => leafletState.markerPosition),
setLatLng: vi.fn(),
}
const map = {
setView: vi.fn().mockReturnThis(),
panTo: vi.fn(),
remove: vi.fn(),
}
const L = {
map: vi.fn(() => map),
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
divIcon: vi.fn(() => ({})),
marker: vi.fn(() => marker),
}
return { default: L, ...L }
})
vi.mock('leaflet/dist/leaflet.css', () => ({ default: {} }))
// Mock controlable du geocodage BAN (bouton « Re-geocoder »).
const { geocodeMock } = vi.hoisted(() => ({ geocodeMock: vi.fn() }))
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
useAddressAutocomplete: () => ({ geocode: geocodeMock }),
}))
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('ref', ref)
vi.stubGlobal('computed', computed)
vi.stubGlobal('watch', watch)
vi.stubGlobal('nextTick', nextTick)
vi.stubGlobal('onMounted', onMounted)
vi.stubGlobal('onBeforeUnmount', onBeforeUnmount)
interface PinProps {
latitude?: string | null
longitude?: string | null
geoManual?: boolean
geocodeQuery?: string | null
readonly?: boolean
}
function mountPin(props: PinProps = {}) {
return mount(AddressGeoPin, {
props: {
latitude: null,
longitude: null,
geoManual: false,
geocodeQuery: '1 rue du Test, 86100 Châtellerault',
...props,
},
global: {
stubs: { MalioButton: true },
},
})
}
beforeEach(() => {
leafletState.dragendHandler = null
geocodeMock.mockReset()
})
describe('AddressGeoPin — adresse sans coordonnees', () => {
it('affiche le badge « a geolocaliser » et aucune carte', () => {
const wrapper = mountPin()
expect(wrapper.find('[data-testid="geo-badge-missing"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="geo-map"]').exists()).toBe(false)
})
})
describe('AddressGeoPin — drag du marqueur (RG-6.08)', () => {
it('emet les coordonnees corrigees avec geoManual=true au dragend', async () => {
const wrapper = mountPin({ latitude: '46.5802596', longitude: '0.3404333' })
await flushPromises() // import dynamique de Leaflet + montage carte
expect(leafletState.dragendHandler).not.toBeNull()
// L'utilisateur depose le pin ailleurs (lieu-dit mal geocode).
leafletState.markerPosition = { lat: 48.1234567, lng: -1.6543217 }
leafletState.dragendHandler?.()
const emitted = wrapper.emitted('update:coords')
expect(emitted).toHaveLength(1)
expect(emitted?.[0]?.[0]).toEqual({
latitude: '48.1234567',
longitude: '-1.6543217',
geoManual: true,
})
})
it('affiche le badge « pin manuel » quand geoManual est vrai', () => {
const wrapper = mountPin({ latitude: '46.58', longitude: '0.34', geoManual: true })
expect(wrapper.find('[data-testid="geo-badge-manual"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="geo-badge-missing"]').exists()).toBe(false)
})
})
describe('AddressGeoPin — re-geocodage depuis l\'adresse', () => {
it('emet la position BAN avec geoManual=false (le back refera autorite au save)', async () => {
geocodeMock.mockResolvedValueOnce({ latitude: '46.5802596', longitude: '0.3404333' })
const wrapper = mountPin()
await wrapper.find('[data-testid="geo-regeocode"]').trigger('click')
await flushPromises()
expect(geocodeMock).toHaveBeenCalledWith('1 rue du Test, 86100 Châtellerault')
expect(wrapper.emitted('update:coords')?.[0]?.[0]).toEqual({
latitude: '46.5802596',
longitude: '0.3404333',
geoManual: false,
})
})
it('signale l\'echec sans emettre quand la BAN ne trouve rien', async () => {
geocodeMock.mockResolvedValueOnce(null)
const wrapper = mountPin()
await wrapper.find('[data-testid="geo-regeocode"]').trigger('click')
await flushPromises()
expect(wrapper.emitted('update:coords')).toBeUndefined()
expect(wrapper.find('[data-testid="geo-regeocode-failed"]').exists()).toBe(true)
})
it('masque le bouton en lecture seule', () => {
const wrapper = mountPin({ readonly: true })
expect(wrapper.find('[data-testid="geo-regeocode"]').exists()).toBe(false)
})
})
@@ -65,8 +65,6 @@ function mountBlock(street: string | null) {
MalioSelectCheckbox: true,
MalioInputText: true,
MalioInputAutocomplete: MalioInputAutocompleteStub,
// Pin geographique (M6.1) : teste dans AddressGeoPin.spec.ts.
AddressGeoPin: true,
},
},
})
@@ -132,8 +130,6 @@ describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
MalioSelectCheckbox: true,
MalioInputAutocomplete: MalioInputAutocompleteStub,
MalioInputText: MalioInputTextProbe,
// Pin geographique (M6.1) : teste dans AddressGeoPin.spec.ts.
AddressGeoPin: true,
},
},
})
@@ -1,158 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import TierAddressMap, { type TierMapAddress } from '../TierAddressMap.vue'
// Mock Leaflet (hoisted) : capture les marqueurs crees (un par adresse
// geolocalisee) et leur handler `dragend`, et trace l'appel a fitBounds.
const leafletState = vi.hoisted(() => ({
markers: [] as Array<{
_latlng: { lat: number, lng: number }
dragend: (() => void) | null
setLatLng: ReturnType<typeof vi.fn>
}>,
fitBoundsCalled: false,
}))
vi.mock('leaflet', () => {
function makeMarker(lat: number, lng: number) {
const marker = {
_latlng: { lat, lng },
dragend: null as (() => void) | null,
addTo: vi.fn().mockReturnThis(),
bindPopup: vi.fn().mockReturnThis(),
on: vi.fn((event: string, handler: () => void) => {
if (event === 'dragend') marker.dragend = handler
}),
getLatLng: vi.fn(() => marker._latlng),
setLatLng: vi.fn(),
remove: vi.fn(),
}
return marker
}
const map = {
fitBounds: vi.fn(() => { leafletState.fitBoundsCalled = true }),
setView: vi.fn().mockReturnThis(),
remove: vi.fn(),
}
const L = {
map: vi.fn(() => map),
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
divIcon: vi.fn(() => ({})),
latLngBounds: vi.fn((points: unknown) => points),
marker: vi.fn((pos: [number, number]) => {
const marker = makeMarker(pos[0], pos[1])
leafletState.markers.push(marker)
return marker
}),
}
return { default: L, ...L }
})
vi.mock('leaflet/dist/leaflet.css', () => ({ default: {} }))
// Mock controlable de l'API (PATCH des coordonnees au drag).
const { patchMock } = vi.hoisted(() => ({ patchMock: vi.fn() }))
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useApi', () => ({ patch: patchMock }))
vi.stubGlobal('ref', ref)
vi.stubGlobal('computed', computed)
vi.stubGlobal('watch', watch)
vi.stubGlobal('nextTick', nextTick)
vi.stubGlobal('onMounted', onMounted)
vi.stubGlobal('onBeforeUnmount', onBeforeUnmount)
function address(over: Partial<TierMapAddress> = {}): TierMapAddress {
return {
id: 1,
latitude: '47.218',
longitude: '-1.553',
geoManual: false,
title: '1 rue du Test, 44000 Nantes',
typeLabel: 'Livraison',
patchPath: '/client_addresses/1',
...over,
}
}
beforeEach(() => {
leafletState.markers = []
leafletState.fitBoundsCalled = false
patchMock.mockReset()
patchMock.mockResolvedValue({})
})
describe('TierAddressMap — marqueurs', () => {
it('pose un marqueur par adresse geolocalisee et liste a part celles sans coordonnees', async () => {
const wrapper = mount(TierAddressMap, {
props: {
addresses: [
address({ id: 1, patchPath: '/client_addresses/1' }),
address({ id: 2, latitude: '48.85', longitude: '2.35', patchPath: '/client_addresses/2' }),
address({ id: 3, latitude: null, longitude: null, patchPath: '/client_addresses/3', title: '5 rue Sans Geo' }),
],
},
})
await flushPromises() // import dynamique de Leaflet + montage carte
// Deux adresses geolocalisees -> deux marqueurs ; la troisieme (sans
// coords) n'est pas posee sur la carte mais listee a part.
expect(leafletState.markers).toHaveLength(2)
expect(leafletState.fitBoundsCalled).toBe(true)
const missing = wrapper.findAll('[data-testid="tier-map-missing"]')
expect(missing).toHaveLength(1)
expect(missing[0]?.text()).toContain('5 rue Sans Geo')
expect(wrapper.find('[data-testid="tier-map"]').exists()).toBe(true)
})
it('affiche un etat vide quand aucune adresse n\'est geolocalisee', async () => {
const wrapper = mount(TierAddressMap, {
props: { addresses: [address({ latitude: null, longitude: null })] },
})
await flushPromises()
expect(leafletState.markers).toHaveLength(0)
expect(wrapper.find('[data-testid="tier-map"]').exists()).toBe(false)
expect(wrapper.findAll('[data-testid="tier-map-missing"]')).toHaveLength(1)
})
})
describe('TierAddressMap — pin ajustable (RG-6.08)', () => {
it('PATCH les coordonnees + geoManual=true au drag quand editable', async () => {
const wrapper = mount(TierAddressMap, {
props: { addresses: [address({ id: 7, patchPath: '/client_addresses/7' })], editable: true },
})
await flushPromises()
const marker = leafletState.markers[0]
expect(marker?.dragend).not.toBeNull()
// L'utilisateur depose le pin ailleurs (entree de site mal geocodee).
marker!._latlng = { lat: 48.1234567, lng: -1.6543217 }
marker!.dragend?.()
await flushPromises()
expect(patchMock).toHaveBeenCalledWith(
'/client_addresses/7',
{ latitude: '48.1234567', longitude: '-1.6543217', geoManual: true },
{ toast: false },
)
expect(wrapper.emitted('updated')?.[0]?.[0]).toEqual({
id: 7,
latitude: '48.1234567',
longitude: '-1.6543217',
})
})
it('ne rend pas les marqueurs draggables (pas de PATCH) en lecture seule', async () => {
mount(TierAddressMap, {
props: { addresses: [address()], editable: false },
})
await flushPromises()
// Aucun handler dragend cable -> pas de drag possible.
expect(leafletState.markers[0]?.dragend).toBeNull()
})
})
@@ -1,5 +1,5 @@
import { ref } from 'vue'
import type { ClientDetail } from '~/modules/commercial/utils/clientConsultation'
import type { ClientDetail } from '~/modules/commercial/utils/forms/clientConsultation'
/**
* Chargement et actions d'archivage d'un client unique (ecran « Consultation
@@ -1,5 +1,5 @@
import { ref } from 'vue'
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
/**
* Chargement et actions d'archivage d'un fournisseur unique (ecran « Consultation
@@ -116,6 +116,7 @@
:readonly="businessReadonly"
:editable="true"
:error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/>
<MalioInputText
v-model="information.employeesCount"
@@ -401,7 +402,7 @@ import {
mapAddressToDraft,
mapRibToDraft,
type ClientDetail,
} from '~/modules/commercial/utils/clientConsultation'
} from '~/modules/commercial/utils/forms/clientConsultation'
import {
buildAccountingPayload,
buildAddressPayload,
@@ -417,7 +418,7 @@ import {
type ClientEditAbilities,
type InformationFormDraft,
type MainFormDraft,
} from '~/modules/commercial/utils/clientEdit'
} from '~/modules/commercial/utils/forms/clientEdit'
import {
buildClientFormTabKeys,
isAddressValid,
@@ -429,7 +430,7 @@ import {
isRibComplete,
isRibRequiredForPaymentType,
showsRelationAndTriageFields,
} from '~/modules/commercial/utils/clientFormRules'
} from '~/modules/commercial/utils/forms/clientFormRules'
import {
emptyAddress,
emptyContact,
@@ -242,13 +242,6 @@
</div>
</template>
<!-- Onglet Carte (M6.6) : vue d'ensemble des implantations du client. -->
<template v-if="showMapTab" #carte>
<div class="mt-12 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<TierAddressMap :addresses="mapAddresses" :editable="canEditAddresses" />
</div>
</template>
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
@@ -287,8 +280,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient'
import { addressTypeFromFlags, buildClientFormTabKeys, isRibRequiredForPaymentType, type AddressType } from '~/modules/commercial/utils/clientFormRules'
import type { TierMapAddress } from '~/modules/commercial/components/TierAddressMap.vue'
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab'
import {
canEditClient,
@@ -305,7 +297,7 @@ import {
showRestoreAction,
type ClientDetail,
type SelectOption,
} from '~/modules/commercial/utils/clientConsultation'
} from '~/modules/commercial/utils/forms/clientConsultation'
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
@@ -316,7 +308,6 @@ const route = useRoute()
const router = useRouter()
const toast = useToast()
const { can, canAny } = usePermissions()
const { isModuleActive } = useModules()
const authStore = useAuthStore()
// Gating de la route : la consultation exige `view`. Usine (sans view) est
@@ -420,54 +411,10 @@ const paymentDelayOptions = computed(() => referentialOptionOf(client.value?.pay
const paymentTypeOptions = computed(() => referentialOptionOf(client.value?.paymentType))
const bankOptions = computed(() => referentialOptionOf(client.value?.bank))
// Onglet « Carte » (M6.6, module field_sales)
// Visible uniquement si le module field_sales est actif ET que l'utilisateur a
// la permission de consultation des tournees. Le drag du pin (PATCH direct) est
// reserve aux roles pouvant editer un client.
const showMapTab = computed(() => isModuleActive('field_sales') && can('field_sales.tours.view'))
const canEditAddresses = computed(() => can('commercial.clients.manage'))
// Cles i18n du type d'adresse (RG-1.06/07/08) pour le libelle du popup carte.
const CLIENT_ADDRESS_TYPE_I18N: Record<AddressType, string> = {
prospect: 'addressTypeProspect',
delivery: 'addressTypeDelivery',
billing: 'addressTypeBilling',
delivery_billing: 'addressTypeDeliveryBilling',
broker: 'addressTypeBroker',
distributor: 'addressTypeDistributor',
}
/** Adresses du client normalisees pour la carte d'ensemble (M6.6). */
const mapAddresses = computed<TierMapAddress[]>(() =>
(client.value?.addresses ?? []).map((a) => {
const type = addressTypeFromFlags({
isProspect: a.isProspect ?? false,
isDelivery: a.isDelivery ?? false,
isBilling: a.isBilling ?? false,
isBroker: a.isBroker ?? false,
isDistributor: a.isDistributor ?? false,
})
const cityLine = [a.postalCode, a.city].filter(Boolean).join(' ')
return {
id: a.id,
latitude: a.latitude ?? null,
longitude: a.longitude ?? null,
geoManual: a.geoManual === true,
title: [a.street, cityLine].filter(Boolean).join(', ') || t('commercial.clients.form.address.title', { n: a.id }),
typeLabel: type ? t(`commercial.clients.form.address.${CLIENT_ADDRESS_TYPE_I18N[type]}`) : '',
patchPath: `/client_addresses/${a.id}`,
}
}),
)
// Onglets : navigation LIBRE (pas de sequence forcee en consultation)
// 4 onglets actifs (Information, Contact, Adresse, + Comptabilite si droit) et
// 4 coquilles (Transport, Statistiques, Rapports, Echanges) ; + Carte si M6.6.
const tabKeys = computed(() => {
const keys = buildClientFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true })
if (showMapTab.value) keys.push('carte')
return keys
})
// 4 coquilles (Transport, Statistiques, Rapports, Echanges).
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline',
@@ -478,7 +425,6 @@ const TAB_ICONS: Record<string, string> = {
statistics: 'mdi:finance',
reports: 'mdi:file-document-edit-outline',
exchanges: 'mdi:account-group-outline',
carte: 'mdi:map-outline',
}
const tabs = computed(() => tabKeys.value.map(key => ({
@@ -111,6 +111,7 @@
:readonly="isValidated('information')"
:editable="true"
:error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/>
<MalioInputText
v-model="information.employeesCount"
@@ -401,12 +402,12 @@ import {
isRibRequiredForPaymentType,
lastFillableTabKey,
showsRelationAndTriageFields,
} from '~/modules/commercial/utils/clientFormRules'
} from '~/modules/commercial/utils/forms/clientFormRules'
import {
buildAddressPayload,
buildMainPayload,
buildRibPayload,
} from '~/modules/commercial/utils/clientEdit'
} from '~/modules/commercial/utils/forms/clientEdit'
import {
emptyAddress,
emptyContact,
@@ -651,6 +652,8 @@ const information = reactive({
description: null as string | null,
competitors: null as string | null,
foundedAt: null as string | null,
// Saisie brute invalide remontee par MalioDate (cf. foundedAtRaw, MUI-44).
foundedAtRaw: '',
employeesCount: null as string | null,
revenueAmount: null as string | null,
profitAmount: null as string | null,
@@ -666,7 +669,8 @@ async function submitInformation(): Promise<void> {
await api.patch(`/clients/${clientId.value}`, {
description: information.description || null,
competitors: information.competitors || null,
foundedAt: information.foundedAt || null,
// Saisie invalide prioritaire -> 422 back sur foundedAt (cf. foundedAtRaw).
foundedAt: information.foundedAtRaw || information.foundedAt || null,
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
revenueAmount: information.revenueAmount || null,
profitAmount: information.profitAmount || null,
@@ -77,6 +77,7 @@
:readonly="businessReadonly"
:editable="true"
:error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/>
<MalioInputText
v-model="information.employeesCount"
@@ -370,7 +371,7 @@ import {
mapAddressToDraft,
mapRibToDraft,
type SupplierDetail,
} from '~/modules/commercial/utils/supplierConsultation'
} from '~/modules/commercial/utils/forms/supplierConsultation'
import {
buildAccountingPayload,
buildAddressPayload,
@@ -386,7 +387,7 @@ import {
type InformationFormDraft,
type MainFormDraft,
type SupplierEditAbilities,
} from '~/modules/commercial/utils/supplierEdit'
} from '~/modules/commercial/utils/forms/supplierEdit'
import {
buildSupplierFormTabKeys,
isAddressValid,
@@ -396,7 +397,7 @@ import {
isRibBlank,
isRibComplete,
isRibRequiredForPaymentType,
} from '~/modules/commercial/utils/supplierFormRules'
} from '~/modules/commercial/utils/forms/supplierFormRules'
import {
emptyAddress,
emptyContact,
@@ -225,13 +225,6 @@
</div>
</template>
<!-- Onglet Carte (M6.6) : vue d'ensemble des implantations du fournisseur. -->
<template v-if="showMapTab" #carte>
<div class="mt-12 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<TierAddressMap :addresses="mapAddresses" :editable="canEditAddresses" />
</div>
</template>
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
@@ -270,7 +263,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/supplierFormRules'
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab'
import {
canEditSupplier,
@@ -287,9 +280,8 @@ import {
showRestoreAction,
type SelectOption,
type SupplierDetail,
} from '~/modules/commercial/utils/supplierConsultation'
import { emptyContact, type SupplierAddressType } from '~/modules/commercial/types/supplierForm'
import type { TierMapAddress } from '~/modules/commercial/components/TierAddressMap.vue'
} from '~/modules/commercial/utils/forms/supplierConsultation'
import { emptyContact } from '~/modules/commercial/types/supplierForm'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
const SIREN_MASK = '#########'
@@ -299,7 +291,6 @@ const route = useRoute()
const router = useRouter()
const toast = useToast()
const { can, canAny } = usePermissions()
const { isModuleActive } = useModules()
const authStore = useAuthStore()
// Gating de la route : la consultation exige `view`. Usine (sans view) est
@@ -395,44 +386,10 @@ const paymentDelayOptions = computed(() => referentialOptionOf(supplier.value?.p
const paymentTypeOptions = computed(() => referentialOptionOf(supplier.value?.paymentType))
const bankOptions = computed(() => referentialOptionOf(supplier.value?.bank))
// Onglet « Carte » (M6.6, module field_sales)
// Visible uniquement si le module field_sales est actif ET que l'utilisateur a
// la permission de consultation des tournees. Le drag du pin (PATCH direct) est
// reserve aux roles pouvant editer un fournisseur.
const showMapTab = computed(() => isModuleActive('field_sales') && can('field_sales.tours.view'))
const canEditAddresses = computed(() => can('commercial.suppliers.manage'))
// Cles i18n du type d'adresse fournisseur (enum PROSPECT/DEPART/RENDU, RG-2.09).
const SUPPLIER_ADDRESS_TYPE_I18N: Record<SupplierAddressType, string> = {
PROSPECT: 'addressTypeProspect',
DEPART: 'addressTypeDepart',
RENDU: 'addressTypeRendu',
}
/** Adresses du fournisseur normalisees pour la carte d'ensemble (M6.6). */
const mapAddresses = computed<TierMapAddress[]>(() =>
(supplier.value?.addresses ?? []).map((a) => {
const cityLine = [a.postalCode, a.city].filter(Boolean).join(' ')
return {
id: a.id,
latitude: a.latitude ?? null,
longitude: a.longitude ?? null,
geoManual: a.geoManual === true,
title: [a.street, cityLine].filter(Boolean).join(', ') || t('commercial.suppliers.form.address.title', { n: a.id }),
typeLabel: a.addressType ? t(`commercial.suppliers.form.address.${SUPPLIER_ADDRESS_TYPE_I18N[a.addressType]}`) : '',
patchPath: `/supplier_addresses/${a.id}`,
}
}),
)
// Onglets : navigation LIBRE (pas de sequence forcee en consultation)
// 3 onglets actifs (Information, Contacts, Adresses, + Comptabilite si droit) et
// 4 coquilles (Transport, Statistiques, Rapports, Echanges) ; + Carte si M6.6.
const tabKeys = computed(() => {
const keys = buildSupplierFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true })
if (showMapTab.value) keys.push('carte')
return keys
})
// 4 coquilles (Transport, Statistiques, Rapports, Echanges).
const tabKeys = computed(() => buildSupplierFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline',
@@ -443,7 +400,6 @@ const TAB_ICONS: Record<string, string> = {
statistics: 'mdi:finance',
reports: 'mdi:file-document-edit-outline',
exchanges: 'mdi:account-group-outline',
carte: 'mdi:map-outline',
}
const tabs = computed(() => tabKeys.value.map(key => ({
@@ -71,6 +71,7 @@
:readonly="isValidated('information')"
:editable="true"
:error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/>
<MalioInputText
v-model="information.employeesCount"
@@ -361,7 +362,7 @@ import {
isRibComplete,
isRibRequiredForPaymentType,
lastFillableTabKey,
} from '~/modules/commercial/utils/supplierFormRules'
} from '~/modules/commercial/utils/forms/supplierFormRules'
import {
buildAccountingPayload,
buildAddressPayload,
@@ -369,7 +370,7 @@ import {
buildInformationPayload,
buildMainPayload,
buildRibPayload,
} from '~/modules/commercial/utils/supplierEdit'
} from '~/modules/commercial/utils/forms/supplierEdit'
import {
emptyAddress,
emptyContact,
@@ -549,6 +550,8 @@ const information = reactive({
description: null as string | null,
competitors: null as string | null,
foundedAt: null as string | null,
// Saisie brute invalide remontee par MalioDate (cf. foundedAtRaw, MUI-44).
foundedAtRaw: '',
employeesCount: null as string | null,
revenueAmount: null as string | null,
profitAmount: null as string | null,
@@ -51,12 +51,6 @@ export interface AddressFormDraft {
billingEmailSecondary: string | null
/** Drapeau UI : 2e champ email revele (comme hasSecondaryPhone). */
hasSecondaryBillingEmail: boolean
/** Latitude WGS84 (chaine decimale NUMERIC(10,7)), null si non geolocalisee (M6.1). */
latitude: string | null
/** Longitude WGS84 (chaine decimale NUMERIC(10,7)), null si non geolocalisee (M6.1). */
longitude: string | null
/** RG-6.08 : pin corrige a la main — le geocodage auto ne reecrit plus. */
geoManual: boolean
}
/** Un RIB du client (onglet Comptabilite). */
@@ -102,9 +96,6 @@ export function emptyAddress(): AddressFormDraft {
billingEmail: null,
billingEmailSecondary: null,
hasSecondaryBillingEmail: false,
latitude: null,
longitude: null,
geoManual: false,
}
}
@@ -55,12 +55,6 @@ export interface SupplierAddressFormDraft {
bennes: string | null
/** Prestation de triage (specifique fournisseur, portee par l'adresse — RG). */
triageProvider: boolean
/** Latitude WGS84 (chaine decimale NUMERIC(10,7)), null si non geolocalisee (M6.1). */
latitude: string | null
/** Longitude WGS84 (chaine decimale NUMERIC(10,7)), null si non geolocalisee (M6.1). */
longitude: string | null
/** RG-6.08 : pin corrige a la main — le geocodage auto ne reecrit plus. */
geoManual: boolean
}
/** Un RIB du fournisseur (onglet Comptabilite). */
@@ -101,9 +95,6 @@ export function emptyAddress(): SupplierAddressFormDraft {
contactIris: [],
bennes: '0',
triageProvider: false,
latitude: null,
longitude: null,
geoManual: false,
}
}
@@ -36,6 +36,7 @@ function informationDraft(overrides: Partial<InformationFormDraft> = {}): Inform
description: 'desc',
competitors: 'concurrents',
foundedAt: '2010-05-01',
foundedAtRaw: '',
employeesCount: '42',
revenueAmount: '1000000',
profitAmount: '50000',
@@ -140,6 +141,16 @@ describe('buildInformationPayload — scoping strict groupe client:write:informa
expect(payload.description).toBeNull()
expect(payload.directorName).toBeNull()
})
it('envoie la saisie invalide (foundedAtRaw) en priorite -> le back tranchera (422)', () => {
// Saisie malformee : on transmet le texte brut tel quel pour declencher la
// 422 back sur foundedAt (validation autoritaire du format, MUI-44).
expect(buildInformationPayload(informationDraft({ foundedAt: null, foundedAtRaw: '32/13/2026' })).foundedAt)
.toBe('32/13/2026')
// Saisie valide : foundedAtRaw vide -> on envoie la date ISO.
expect(buildInformationPayload(informationDraft({ foundedAt: '2010-05-01', foundedAtRaw: '' })).foundedAt)
.toBe('2010-05-01')
})
})
describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => {
@@ -11,7 +11,7 @@ import {
mapMainDraft,
resolveTabEditability,
} from '../supplierEdit'
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/supplierForm'
describe('buildMainPayload (groupe supplier:write:main)', () => {
@@ -37,7 +37,7 @@ describe('buildMainPayload (groupe supplier:write:main)', () => {
describe('buildInformationPayload (groupe supplier:write:information)', () => {
const base = {
description: null, competitors: null, foundedAt: null, employeesCount: null,
description: null, competitors: null, foundedAt: null, foundedAtRaw: '', employeesCount: null,
revenueAmount: null, profitAmount: null, directorName: null, volumeForecast: null,
}
@@ -48,6 +48,15 @@ describe('buildInformationPayload (groupe supplier:write:information)', () => {
})
expect(buildInformationPayload(base)).toMatchObject({ employeesCount: null, volumeForecast: null })
})
it('envoie la saisie invalide (foundedAtRaw) en priorite -> le back tranchera (422)', () => {
// Saisie malformee transmise telle quelle pour declencher la 422 back (MUI-44).
expect(buildInformationPayload({ ...base, foundedAt: null, foundedAtRaw: '32/13/2026' }).foundedAt)
.toBe('32/13/2026')
// Saisie valide : foundedAtRaw vide -> on envoie la date ISO.
expect(buildInformationPayload({ ...base, foundedAt: '2008-04-01', foundedAtRaw: '' }).foundedAt)
.toBe('2008-04-01')
})
})
describe('buildContactPayload (sous-ressource supplier_contact)', () => {
@@ -69,10 +69,6 @@ export interface AddressRead extends HydraRef {
isBilling?: boolean
isBroker?: boolean
isDistributor?: boolean
/** Geolocalisation (M6.1) : chaines decimales NUMERIC(10,7) cote API. */
latitude?: string | null
longitude?: string | null
geoManual?: boolean
sites?: SiteRead[]
categories?: CategoryRead[]
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
@@ -229,9 +225,6 @@ export function mapAddressToDraft(address: AddressRead): AddressFormDraft {
billingEmail: address.billingEmail ?? null,
billingEmailSecondary: address.billingEmailSecondary ?? null,
hasSecondaryBillingEmail: (address.billingEmailSecondary ?? null) !== null && address.billingEmailSecondary !== '',
latitude: address.latitude ?? null,
longitude: address.longitude ?? null,
geoManual: address.geoManual === true,
}
}
@@ -20,14 +20,14 @@ import {
iriOf,
relationOf,
type ClientDetail,
} from '~/modules/commercial/utils/clientConsultation'
} from '~/modules/commercial/utils/forms/clientConsultation'
import {
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
blankEmptyRequired,
MAIN_REQUIRED_NON_NULLABLE_KEYS,
omitEmptyRequired,
RIB_REQUIRED_NON_NULLABLE_KEYS,
} from '~/modules/commercial/utils/clientFormRules'
} from '~/modules/commercial/utils/forms/clientFormRules'
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
/**
@@ -53,6 +53,13 @@ export interface InformationFormDraft {
competitors: string | null
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
foundedAt: string | null
/**
* Saisie brute invalide remontee par MalioDate (`@update:rawValue`) : '' tant
* que la saisie est valide/vide, sinon le texte tel que tape. On l'envoie au
* back en priorite sur `foundedAt` pour que la 422 (validation autoritaire du
* format, ERP-101) porte sur le champ et s'affiche inline. Cf. MUI-44.
*/
foundedAtRaw: string
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
employeesCount: string | null
revenueAmount: string | null
@@ -118,6 +125,8 @@ export function mapInformationDraft(client: ClientDetail): InformationFormDraft
competitors: client.competitors ?? null,
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null,
// Aucune saisie brute invalide au chargement (la valeur stockee est valide).
foundedAtRaw: '',
employeesCount: client.employeesCount != null ? String(client.employeesCount) : null,
revenueAmount: client.revenueAmount ?? null,
profitAmount: client.profitAmount ?? null,
@@ -191,7 +200,9 @@ export function buildInformationPayload(information: InformationFormDraft): Reco
return {
description: information.description || null,
competitors: information.competitors || null,
foundedAt: information.foundedAt || null,
// Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle
// pour que le back renvoie une 422 sur foundedAt (cf. foundedAtRaw).
foundedAt: information.foundedAtRaw || information.foundedAt || null,
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
revenueAmount: information.revenueAmount || null,
profitAmount: information.profitAmount || null,
@@ -254,11 +265,6 @@ export function buildAddressPayload(
contacts: address.contactIris,
billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null,
billingEmailSecondary: isBillingEmailRequired && address.hasSecondaryBillingEmail ? (address.billingEmailSecondary || null) : null,
// Geolocalisation (M6.1) : pin manuel persiste avec geoManual=true ;
// geoManual=false laisse le back regeocoder depuis l'adresse postale.
latitude: address.latitude || null,
longitude: address.longitude || null,
geoManual: address.geoManual,
}, ADDRESS_REQUIRED_NON_NULLABLE_KEYS, options)
}
@@ -76,10 +76,6 @@ export interface AddressRead extends HydraRef {
streetComplement?: string | null
bennes?: number | null
triageProvider?: boolean
/** Geolocalisation (M6.1) : chaines decimales NUMERIC(10,7) cote API. */
latitude?: string | null
longitude?: string | null
geoManual?: boolean
sites?: SiteRead[]
categories?: CategoryRead[]
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
@@ -204,9 +200,6 @@ export function mapAddressToDraft(address: AddressRead): SupplierAddressFormDraf
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
bennes: address.bennes != null ? String(address.bennes) : '0',
triageProvider: address.triageProvider ?? false,
latitude: address.latitude ?? null,
longitude: address.longitude ?? null,
geoManual: address.geoManual === true,
}
}
@@ -17,8 +17,8 @@ import {
MAIN_REQUIRED_NON_NULLABLE_KEYS,
omitEmptyRequired,
RIB_REQUIRED_NON_NULLABLE_KEYS,
} from '~/modules/commercial/utils/supplierFormRules'
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
} from '~/modules/commercial/utils/forms/supplierFormRules'
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
import type {
SupplierAddressFormDraft,
SupplierContactFormDraft,
@@ -38,6 +38,13 @@ export interface InformationFormDraft {
competitors: string | null
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
foundedAt: string | null
/**
* Saisie brute invalide remontee par MalioDate (`@update:rawValue`) : '' tant
* que la saisie est valide/vide, sinon le texte tel que tape. On l'envoie au
* back en priorite sur `foundedAt` pour que la 422 (validation autoritaire du
* format, ERP-101) porte sur le champ et s'affiche inline. Cf. MUI-44.
*/
foundedAtRaw: string
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
employeesCount: string | null
revenueAmount: string | null
@@ -95,6 +102,8 @@ export function mapInformationDraft(supplier: SupplierDetail): InformationFormDr
competitors: supplier.competitors ?? null,
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
foundedAt: supplier.foundedAt ? supplier.foundedAt.slice(0, 10) : null,
// Aucune saisie brute invalide au chargement (la valeur stockee est valide).
foundedAtRaw: '',
employeesCount: supplier.employeesCount != null ? String(supplier.employeesCount) : null,
revenueAmount: supplier.revenueAmount ?? null,
profitAmount: supplier.profitAmount ?? null,
@@ -177,7 +186,9 @@ export function buildInformationPayload(information: InformationFormDraft): Reco
return {
description: information.description || null,
competitors: information.competitors || null,
foundedAt: information.foundedAt || null,
// Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle
// pour que le back renvoie une 422 sur foundedAt (cf. foundedAtRaw).
foundedAt: information.foundedAtRaw || information.foundedAt || null,
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
revenueAmount: information.revenueAmount || null,
profitAmount: information.profitAmount || null,
@@ -237,11 +248,6 @@ export function buildAddressPayload(address: SupplierAddressFormDraft, options:
contacts: address.contactIris,
bennes: address.bennes !== null && address.bennes !== '' ? Number(address.bennes) : null,
triageProvider: address.triageProvider,
// Geolocalisation (M6.1) : pin manuel persiste avec geoManual=true ;
// geoManual=false laisse le back regeocoder depuis l'adresse postale.
latitude: address.latitude || null,
longitude: address.longitude || null,
geoManual: address.geoManual,
}, ADDRESS_REQUIRED_NON_NULLABLE_KEYS, options)
}
@@ -1,441 +0,0 @@
<template>
<!-- Carte Leaflet (exception documentee a @malio/layer-ui : carte interactive,
type non couvert par la lib cf. frontend.md § Composants formulaires).
TODO : migrer si la lib couvre un jour les cartes. -->
<div class="relative h-full w-full">
<div ref="mapEl" class="h-full w-full" data-testid="tour-map" />
<!-- Aide a la selection rectangle (lasso facon Badger Maps). -->
<div class="pointer-events-none absolute bottom-2 left-2 z-[400] rounded bg-white/90 px-2 py-1 text-xs text-gray-600 shadow">
{{ t('field_sales.plan.map.lassoHint') }}
</div>
</div>
</template>
<script setup lang="ts">
import type { Map as LeafletMap, Marker, Polyline, Rectangle } from 'leaflet'
import type { PlanningStop } from '~/modules/field-sales/composables/useTourPlanning'
import type { VisitableTier } from '~/modules/field-sales/types/tour'
/**
* Carte interactive de planification de tournee (M6.5, spec § 6.1).
*
* - Charge les pins des Tiers geolocalises de la zone visible
* (GET /api/visitable_tiers?bbox=...), colores par type (client/fournisseur),
* filtrables (types + recherche). Recharge au deplacement/zoom (debounce).
* - Popup au clic : nom, adresse, bouton « + Ajouter » (emet `add-tier`).
* - Selection rectangle (Maj + glisser) : ajoute tous les Tiers entoures
* (emet `add-tiers`).
* - Trace la tournee par-dessus : polyline + marqueurs numerotes suivant l'ordre
* des etapes geolocalisees.
*
* Instances Leaflet hors reactivite Vue (un proxy casse l'API Leaflet).
*/
const props = withDefaults(defineProps<{
/** Etapes geolocalisees a tracer (polyline numerotee). */
stops: PlanningStop[]
/** Types de pins affiches. */
types: Array<'client' | 'supplier'>
/** Recherche raison sociale / ville. */
search: string
/** Centre initial (defaut : Nantes). */
center?: [number, number]
/** Point de depart de la tournee (marqueur « maison »), si geolocalise. */
start?: { latitude: number, longitude: number, label?: string } | null
}>(), {
center: () => [47.218, -1.553],
start: null,
})
const emit = defineEmits<{
/** Ajout d'un seul Tiers (popup « + Ajouter »). */
'add-tier': [tier: VisitableTier]
/** Ajout d'un lot de Tiers (selection rectangle). */
'add-tiers': [tiers: VisitableTier[]]
}>()
const { t } = useI18n()
const api = useApi()
const mapEl = ref<HTMLElement | null>(null)
// Instances Leaflet (hors reactivite).
let L: typeof import('leaflet') | null = null
let map: LeafletMap | null = null
let pinLayer: Marker[] = []
let pinTiers: Array<{ tier: VisitableTier, marker: Marker }> = []
let routeLine: Polyline | null = null
let stopMarkers: Marker[] = []
let startMarker: Marker | null = null
let selectionRect: Rectangle | null = null
// Signature du dernier cadrage automatique (ensemble des points geolocalises).
// Evite de re-cadrer la carte a chaque recompute (memes points, ETA mises a jour)
// ou reorder (memes points, ordre different) : on ne recadre qu'a l'ajout/retrait.
let lastFitSignature = ''
// Observe les changements de taille du conteneur (layout flex/responsive) pour
// reparer le rendu des tuiles (invalidateSize).
let resizeObserver: ResizeObserver | null = null
/** Zoom initial (niveau agglomeration). */
const INITIAL_ZOOM = 12
/** Couleur du pin par type de Tiers. */
const PIN_COLORS: Record<string, string> = {
client: '#2563eb', // bleu
supplier: '#16a34a', // vert
}
/** Debounce du rechargement des pins au deplacement de la carte. */
let fetchTimer: ReturnType<typeof setTimeout> | null = null
async function ensureMap(): Promise<void> {
if (map !== null || mapEl.value === null) {
return
}
const mod = await import('leaflet')
L = mod.default ?? mod
await import('leaflet/dist/leaflet.css')
if (mapEl.value === null) {
return
}
map = L.map(mapEl.value, {
// Conserve les tuiles hors-cadre un court instant : panning plus fluide.
preferCanvas: true,
}).setView(props.center, INITIAL_ZOOM)
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
// Garde les tuiles deja chargees pendant le zoom (moins de gris/clignotement).
keepBuffer: 4,
}).addTo(map)
// Selection rectangle a la place du box-zoom natif (Maj + glisser).
map.boxZoom.disable()
map.on('mousedown', onMouseDown)
// Rechargement des pins quand la zone visible change.
map.on('moveend', scheduleFetch)
// Le conteneur est dans un layout flex (lg:flex-1) : sa taille n'est pas
// toujours stabilisee a la creation de la map tuiles partielles/grises et
// panning saccade. On force un recalcul de taille apres le 1er rendu, puis a
// chaque resize du conteneur (passage responsive, ouverture panneau, etc.).
requestAnimationFrame(() => map?.invalidateSize())
resizeObserver = new ResizeObserver(() => map?.invalidateSize())
resizeObserver.observe(mapEl.value)
drawRoute()
await fetchPins()
}
/** bbox de la zone visible au format Leaflet (minLng,minLat,maxLng,maxLat). */
function currentBbox(): string | null {
if (map === null) {
return null
}
return map.getBounds().toBBoxString()
}
function scheduleFetch(): void {
if (fetchTimer !== null) {
clearTimeout(fetchTimer)
}
fetchTimer = setTimeout(() => {
void fetchPins()
}, 300)
}
/**
* Charge les pins de la zone visible. `?pagination=false` : la carte affiche
* TOUS les pins de la bbox (le volume est borne par la zone, pas par la page).
*/
async function fetchPins(): Promise<void> {
if (map === null || L === null) {
return
}
if (props.types.length === 0) {
clearPins()
return
}
const bbox = currentBbox()
if (bbox === null) {
return
}
const query: Record<string, string> = {
bbox,
type: props.types.join(','),
pagination: 'false',
}
if (props.search.trim() !== '') {
query.q = props.search.trim()
}
try {
const response = await api.get<{ member?: VisitableTier[] }>(
'/visitable_tiers',
query,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
renderPins(response.member ?? [])
}
catch {
// Echec non bloquant : la carte reste utilisable, les pins ne se mettent
// simplement pas a jour.
}
}
function clearPins(): void {
pinLayer.forEach(m => m.remove())
pinLayer = []
pinTiers = []
}
function renderPins(tiers: VisitableTier[]): void {
if (map === null || L === null) {
return
}
clearPins()
for (const tier of tiers) {
const marker = L.marker([tier.latitude, tier.longitude], {
icon: pinIcon(PIN_COLORS[tier.tierType] ?? '#6b7280'),
}).addTo(map)
marker.bindPopup(popupHtml(tier))
marker.on('popupopen', () => bindPopupButton(tier))
pinLayer.push(marker)
pinTiers.push({ tier, marker })
}
}
/** divIcon SVG inline colore (evite les assets PNG Leaflet casses par Vite). */
function pinIcon(color: string) {
return L!.divIcon({
className: '',
html: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="26" height="38" fill="${color}" stroke="#ffffff" stroke-width="1"><path d="M12 0C7 0 3 4 3 9c0 6.6 9 15 9 15s9-8.4 9-15c0-5-4-9-9-9zm0 12.5A3.5 3.5 0 1 1 12 5.5a3.5 3.5 0 0 1 0 7z"/></svg>`,
iconSize: [26, 38],
iconAnchor: [13, 38],
popupAnchor: [0, -34],
})
}
/** Contenu HTML du popup (le bouton est cable a l'ouverture, cf. bindPopupButton). */
function popupHtml(tier: VisitableTier): string {
const name = escapeHtml(tier.displayName)
const address = escapeHtml(tier.address)
return `<div class="text-sm">
<div class="font-semibold">${name}</div>
<div class="text-gray-600">${address}</div>
<button type="button" data-add-tier class="mt-2 rounded bg-blue-600 px-2 py-1 text-xs font-medium text-white">${t('field_sales.plan.map.add')}</button>
</div>`
}
/** Cable le bouton « + Ajouter » du popup ouvert vers l'emit `add-tier`. */
function bindPopupButton(tier: VisitableTier): void {
const el = map?.getContainer().querySelector('[data-add-tier]')
el?.addEventListener('click', () => {
emit('add-tier', tier)
map?.closePopup()
}, { once: true })
}
// Selection rectangle (lasso)
let selectStart: import('leaflet').LatLng | null = null
function onMouseDown(e: import('leaflet').LeafletMouseEvent): void {
if (map === null || L === null || !e.originalEvent.shiftKey) {
return
}
// Empeche le drag de la carte pendant la selection.
map.dragging.disable()
selectStart = e.latlng
selectionRect = L.rectangle(L.latLngBounds(e.latlng, e.latlng), {
color: '#2563eb',
weight: 1,
fillOpacity: 0.1,
}).addTo(map)
map.on('mousemove', onMouseMove)
map.on('mouseup', onMouseUp)
}
function onMouseMove(e: import('leaflet').LeafletMouseEvent): void {
if (selectStart === null || selectionRect === null || L === null) {
return
}
selectionRect.setBounds(L.latLngBounds(selectStart, e.latlng))
}
function onMouseUp(): void {
if (map === null) {
return
}
const bounds = selectionRect?.getBounds() ?? null
cleanupSelection()
if (bounds === null) {
return
}
const selected = pinTiers
.filter(({ marker }) => bounds.contains(marker.getLatLng()))
.map(({ tier }) => tier)
if (selected.length > 0) {
emit('add-tiers', selected)
}
}
function cleanupSelection(): void {
selectionRect?.remove()
selectionRect = null
selectStart = null
map?.off('mousemove', onMouseMove)
map?.off('mouseup', onMouseUp)
map?.dragging.enable()
}
// Trace de la tournee
function drawRoute(): void {
if (map === null || L === null) {
return
}
routeLine?.remove()
routeLine = null
stopMarkers.forEach(m => m.remove())
stopMarkers = []
startMarker?.remove()
startMarker = null
// Point de depart : marqueur « maison » distinctif, en tete du trace.
const start = props.start
if (start != null && start.latitude != null && start.longitude != null) {
startMarker = L.marker([start.latitude, start.longitude], {
icon: startIcon(),
zIndexOffset: 1100,
}).addTo(map)
startMarker.bindTooltip(start.label && start.label.trim() !== '' ? start.label : t('field_sales.plan.map.startPoint'), { direction: 'top' })
}
const located = props.stops.filter(s => s.latitude != null && s.longitude != null)
const stopPoints = located.map(s => [s.latitude as number, s.longitude as number] as [number, number])
// La polyline part du point de depart (si geolocalise) puis enchaine les etapes.
const linePoints: Array<[number, number]> = start != null && start.latitude != null && start.longitude != null
? [[start.latitude, start.longitude], ...stopPoints]
: stopPoints
if (linePoints.length >= 2) {
routeLine = L.polyline(linePoints, { color: '#1e40af', weight: 3, opacity: 0.7 }).addTo(map)
}
located.forEach((stop, index) => {
const marker = L!.marker([stop.latitude as number, stop.longitude as number], {
icon: numberedIcon(index + 1),
zIndexOffset: 1000,
}).addTo(map!)
marker.bindTooltip(stop.label, { direction: 'top' })
stopMarkers.push(marker)
})
fitToRoute(linePoints)
}
/**
* Cadre la carte sur l'ensemble des points de la tournee (depart + etapes).
* Ne recadre que si l'ensemble des points a change (ajout/retrait d'etape ou de
* depart) : un recompute (memes points) ou un reorder ne doit pas faire sauter la
* vue. Signature triee independante de l'ordre des etapes.
*/
function fitToRoute(points: Array<[number, number]>): void {
if (map === null || L === null || points.length === 0) {
return
}
const signature = points
.map(([lat, lng]) => `${lat.toFixed(5)},${lng.toFixed(5)}`)
.sort()
.join('|')
if (signature === lastFitSignature) {
return
}
lastFitSignature = signature
if (points.length === 1) {
map.setView(points[0]!, Math.max(map.getZoom(), 13))
return
}
map.fitBounds(L.latLngBounds(points), { padding: [40, 40], maxZoom: 15 })
}
/** Pastille numerotee pour une etape de la tournee. */
function numberedIcon(n: number) {
return L!.divIcon({
className: '',
html: `<div style="display:flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:9999px;background:#1e40af;color:#fff;font-size:12px;font-weight:700;border:2px solid #fff;box-shadow:0 1px 2px rgba(0,0,0,.4)">${n}</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12],
})
}
/**
* Marqueur du point de depart : pastille « maison » ambre, visuellement distincte
* des pins de Tiers (goutte) et des etapes numerotees (rond bleu).
*/
function startIcon() {
return L!.divIcon({
className: '',
html: `<div style="display:flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:9999px;background:#f59e0b;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.5)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="#ffffff"><path d="M12 3 2 12h3v8h6v-6h2v6h6v-8h3z"/></svg>
</div>`,
iconSize: [30, 30],
iconAnchor: [15, 15],
})
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
// Recharge les pins quand les filtres changent.
watch(() => [props.types, props.search], scheduleFetch, { deep: true })
// Redessine le trace quand les etapes ou le point de depart changent.
watch(() => props.stops, drawRoute, { deep: true })
watch(() => props.start, drawRoute, { deep: true })
onMounted(ensureMap)
onBeforeUnmount(() => {
if (fetchTimer !== null) {
clearTimeout(fetchTimer)
}
resizeObserver?.disconnect()
resizeObserver = null
map?.remove()
map = null
L = null
pinLayer = []
pinTiers = []
stopMarkers = []
startMarker = null
routeLine = null
selectionRect = null
})
defineExpose({
/** Recentre la carte sur une cible (ex: depuis le panneau). */
panTo(target: { latitude: number, longitude: number }) {
map?.panTo([target.latitude, target.longitude])
},
})
</script>
@@ -1,148 +0,0 @@
<template>
<div>
<p v-if="stops.length === 0" class="py-6 text-center text-sm text-gray-500" data-testid="stops-empty">
{{ t('field_sales.plan.panel.noStops') }}
</p>
<!-- Liste draggable (vuedraggable / SortableJS) : au drop, on emet le
nouvel ordre. La poignee limite le drag a l'icone (le reste de la
ligne reste cliquable). Etat 100 % local cote parent. -->
<draggable
v-else
:model-value="stops"
item-key="id"
handle=".drag-handle"
ghost-class="opacity-50"
class="flex flex-col gap-2"
@update:model-value="onReorder"
>
<template #item="{ element, index }">
<div
class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2"
:data-testid="`stop-${element.id}`"
>
<!-- Poignee de drag + numero d'ordre. -->
<button
type="button"
class="drag-handle mt-0.5 flex h-7 w-7 shrink-0 cursor-grab items-center justify-center rounded-full bg-blue-800 text-xs font-bold text-white"
:aria-label="t('field_sales.plan.panel.stops')"
>
{{ index + 1 }}
</button>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate font-medium text-gray-900">{{ element.label }}</span>
<span
v-if="!isStopLocated(element)"
class="shrink-0 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800"
>
{{ t('field_sales.plan.stop.toGeolocate') }}
</span>
</div>
<p class="truncate text-xs text-gray-500">{{ element.displayAddress }}</p>
<!-- ETA + temps depuis l'etape precedente. -->
<p v-if="isStopLocated(element)" class="mt-0.5 text-xs text-gray-600">
<span class="font-medium">{{ t('field_sales.plan.stop.eta') }}</span>
{{ formatTime(element.eta) }}
<span v-if="index > 0" class="text-gray-400">
· {{ formatDuration(element.legDurationS) }} / {{ formatDistance(element.legDistanceM) }}
{{ t('field_sales.plan.stop.fromPrevious') }}
</span>
</p>
<!-- Actions : Y aller (deep links) · Voir le Tiers. -->
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<div class="relative">
<button
type="button"
class="font-medium text-blue-700 hover:underline disabled:text-gray-300"
:disabled="navLinks(element) === null"
@click="toggleMenu(element.id)"
>
{{ t('field_sales.plan.stop.goThere') }}
</button>
<div
v-if="openMenuId === element.id && navLinks(element) !== null"
class="absolute z-10 mt-1 flex flex-col rounded border border-gray-200 bg-white py-1 shadow-lg"
>
<a :href="navLinks(element)!.waze" target="_blank" rel="noopener" class="px-3 py-1 hover:bg-gray-100" @click="openMenuId = null">{{ t('field_sales.plan.stop.waze') }}</a>
<a :href="navLinks(element)!.google" target="_blank" rel="noopener" class="px-3 py-1 hover:bg-gray-100" @click="openMenuId = null">{{ t('field_sales.plan.stop.google') }}</a>
<a :href="navLinks(element)!.apple" target="_blank" rel="noopener" class="px-3 py-1 hover:bg-gray-100" @click="openMenuId = null">{{ t('field_sales.plan.stop.apple') }}</a>
</div>
</div>
<button
v-if="element.tierType !== 'custom'"
type="button"
class="text-gray-600 hover:underline"
@click="emit('view-tier', element)"
>
{{ t('field_sales.plan.stop.viewTier') }}
</button>
</div>
</div>
<!-- Suppression de l'etape. -->
<button
type="button"
class="mt-0.5 shrink-0 text-gray-400 hover:text-red-600"
:aria-label="t('field_sales.plan.stop.remove')"
@click="emit('remove', element)"
>
<Icon name="mdi:close" size="18" />
</button>
</div>
</template>
</draggable>
</div>
</template>
<script setup lang="ts">
import draggable from 'vuedraggable'
import {
buildNavigationLinks,
isStopLocated,
formatDistance,
formatDuration,
formatTime,
type PlanningStop,
} from '~/modules/field-sales/composables/useTourPlanning'
import type { NavigationLinks } from '~/modules/field-sales/types/tour'
/**
* Liste ordonnee et draggable des etapes d'une tournee (panneau de
* planification, M6.5). Le reordonnancement (drag & drop) emet le nouvel ordre ;
* la persistance (POST /reorder) est a la charge de la page.
*/
defineProps<{
stops: PlanningStop[]
}>()
const emit = defineEmits<{
/** Nouvel ordre des etapes apres drop. */
'reorder': [stops: PlanningStop[]]
/** Retrait d'une etape. */
'remove': [stop: PlanningStop]
/** « Voir le Tiers » (etape sur Tiers referentiel). */
'view-tier': [stop: PlanningStop]
}>()
const { t } = useI18n()
/** Menu « Y aller » ouvert (id de l'etape) ou null. */
const openMenuId = ref<number | null>(null)
function toggleMenu(id: number): void {
openMenuId.value = openMenuId.value === id ? null : id
}
function navLinks(stop: PlanningStop): NavigationLinks | null {
return buildNavigationLinks(stop)
}
function onReorder(next: PlanningStop[]): void {
emit('reorder', next)
}
</script>
@@ -1,132 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
reorderStops,
computeTotals,
buildNavigationLinks,
isStopLocated,
formatDistance,
formatDuration,
formatTime,
type PlanningStop,
} from '../useTourPlanning'
/** Fabrique une etape de planification minimale pour les tests. */
function makeStop(overrides: Partial<PlanningStop> = {}): PlanningStop {
return {
id: overrides.id ?? 1,
tierType: overrides.tierType ?? 'client',
tierId: overrides.tierId ?? null,
addressId: overrides.addressId ?? null,
customLabel: null,
customAddress: null,
customLatitude: null,
customLongitude: null,
position: overrides.position ?? 0,
visitMinutes: overrides.visitMinutes ?? null,
legDistanceM: overrides.legDistanceM ?? null,
legDurationS: overrides.legDurationS ?? null,
eta: overrides.eta ?? null,
label: overrides.label ?? 'Étape',
displayAddress: overrides.displayAddress ?? '',
latitude: overrides.latitude ?? null,
longitude: overrides.longitude ?? null,
}
}
describe('reorderStops', () => {
it('deplace une etape et renumerote les positions de maniere contigue', () => {
const stops = [
makeStop({ id: 1, position: 0, label: 'A' }),
makeStop({ id: 2, position: 1, label: 'B' }),
makeStop({ id: 3, position: 2, label: 'C' }),
]
// Deplace C (index 2) en tete (index 0).
const result = reorderStops(stops, 2, 0)
expect(result.map(s => s.label)).toEqual(['C', 'A', 'B'])
expect(result.map(s => s.position)).toEqual([0, 1, 2])
})
it('ne mute pas le tableau source', () => {
const stops = [makeStop({ id: 1, position: 0 }), makeStop({ id: 2, position: 1 })]
reorderStops(stops, 0, 1)
expect(stops.map(s => s.id)).toEqual([1, 2])
})
it('retourne une copie inchangee si un index est hors borne', () => {
const stops = [makeStop({ id: 1, position: 0 })]
const result = reorderStops(stops, 0, 5)
expect(result.map(s => s.id)).toEqual([1])
})
})
describe('computeTotals', () => {
it('somme distances/trajets et ajoute les visites (defaut + specifique)', () => {
const stops = [
// 1re etape : pas de leg (point de depart). Visite = defaut 30 min.
makeStop({ id: 1, legDistanceM: null, legDurationS: null }),
// 2e : 10 km / 12 min de trajet, visite specifique 15 min.
makeStop({ id: 2, legDistanceM: 10_000, legDurationS: 720, visitMinutes: 15 }),
// 3e : 5 km / 6 min, visite par defaut.
makeStop({ id: 3, legDistanceM: 5_000, legDurationS: 360, visitMinutes: null }),
]
const totals = computeTotals(stops, 30)
expect(totals.totalDistanceM).toBe(15_000)
expect(totals.travelDurationS).toBe(1_080)
// Visites : 30 + 15 + 30 = 75 min = 4500 s.
expect(totals.visitDurationS).toBe(4_500)
expect(totals.totalDurationS).toBe(1_080 + 4_500)
expect(totals.visitCount).toBe(3)
})
it('renvoie des totaux nuls pour une tournee vide', () => {
const totals = computeTotals([], 30)
expect(totals.totalDistanceM).toBe(0)
expect(totals.totalDurationS).toBe(0)
expect(totals.visitCount).toBe(0)
})
})
describe('buildNavigationLinks', () => {
it('construit les trois deep links Waze/Google/Apple', () => {
const links = buildNavigationLinks({ latitude: 47.218, longitude: -1.553 })
expect(links).not.toBeNull()
expect(links!.waze).toBe('https://waze.com/ul?ll=47.218,-1.553&navigate=yes')
expect(links!.google).toBe('https://www.google.com/maps/dir/?api=1&destination=47.218,-1.553')
expect(links!.apple).toBe('https://maps.apple.com/?daddr=47.218,-1.553')
})
it('retourne null sans coordonnees (etape a geolocaliser)', () => {
expect(buildNavigationLinks(null)).toBeNull()
expect(buildNavigationLinks({ latitude: 47.2 })).toBeNull()
expect(buildNavigationLinks({ latitude: null, longitude: null })).toBeNull()
})
})
describe('isStopLocated', () => {
it('distingue une etape geolocalisee d\'une etape sans coordonnees', () => {
expect(isStopLocated({ latitude: 47.2, longitude: -1.5 })).toBe(true)
expect(isStopLocated({ latitude: null, longitude: null })).toBe(false)
})
})
describe('formatteurs', () => {
it('formate distances et durees', () => {
expect(formatDistance(850)).toBe('850 m')
expect(formatDistance(12_340)).toBe('12,3 km')
expect(formatDistance(null)).toBe('—')
expect(formatDuration(1_500)).toBe('25 min')
expect(formatDuration(5_100)).toBe('1 h 25')
expect(formatDuration(null)).toBe('—')
})
it('extrait l\'heure HH:MM d\'une chaine ISO', () => {
expect(formatTime('1970-01-01T08:30:00+00:00')).toBe('08:30')
expect(formatTime(null)).toBe('—')
})
})
@@ -1,178 +0,0 @@
import type { NavigationLinks, TierType, TourStop, TourTotals } from '~/modules/field-sales/types/tour'
/**
* Composable de planification de tournee (M6.5).
*
* Porte la logique PURE de l'ecran de planification, isolee de Vue/Nuxt pour
* etre testable directement (Vitest) :
* - reordonnancement des etapes (drag & drop) + renumerotation des positions ;
* - recalcul instantane des totaux (trajets + visites) pour le feedback UI,
* avant le retour serveur du /compute ;
* - construction des deep links de navigation « Y aller » (Waze/Google/Apple).
*
* Les coordonnees et libelles des etapes sur Tiers referentiel ne sont pas
* portes par tour_stop:read : l'ecran les resout via GET /visitable_tiers/{id}
* et alimente un `PlanningStop` enrichi, sur lequel operent ces fonctions.
*/
/** Coordonnees WGS84 minimales d'une cible. */
export interface LatLng {
latitude: number
longitude: number
}
/**
* Etape « enrichie » manipulee par l'ecran : l'etape API + le libelle, l'adresse
* et les coordonnees resolus (depuis le Tiers pour une etape referentiel, depuis
* les colonnes custom_* pour un point libre).
*/
export interface PlanningStop extends TourStop {
/** Nom affichable (raison sociale du Tiers ou libelle du point libre). */
label: string
/** Adresse formatee sur une ligne. */
displayAddress: string
/** Coordonnees resolues, ou null si l'etape n'est pas geolocalisee (RG-6.05). */
latitude: number | null
longitude: number | null
}
/** Vitesse moyenne par defaut (km/h) — alignee sur HaversineRouteEngine (back). */
const DEFAULT_SPEED_KMH = 50
/**
* Deplace l'etape `fromIndex` vers `toIndex` et renumerote toutes les positions
* (0-indexees, contigues). Retourne un NOUVEAU tableau (pas de mutation).
*/
export function reorderStops<T extends { position: number }>(stops: readonly T[], fromIndex: number, toIndex: number): T[] {
const next = [...stops]
if (fromIndex < 0 || fromIndex >= next.length || toIndex < 0 || toIndex >= next.length) {
return next
}
const [moved] = next.splice(fromIndex, 1)
if (moved === undefined) {
return next
}
next.splice(toIndex, 0, moved)
return next.map((stop, index) => ({ ...stop, position: index }))
}
/**
* Recalcule les totaux d'une tournee a partir des legs deja calcules et des
* durees de visite (RG-6.11). Duree totale = trajets + visites.
*/
export function computeTotals(stops: readonly PlanningStop[], defaultVisitMinutes: number): TourTotals {
let totalDistanceM = 0
let travelDurationS = 0
let visitDurationS = 0
for (const stop of stops) {
totalDistanceM += stop.legDistanceM ?? 0
travelDurationS += stop.legDurationS ?? 0
visitDurationS += (stop.visitMinutes ?? defaultVisitMinutes) * 60
}
return {
totalDistanceM,
travelDurationS,
visitDurationS,
totalDurationS: travelDurationS + visitDurationS,
visitCount: stops.length,
}
}
/**
* Deep links de navigation vers une cible geolocalisee (spec M6 § 6.1).
* Waze/Google Maps ne prennent qu'UNE destination -> navigation etape par etape
* (HP-M6-7 assume). Retourne null si la cible n'a pas de coordonnees.
*/
export function buildNavigationLinks(target: { latitude?: number | null, longitude?: number | null } | null): NavigationLinks | null {
if (target == null || target.latitude == null || target.longitude == null) {
return null
}
const lat = target.latitude
const lng = target.longitude
return {
waze: `https://waze.com/ul?ll=${lat},${lng}&navigate=yes`,
google: `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`,
apple: `https://maps.apple.com/?daddr=${lat},${lng}`,
}
}
/** Vrai si l'etape est geolocalisee (entre dans le calcul de trajet, RG-6.05). */
export function isStopLocated(stop: Pick<PlanningStop, 'latitude' | 'longitude'>): boolean {
return stop.latitude != null && stop.longitude != null
}
/** Estime une duree de trajet (s) a partir d'une distance (m) et la vitesse moyenne. */
export function estimateDurationSeconds(distanceMeters: number, speedKmh: number = DEFAULT_SPEED_KMH): number {
if (speedKmh <= 0) {
return 0
}
return Math.round((distanceMeters / 1000) / speedKmh * 3600)
}
/** Formate une distance (m) en « 12,3 km » ou « 850 m ». */
export function formatDistance(meters: number | null): string {
if (meters == null) {
return '—'
}
if (meters < 1000) {
return `${Math.round(meters)} m`
}
return `${(meters / 1000).toFixed(1).replace('.', ',')} km`
}
/** Formate une duree (s) en « 1 h 25 » ou « 25 min ». */
export function formatDuration(seconds: number | null): string {
if (seconds == null) {
return '—'
}
const totalMinutes = Math.round(seconds / 60)
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60
if (hours === 0) {
return `${minutes} min`
}
return `${hours} h ${String(minutes).padStart(2, '0')}`
}
/** Extrait l'heure « HH:MM » d'une chaine ISO (eta / departureTime). */
export function formatTime(iso: string | null): string {
if (iso == null || iso === '') {
return '—'
}
const match = iso.match(/(\d{2}):(\d{2})/)
return match ? `${match[1]}:${match[2]}` : '—'
}
/** Libelle FR court d'un type de Tiers (pour la couleur/le badge du pin). */
export function tierTypeLabel(type: TierType): string {
switch (type) {
case 'client':
return 'Client'
case 'supplier':
return 'Fournisseur'
default:
return 'Point libre'
}
}
export function useTourPlanning() {
return {
reorderStops,
computeTotals,
buildNavigationLinks,
isStopLocated,
estimateDurationSeconds,
formatDistance,
formatDuration,
formatTime,
tierTypeLabel,
}
}
@@ -1,15 +0,0 @@
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
import type { Tour } from '~/modules/field-sales/types/tour'
/**
* Liste paginee des tournees (GET /api/tours), branchee sur usePaginatedList
* (regle ABSOLUE n°13 : toute collection est paginee). Tri par date decroissante
* par defaut. Le filtre `owner` est applique cote back (RG-6.01) rien a passer
* ici.
*/
export function useToursRepository() {
return usePaginatedList<Tour>({
url: '/tours',
defaultSort: { field: 'tourDate', direction: 'desc' },
})
}
@@ -1,4 +0,0 @@
// Layer Nuxt du module « Tournées » (field_sales, M6). Auto-detecte par le
// shell via le scan de frontend/modules/*/. Config minimale : pages,
// composants et composables sont decouverts par convention.
export default defineNuxtConfig({})
@@ -1,739 +0,0 @@
<template>
<div>
<!-- Entete : retour + nom de la tournee. -->
<div class="flex items-center gap-3 pt-6 pb-4">
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="t('field_sales.plan.back')" @click="goBack" />
<h1 class="truncate text-[24px] font-semibold text-primary-500">
{{ tour?.label ?? t('field_sales.plan.title') }}
</h1>
</div>
<!-- Layout split responsive : carte + panneau cote a cote en desktop,
empile en mobile (carte au-dessus). Etat 100 % local. -->
<div class="flex flex-col gap-4 lg:h-[calc(100vh-180px)] lg:flex-row">
<!-- Carte interactive. -->
<div class="relative h-[45vh] overflow-hidden rounded border border-gray-200 lg:h-auto lg:flex-1">
<TourMap
ref="mapRef"
:stops="stops"
:types="activeTypes"
:search="mapSearch"
:center="mapCenter"
:start="mapStart"
@add-tier="addTier"
@add-tiers="addTiers"
/>
<!-- Filtres de la carte (types + recherche). -->
<div class="absolute right-2 top-2 z-[400] flex flex-col gap-2 rounded bg-white/95 p-2 shadow">
<MalioInputText
v-model="mapSearch"
:placeholder="t('field_sales.plan.map.search')"
icon-name="mdi:magnify"
:reserve-message-space="false"
input-class="w-44"
/>
<div class="flex gap-3 text-xs">
<label class="flex items-center gap-1">
<input v-model="showClients" type="checkbox" class="accent-blue-600"> {{ t('field_sales.plan.map.typeClient') }}
</label>
<label class="flex items-center gap-1">
<input v-model="showSuppliers" type="checkbox" class="accent-green-600"> {{ t('field_sales.plan.map.typeSupplier') }}
</label>
</div>
</div>
</div>
<!-- Panneau tournee. -->
<div class="flex flex-col gap-4 overflow-y-auto rounded border border-gray-200 p-4 lg:w-[420px]">
<!-- Parametres de la tournee. -->
<div class="flex flex-col gap-3">
<MalioInputText
v-model="panel.label"
:label="t('field_sales.plan.panel.label')"
@update:model-value="debouncedSaveLabel"
/>
<div class="flex gap-3">
<MalioDate v-model="panel.tourDate" :label="t('field_sales.plan.panel.date')" class="flex-1" @update:model-value="saveDate" />
<MalioTime v-model="panel.departureTime" :label="t('field_sales.plan.panel.departureTime')" class="flex-1" @update:model-value="saveDepartureTime" />
</div>
<!-- Point de départ : un de mes sites OU une adresse libre (BAN). -->
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-gray-700">{{ t('field_sales.plan.panel.startLabel') }}</span>
<div class="flex gap-4">
<MalioRadioButton
v-model="startMode"
name="start-mode"
value="site"
:label="t('field_sales.plan.panel.startModeSite')"
:disabled="userSites.length === 0"
group-class="mt-0"
/>
<MalioRadioButton
v-model="startMode"
name="start-mode"
value="custom"
:label="t('field_sales.plan.panel.startModeCustom')"
group-class="mt-0"
/>
</div>
<MalioSelect
v-if="startMode === 'site'"
:model-value="selectedSiteId"
:options="siteOptions"
:empty-option-label="t('field_sales.plan.panel.startSitePlaceholder')"
@update:model-value="onSiteSelect"
/>
<MalioInputAutocomplete
v-else
:model-value="panel.startLabel"
:options="startAddressOptions"
:loading="startAddressLoading"
:min-search-length="3"
:allow-create="true"
:no-results-text="t('field_sales.plan.panel.startNoResults')"
@update:model-value="onStartLabelInput"
@search="onStartAddressSearch"
@select="onStartAddressSelect"
/>
</div>
<MalioInputNumber
v-model="panel.defaultVisitMinutes"
:label="t('field_sales.plan.panel.defaultVisitMinutes')"
:min="0"
@update:model-value="debouncedSaveVisitMinutes"
/>
</div>
<!-- Totaux. -->
<div class="grid grid-cols-3 gap-2 rounded bg-gray-50 p-3 text-center">
<div>
<p class="text-xs text-gray-500">{{ t('field_sales.plan.panel.distance') }}</p>
<p class="font-semibold">{{ formatDistance(totals.totalDistanceM) }}</p>
</div>
<div>
<p class="text-xs text-gray-500">{{ t('field_sales.plan.panel.duration') }}</p>
<p class="font-semibold">{{ formatDuration(totals.totalDurationS) }}</p>
</div>
<div>
<p class="text-xs text-gray-500">{{ t('field_sales.plan.panel.visits') }}</p>
<p class="font-semibold">{{ totals.visitCount }}</p>
</div>
</div>
<!-- Actions tournee. -->
<div class="flex flex-wrap gap-2">
<MalioButton variant="primary" :label="t('field_sales.plan.actions.compute')" :disabled="busy" @click="runCompute" />
<MalioButton variant="secondary" :label="t('field_sales.plan.actions.optimize')" :disabled="busy" @click="runOptimize" />
<MalioButton variant="tertiary" :label="t('field_sales.plan.actions.duplicate')" :disabled="busy" @click="duplicateOpen = true" />
<MalioButton variant="tertiary" :label="t('field_sales.plan.actions.pdf')" @click="openPdf" />
</div>
<!-- Etapes draggables. -->
<div>
<div class="mb-2 flex items-center justify-between">
<h2 class="font-semibold text-gray-800">{{ t('field_sales.plan.panel.stops') }}</h2>
</div>
<TourStopList
:stops="stops"
@reorder="onReorder"
@remove="removeStop"
@view-tier="viewTier"
/>
</div>
</div>
</div>
<!-- Modale : duplication. -->
<MalioModal v-model="duplicateOpen" modal-class="max-w-md">
<template #header>
<h2 class="text-[22px] font-bold">{{ t('field_sales.plan.duplicateModal.title') }}</h2>
</template>
<MalioDate v-model="duplicateDate" :label="t('field_sales.plan.duplicateModal.date')" required :error="duplicateError" />
<template #footer>
<MalioButton variant="secondary" :label="t('field_sales.plan.duplicateModal.cancel')" button-class="flex-1" @click="duplicateOpen = false" />
<MalioButton variant="primary" :label="t('field_sales.plan.duplicateModal.confirm')" button-class="flex-1" :disabled="busy" @click="confirmDuplicate" />
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import TourMap from '~/modules/field-sales/components/TourMap.vue'
import TourStopList from '~/modules/field-sales/components/TourStopList.vue'
import {
computeTotals,
formatDistance,
formatDuration,
formatTime,
type PlanningStop,
} from '~/modules/field-sales/composables/useTourPlanning'
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import { siteFullAddress, siteOptionLabel } from '~/modules/field-sales/utils/startPoint'
import type { Tour, TourStop, VisitableTier } from '~/modules/field-sales/types/tour'
import type { Site } from '~/shared/types/sites'
/**
* Ecran de planification d'une tournee (M6.5, spec § 6.1).
*
* Carte interactive (pins + lasso + trace) a gauche, panneau (parametres,
* totaux, actions, etapes draggables) a droite ; empile en mobile. Etat 100 %
* LOCAL (jamais dans l'URL, regle ABSOLUE n°6).
*
* Les etapes sur Tiers referentiel ne portent pas leurs coordonnees/nom dans
* tour_stop:read : on les resout via GET /visitable_tiers/{type}-{addressId}
* (cache local) pour alimenter des `PlanningStop` enrichis.
*/
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const route = useRoute()
const toast = useToast()
const autocomplete = useAddressAutocomplete()
const authStore = useAuthStore()
const tourId = computed(() => Number(route.params.id))
// Point de départ : choix entre « mes sites » et « adresse libre »
// Les sites de l'utilisateur (embarqués dans /api/me) servent de départs
// pré-enregistrés ; sinon une adresse libre géocodée via la BAN.
const userSites = computed<Site[]>(() => authStore.user?.sites ?? [])
const startMode = ref<'site' | 'custom'>('custom')
const selectedSiteId = ref<number | null>(null)
// Le mode de départ n'est dérivé du back qu'au premier chargement (cf. applyTour).
const startModeInitialized = ref(false)
const siteOptions = computed(() => userSites.value.map(s => ({ value: s.id, label: siteOptionLabel(s) })))
// Suggestions BAN du champ « adresse libre » (mode custom).
const startAddressOptions = ref<Array<{ value: string, label: string }>>([])
const startAddressLoading = ref(false)
let startAddressSuggestions: AddressSuggestion[] = []
/** Libellé stocké quand on choisit un site : « Site de {nom} — {adresse} ». */
function composeSiteStartLabel(site: Site): string {
return `${t('field_sales.plan.panel.startSitePrefix', { name: site.name })} — ${siteFullAddress(site)}`
}
const tour = ref<Tour | null>(null)
const stops = ref<PlanningStop[]>([])
const busy = ref(false)
const mapRef = ref<InstanceType<typeof TourMap> | null>(null)
useHead({ title: () => tour.value?.label ?? t('field_sales.plan.title') })
// Cache des infos Tiers (nom/adresse/coords) par cle « type-addressId » : evite
// de refetcher /visitable_tiers/{id} a chaque recompute.
const tierCache = new Map<string, { label: string, displayAddress: string, latitude: number, longitude: number }>()
// Panneau (formulaire local synchronise avec la tournee)
// defaultVisitMinutes est une chaine (MalioInputNumber est un v-model string).
const panel = reactive<{
label: string
tourDate: string | null
departureTime: string | null
startLabel: string
defaultVisitMinutes: string
}>({
label: '',
tourDate: null,
departureTime: null,
startLabel: '',
defaultVisitMinutes: '30',
})
/** Debounce simple (les saves lisent l'etat `panel`, donc sans argument). */
function debounce(fn: () => void, ms: number): () => void {
let handle: ReturnType<typeof setTimeout> | null = null
return () => {
if (handle !== null) {
clearTimeout(handle)
}
handle = setTimeout(fn, ms)
}
}
const debouncedSaveLabel = debounce(() => { void saveLabel() }, 600)
const debouncedSaveStart = debounce(() => { void saveStart() }, 800)
const debouncedSaveVisitMinutes = debounce(() => { void saveVisitMinutes() }, 600)
// Carte : filtres
const showClients = ref(true)
const showSuppliers = ref(true)
const mapSearch = ref('')
const activeTypes = computed<Array<'client' | 'supplier'>>(() => {
const types: Array<'client' | 'supplier'> = []
if (showClients.value) {
types.push('client')
}
if (showSuppliers.value) {
types.push('supplier')
}
return types
})
const mapCenter = ref<[number, number]>([47.218, -1.553])
// Point de départ géolocalisé à afficher sur la carte (marqueur « maison »).
// Le back stocke lat/lng en chaînes ; null tant que la BAN n'a rien géocodé.
const mapStart = computed<{ latitude: number, longitude: number, label?: string } | null>(() => {
const lat = tour.value?.startLatitude
const lng = tour.value?.startLongitude
if (lat == null || lng == null) {
return null
}
const latNum = Number(lat)
const lngNum = Number(lng)
if (!Number.isFinite(latNum) || !Number.isFinite(lngNum)) {
return null
}
return { latitude: latNum, longitude: lngNum, label: tour.value?.startLabel ?? undefined }
})
// Totaux recalcules localement (feedback instantane)
const totals = computed(() => computeTotals(stops.value, Number(panel.defaultVisitMinutes) || 0))
// Modales
const duplicateOpen = ref(false)
const duplicateDate = ref<string | null>(null)
const duplicateError = ref('')
// =============================================================================
// Chargement + enrichissement
// =============================================================================
onMounted(loadTour)
async function loadTour(): Promise<void> {
try {
const raw = await api.get<Tour>(`/tours/${tourId.value}`, {}, {
headers: { Accept: 'application/ld+json' },
toast: false,
})
await applyTour(raw, true)
}
catch {
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.loadError') })
router.push('/tours')
}
}
/** Applique une reponse Tour au state local. `withStops` re-enrichit les etapes. */
async function applyTour(raw: Tour, withStops: boolean): Promise<void> {
tour.value = raw
panel.label = raw.label
panel.tourDate = raw.tourDate ? raw.tourDate.slice(0, 10) : null
panel.departureTime = extractTime(raw.departureTime)
panel.startLabel = raw.startLabel ?? ''
panel.defaultVisitMinutes = String(raw.defaultVisitMinutes)
// Mode du point de départ : dérivé UNE SEULE FOIS, au premier chargement de
// la tournée. Les `patchTour` suivants (sauvegardes) rappellent `applyTour`
// mais ne doivent pas réécraser le choix explicite de l'utilisateur (sinon
// sélectionner un site, qui déclenche un PATCH, ré-évalue le mode et peut
// retomber en « adresse libre » sur la moindre divergence de libellé).
if (!startModeInitialized.value) {
const matchedSite = userSites.value.find(s => composeSiteStartLabel(s) === panel.startLabel)
if (matchedSite) {
startMode.value = 'site'
selectedSiteId.value = matchedSite.id
}
else {
startMode.value = panel.startLabel === '' && userSites.value.length > 0 ? 'site' : 'custom'
selectedSiteId.value = null
}
startModeInitialized.value = true
}
if (withStops) {
stops.value = await enrichStops(raw.stops ?? [])
recenterOnFirstStop()
}
}
/** Resout nom/adresse/coords de chaque etape en `PlanningStop`. */
async function enrichStops(rawStops: TourStop[]): Promise<PlanningStop[]> {
const ordered = [...rawStops].sort((a, b) => a.position - b.position)
return Promise.all(ordered.map(async (stop): Promise<PlanningStop> => {
if (stop.tierType === 'custom') {
return {
...stop,
label: stop.customLabel ?? '',
displayAddress: stop.customAddress ?? '',
latitude: stop.customLatitude != null ? Number(stop.customLatitude) : null,
longitude: stop.customLongitude != null ? Number(stop.customLongitude) : null,
}
}
const info = await resolveTier(stop.tierType, stop.addressId)
return {
...stop,
label: info?.label ?? `#${stop.tierId}`,
displayAddress: info?.displayAddress ?? '',
latitude: info?.latitude ?? null,
longitude: info?.longitude ?? null,
}
}))
}
/** Infos d'un Tiers (cache + GET /visitable_tiers/{type-addressId}). */
async function resolveTier(tierType: string, addressId: number | null) {
if (addressId === null) {
return null
}
const key = `${tierType}-${addressId}`
const cached = tierCache.get(key)
if (cached) {
return cached
}
try {
const tier = await api.get<VisitableTier>(`/visitable_tiers/${key}`, {}, {
headers: { Accept: 'application/ld+json' },
toast: false,
})
const info = {
label: tier.displayName,
displayAddress: tier.address,
latitude: tier.latitude,
longitude: tier.longitude,
}
tierCache.set(key, info)
return info
}
catch {
return null
}
}
function recenterOnFirstStop(): void {
const located = stops.value.find(s => s.latitude != null && s.longitude != null)
if (located) {
mapCenter.value = [located.latitude as number, located.longitude as number]
}
}
// =============================================================================
// Ajout d'etapes (carte)
// =============================================================================
async function addTier(tier: VisitableTier): Promise<void> {
// Pre-alimente le cache (la carte connait deja nom/adresse/coords du pin).
tierCache.set(`${tier.tierType}-${tier.addressId}`, {
label: tier.displayName,
displayAddress: tier.address,
latitude: tier.latitude,
longitude: tier.longitude,
})
await postStop({
tierType: tier.tierType,
tierId: tier.tierId,
addressId: tier.addressId,
position: stops.value.length,
})
}
async function addTiers(tiers: VisitableTier[]): Promise<void> {
if (busy.value) {
return
}
busy.value = true
try {
let position = stops.value.length
for (const tier of tiers) {
tierCache.set(`${tier.tierType}-${tier.addressId}`, {
label: tier.displayName,
displayAddress: tier.address,
latitude: tier.latitude,
longitude: tier.longitude,
})
await api.post('/tours/' + tourId.value + '/stops', {
tierType: tier.tierType,
tierId: tier.tierId,
addressId: tier.addressId,
position: position++,
}, { toast: false })
}
await runCompute()
}
catch {
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.stopError') })
}
finally {
busy.value = false
}
}
/** POST d'une etape puis recompute (factorise add tier / custom). */
async function postStop(payload: Record<string, unknown>): Promise<void> {
if (busy.value) {
return
}
busy.value = true
try {
await api.post('/tours/' + tourId.value + '/stops', payload, { toast: false })
await runCompute()
}
catch {
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.stopError') })
}
finally {
busy.value = false
}
}
// =============================================================================
// Reordonnancement / suppression / navigation
// =============================================================================
async function onReorder(next: PlanningStop[]): Promise<void> {
if (busy.value) {
return
}
// Optimisme : on reflete l'ordre immediatement, le serveur recalcule ensuite.
stops.value = next.map((s, i) => ({ ...s, position: i }))
busy.value = true
try {
const raw = await api.post<Tour>('/tours/' + tourId.value + '/reorder', {
stopIds: next.map(s => s.id),
}, { headers: { Accept: 'application/ld+json' }, toast: false })
await applyTour(raw, true)
}
catch {
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.stopError') })
await loadTour()
}
finally {
busy.value = false
}
}
async function removeStop(stop: PlanningStop): Promise<void> {
if (busy.value) {
return
}
busy.value = true
try {
await api.delete(`/tour_stops/${stop.id}`, {}, { toast: false })
await runCompute()
}
catch {
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.stopError') })
}
finally {
busy.value = false
}
}
function viewTier(stop: PlanningStop): void {
if (stop.tierId === null) {
return
}
router.push(stop.tierType === 'supplier' ? `/suppliers/${stop.tierId}` : `/clients/${stop.tierId}`)
}
// =============================================================================
// Actions tournee : compute / optimize / duplicate / pdf
// =============================================================================
async function runCompute(): Promise<void> {
await runTourAction('/compute', 'computeError')
}
async function runOptimize(): Promise<void> {
await runTourAction('/optimize', 'optimizeError')
}
/** Factorise compute/optimize : POST sans corps -> reapplique la tournee. */
async function runTourAction(path: string, errorKey: string): Promise<void> {
const wasBusy = busy.value
busy.value = true
try {
const raw = await api.post<Tour>('/tours/' + tourId.value + path, {}, {
headers: { Accept: 'application/ld+json' },
toast: false,
})
await applyTour(raw, true)
}
catch {
toast.error({ title: t('errors.title'), message: t(`field_sales.plan.toast.${errorKey}`) })
}
finally {
busy.value = wasBusy ? busy.value : false
}
}
async function confirmDuplicate(): Promise<void> {
duplicateError.value = ''
if (duplicateDate.value === null || duplicateDate.value === '') {
duplicateError.value = t('field_sales.plan.duplicateModal.date')
return
}
busy.value = true
try {
const copy = await api.post<Tour>('/tours/' + tourId.value + '/duplicate', {
tourDate: duplicateDate.value,
}, { headers: { Accept: 'application/ld+json' }, toast: false })
toast.success({ title: t('field_sales.tours.title'), message: t('field_sales.plan.toast.duplicated') })
duplicateOpen.value = false
router.push(`/tours/${copy.id}/plan`)
}
catch {
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.duplicateError') })
}
finally {
busy.value = false
}
}
/** Ouvre la feuille de route PDF (le cookie JWT est envoye avec la requete). */
function openPdf(): void {
window.open(`/api/tours/${tourId.value}/roadbook.pdf`, '_blank')
}
// =============================================================================
// Sauvegarde des parametres du panneau (PATCH, recompute si ETA impactee)
// =============================================================================
async function saveLabel(): Promise<void> {
if (panel.label.trim() !== '' && panel.label !== tour.value?.label) {
await patchTour({ label: panel.label.trim() }, false)
}
}
async function saveDate(): Promise<void> {
if (panel.tourDate) {
await patchTour({ tourDate: panel.tourDate }, false)
}
}
async function saveDepartureTime(): Promise<void> {
if (panel.departureTime) {
await patchTour({ departureTime: panel.departureTime }, true)
}
}
async function saveVisitMinutes(): Promise<void> {
const minutes = Number(panel.defaultVisitMinutes)
if (!Number.isFinite(minutes) || minutes < 0) {
return
}
await patchTour({ defaultVisitMinutes: minutes }, true)
}
/**
* Persiste le point de départ : `label` est ce qui est stocké/affiché (ex.
* « Site de Châtellerault » ou l'adresse libre), `geocodeQuery` l'adresse
* postale réellement géocodée via la BAN. Coords nulles si la BAN ne trouve rien
* (le badge « à géolocaliser » s'affiche, la tournée reste sauvegardable).
*/
async function persistStart(label: string, geocodeQuery: string): Promise<void> {
const trimmed = label.trim()
if (trimmed === '' && (tour.value?.startLabel ?? '') === '') {
return
}
let coords: { latitude: string, longitude: string } | null = null
if (geocodeQuery.trim() !== '') {
try {
coords = await autocomplete.geocode(geocodeQuery.trim())
}
catch {
coords = null
}
}
await patchTour({
startLabel: trimmed === '' ? null : trimmed,
startLatitude: coords?.latitude ?? null,
startLongitude: coords?.longitude ?? null,
}, true)
}
/** Mode « adresse libre » : saisie au clavier → géocode le texte tel quel. */
async function saveStart(): Promise<void> {
await persistStart(panel.startLabel, panel.startLabel)
}
/** Met à jour le texte du champ « adresse libre » (puis save débouncé). */
function onStartLabelInput(value: string | number | null): void {
panel.startLabel = value === null ? '' : String(value)
debouncedSaveStart()
}
/** Mode « mes sites » : choix d'un site → libellé « Site de … » + géocodage de son adresse. */
async function onSiteSelect(value: string | number | null): Promise<void> {
const id = value === null || value === '' ? null : Number(value)
selectedSiteId.value = id
const site = userSites.value.find(s => s.id === id)
if (!site) {
panel.startLabel = ''
await persistStart('', '')
return
}
const label = composeSiteStartLabel(site)
panel.startLabel = label
await persistStart(label, siteFullAddress(site))
}
/** Recherche d'adresse assistée (event de MalioInputAutocomplete, mode libre). */
async function onStartAddressSearch(query: string): Promise<void> {
if (query.trim().length < 3) {
startAddressOptions.value = []
return
}
startAddressLoading.value = true
try {
const suggestions = await autocomplete.searchAddress(query)
startAddressSuggestions = suggestions
startAddressOptions.value = suggestions.map(s => ({ value: s.label, label: s.label }))
}
catch {
// Erreur transitoire : on vide les suggestions (la frappe suivante réessaie).
startAddressOptions.value = []
}
finally {
startAddressLoading.value = false
}
}
/** Sélection d'une suggestion d'adresse libre → libellé = adresse, puis géocodage. */
async function onStartAddressSelect(option: { label: string, value: string | number } | null): Promise<void> {
if (option === null) {
return
}
const suggestion = startAddressSuggestions.find(s => s.label === option.value)
const label = suggestion?.label ?? String(option.value)
panel.startLabel = label
await persistStart(label, label)
}
/** PATCH /tours/{id}. `recompute` enchaine /compute (ETA impactee). */
async function patchTour(partial: Record<string, unknown>, recompute: boolean): Promise<void> {
busy.value = true
try {
const raw = await api.patch<Tour>(`/tours/${tourId.value}`, partial, {
headers: { Accept: 'application/ld+json' },
toast: false,
})
// PATCH renvoie tour:read SANS etapes : on ne touche pas a `stops`.
await applyTour(raw, false)
if (recompute) {
await runCompute()
}
}
catch {
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.saveError') })
}
finally {
busy.value = false
}
}
// =============================================================================
// Utilitaires
// =============================================================================
function extractTime(iso: string | null): string | null {
const formatted = formatTime(iso)
return formatted === '—' ? null : formatted
}
function goBack(): void {
router.push('/tours')
}
</script>
@@ -1,124 +0,0 @@
<template>
<div>
<PageHeader>
{{ t('field_sales.tours.title') }}
<template #actions>
<MalioButton
v-if="canManage"
variant="secondary"
:label="t('field_sales.tours.add')"
icon-name="mdi:add-bold"
icon-position="left"
@click="goToCreate"
/>
</template>
</PageHeader>
<MalioDataTable
:columns="columns"
:items="rows"
:total-items="totalItems"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
row-clickable
:empty-message="t('field_sales.tours.empty')"
@row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
>
<template #cell-tourDate="{ item }">
{{ formatDate(item.tourDate as string) }}
</template>
<template #cell-status="{ item }">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium" :class="statusClass(item.status as TourStatus)">
{{ t(`field_sales.tours.status.${item.status}`) }}
</span>
</template>
<template #cell-distance="{ item }">
{{ formatDistance((item.totalDistanceM as number | null) ?? null) }}
</template>
<template #cell-duration="{ item }">
{{ formatDuration((item.totalDurationS as number | null) ?? null) }}
</template>
</MalioDataTable>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useToursRepository } from '~/modules/field-sales/composables/useToursRepository'
import { formatDistance, formatDuration } from '~/modules/field-sales/composables/useTourPlanning'
import type { Tour, TourStatus } from '~/modules/field-sales/types/tour'
const { t } = useI18n()
const router = useRouter()
const { can } = usePermissions()
useHead({ title: t('field_sales.tours.title') })
const canManage = computed(() => can('field_sales.tours.manage'))
const {
items: tours,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: loadTours,
goToPage,
setItemsPerPage,
} = useToursRepository()
const rows = computed(() => tours.value.map(tour => ({
id: tour.id,
label: tour.label,
tourDate: tour.tourDate,
status: tour.status,
totalDistanceM: tour.totalDistanceM,
totalDurationS: tour.totalDurationS,
})))
const columns = [
{ key: 'label', label: t('field_sales.tours.column.label') },
{ key: 'tourDate', label: t('field_sales.tours.column.date') },
{ key: 'status', label: t('field_sales.tours.column.status') },
{ key: 'distance', label: t('field_sales.tours.column.distance') },
{ key: 'duration', label: t('field_sales.tours.column.duration') },
]
/** Couleur du badge de statut. */
function statusClass(status: TourStatus): string {
switch (status) {
case 'planned':
return 'bg-blue-100 text-blue-800'
case 'in_progress':
return 'bg-amber-100 text-amber-800'
case 'done':
return 'bg-green-100 text-green-800'
default:
return 'bg-gray-100 text-gray-700'
}
}
/** Date courte FR (la date arrive en ISO depuis l'API). */
function formatDate(iso: string): string {
if (!iso) {
return ''
}
const date = new Date(iso)
return Number.isNaN(date.getTime()) ? '' : date.toLocaleDateString('fr-FR')
}
/** Clic ligne → ecran de planification de la tournee. */
function onRowClick(item: Record<string, unknown>): void {
router.push(`/tours/${(item as { id: Tour['id'] }).id}/plan`)
}
function goToCreate(): void {
router.push('/tours/new')
}
onMounted(loadTours)
</script>
@@ -1,96 +0,0 @@
<template>
<div>
<PageHeader>
{{ t('field_sales.tours.new.title') }}
</PageHeader>
<div class="mx-auto max-w-xl">
<form class="flex flex-col gap-4" @submit.prevent="submit">
<MalioInputText
v-model="form.label"
:label="t('field_sales.tours.new.label')"
required
:error="errors.label"
/>
<MalioDate
v-model="form.tourDate"
:label="t('field_sales.tours.new.date')"
required
:error="errors.tourDate"
/>
<div class="mt-2 flex justify-end gap-3">
<MalioButton
variant="secondary"
:label="t('field_sales.tours.new.cancel')"
type="button"
@click="cancel"
/>
<MalioButton
variant="primary"
:label="t('field_sales.tours.new.create')"
type="submit"
:disabled="submitting"
/>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import type { Tour } from '~/modules/field-sales/types/tour'
/**
* Creation d'une tournee (draft). Formulaire minimal (nom + date) : le reste de
* la planification (etapes, point de depart, heure) se fait sur l'ecran de
* planification une fois la tournee creee. Validation inline 422 via useFormErrors.
*/
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const { can } = usePermissions()
const { errors, clearErrors, handleApiError } = useFormErrors()
useHead({ title: t('field_sales.tours.new.title') })
// Garde-fou : sans manage, on renvoie vers la liste (le back refuse de toute facon).
if (!can('field_sales.tours.manage')) {
router.replace('/tours')
}
const form = reactive<{ label: string, tourDate: string | null }>({
label: '',
tourDate: null,
})
const submitting = ref(false)
async function submit(): Promise<void> {
if (submitting.value) {
return
}
clearErrors()
submitting.value = true
try {
const tour = await api.post<Tour>('/tours', {
label: form.label,
tourDate: form.tourDate,
}, { toast: false })
// Enchaine directement sur la planification de la tournee creee.
router.push(`/tours/${tour.id}/plan`)
}
catch (e) {
handleApiError(e, { fallbackMessage: t('field_sales.tours.new.error') })
}
finally {
submitting.value = false
}
}
function cancel(): void {
router.push('/tours')
}
</script>
@@ -1,84 +0,0 @@
/**
* Types du module « Tournées » (field_sales, M6.5).
*
* Reflet des DTO exposes par l'API (groupes `tour:read` / `tour_stop:read` /
* VisitableTier). Les dates/heures arrivent en chaines ISO 8601 ; le formatage
* d'affichage (HH:MM, jj/mm/aaaa) est fait dans les ecrans.
*/
/** Type de Tiers visitable cote front (aligne sur l'enum ouvert du back). */
export type TierType = 'client' | 'supplier' | 'custom'
/** Cycle de vie d'une tournee (RG-6.02). */
export type TourStatus = 'draft' | 'planned' | 'in_progress' | 'done'
/** Une etape de tournee (tour_stop:read). */
export interface TourStop {
id: number
tierType: TierType
tierId: number | null
addressId: number | null
customLabel: string | null
customAddress: string | null
customLatitude: string | null
customLongitude: string | null
position: number
visitMinutes: number | null
/** Distance depuis l'etape precedente (m), calculee (compute). */
legDistanceM: number | null
/** Duree de trajet depuis l'etape precedente (s), calculee. */
legDurationS: number | null
/** Heure d'arrivee estimee (ISO time), calculee (RG-6.11). */
eta: string | null
}
/** Une tournee (tour:read + stops embarquees en tour:item:read). */
export interface Tour {
id: number
label: string
/** Date de realisation (ISO date). */
tourDate: string
/** Heure de depart (ISO time). */
departureTime: string
startLatitude: string | null
startLongitude: string | null
startLabel: string | null
defaultVisitMinutes: number
status: TourStatus
totalDistanceM: number | null
totalDurationS: number | null
stops?: TourStop[]
}
/** Un pin de la carte = une adresse geolocalisee d'un Tiers (VisitableTier). */
export interface VisitableTier {
id: string
tierType: Exclude<TierType, 'custom'>
tierId: number
addressId: number
displayName: string
address: string
latitude: number
longitude: number
}
/** Liens d'ouverture de navigation externe (« Y aller »). */
export interface NavigationLinks {
waze: string
google: string
apple: string
}
/** Totaux d'une tournee recalcules cote front (feedback instantane). */
export interface TourTotals {
/** Distance cumulee des trajets (m). */
totalDistanceM: number
/** Duree totale = trajets + visites (s). */
totalDurationS: number
/** Duree de trajet seule (s). */
travelDurationS: number
/** Duree de visite cumulee (s). */
visitDurationS: number
/** Nombre de visites (etapes). */
visitCount: number
}
@@ -1,35 +0,0 @@
import { describe, it, expect } from 'vitest'
import { siteFullAddress, siteOptionLabel, type StartSite } from '../startPoint'
function site(over: Partial<StartSite> = {}): StartSite {
return {
name: 'Châtellerault',
street: "14 allée d'Argenson",
postalCode: '86100',
city: 'Châtellerault',
...over,
}
}
describe('startPoint — siteFullAddress', () => {
it('utilise fullAddress du backend quand il est présent', () => {
expect(siteFullAddress(site({ fullAddress: "14 allée d'Argenson, 86100 Châtellerault" })))
.toBe("14 allée d'Argenson, 86100 Châtellerault")
})
it('recompose « rue, CP ville » quand fullAddress est absent', () => {
expect(siteFullAddress(site({ fullAddress: undefined })))
.toBe("14 allée d'Argenson, 86100 Châtellerault")
})
it('ignore les segments vides à la recomposition', () => {
expect(siteFullAddress({ name: 'X', street: '', postalCode: '79000', city: 'Niort' }))
.toBe('79000 Niort')
})
})
describe('startPoint — siteOptionLabel', () => {
it('formate « nom — code postal »', () => {
expect(siteOptionLabel(site())).toBe('Châtellerault — 86100')
})
})
@@ -1,37 +0,0 @@
/**
* Helpers purs du « Point de départ » d'une tournée (M6.5+).
*
* Le point de départ peut être choisi parmi les sites de l'utilisateur ou saisi
* en adresse libre (autocomplete BAN). Ces helpers ne touchent ni à l'API ni à
* l'état réactif : ils formatent des libellés à partir d'un site, donc testables
* unitairement (cf. startPoint.spec.ts). La composition du libellé stocké
* (`startLabel`) reste dans le composant car elle dépend d'i18n (préfixe
* « Site de »).
*/
/** Sous-ensemble d'un site nécessaire au formatage du point de départ. */
export interface StartSite {
name: string
street: string
postalCode: string
city: string
/** Adresse complète reconstituée côté backend (peut être absente). */
fullAddress?: string
}
/**
* Adresse postale complète d'un site (« rue, CP ville »), à géocoder via la BAN.
* Utilise `fullAddress` du backend si présent, sinon recompose depuis les champs.
*/
export function siteFullAddress(site: StartSite): string {
if (site.fullAddress && site.fullAddress.trim() !== '') {
return site.fullAddress.trim()
}
const cityLine = [site.postalCode, site.city].filter(Boolean).join(' ')
return [site.street, cityLine].filter(Boolean).join(', ')
}
/** Libellé d'une option du select de sites : « {nom} — {code postal} ». */
export function siteOptionLabel(site: Pick<StartSite, 'name' | 'postalCode'>): string {
return `${site.name}${site.postalCode}`
}
+5 -47
View File
@@ -7,19 +7,16 @@
"name": "starseed-frontend",
"hasInstallScript": true,
"dependencies": {
"@malio/layer-ui": "^1.7.8",
"@malio/layer-ui": "^1.7.10",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3",
"@types/leaflet": "^1.9.21",
"leaflet": "^1.9.4",
"nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0",
"pinia": "^3.0.4",
"vue": "^3.5.29",
"vue-router": "^4.6.4",
"vuedraggable": "^4.1.0"
"vue-router": "^4.6.4"
},
"devDependencies": {
"@nuxt/eslint-config": "^1.9.0",
@@ -1869,9 +1866,9 @@
"license": "MIT"
},
"node_modules/@malio/layer-ui": {
"version": "1.7.8",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.8/layer-ui-1.7.8.tgz",
"integrity": "sha512-gUMAZzBsPCfQUF3OQSjN/OFzjONvQZYfwqH0u5VUbxaqwBdX1hUGtjD4ym6RvZkyNsKulrxkncFZYTWCS+IdGA==",
"version": "1.7.10",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.10/layer-ui-1.7.10.tgz",
"integrity": "sha512-ZWYaKvl+VpGAqeTE+4xdyKOmuRd4zwjlUYVppeIBZwGeNAK16kZnrztR+4eQmnzUqPZVybBhEBdKP9weqWHSUg==",
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -5143,27 +5140,12 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/linkify-it": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz",
@@ -10550,12 +10532,6 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -15062,12 +15038,6 @@
"node": ">=20.0.0"
}
},
"node_modules/sortablejs": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
"license": "MIT"
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -17565,18 +17535,6 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/vuedraggable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
"license": "MIT",
"dependencies": {
"sortablejs": "1.14.0"
},
"peerDependencies": {
"vue": "^3.0.1"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+2 -5
View File
@@ -17,19 +17,16 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@malio/layer-ui": "^1.7.8",
"@malio/layer-ui": "^1.7.10",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3",
"@types/leaflet": "^1.9.21",
"leaflet": "^1.9.4",
"nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0",
"pinia": "^3.0.4",
"vue": "^3.5.29",
"vue-router": "^4.6.4",
"vuedraggable": "^4.1.0"
"vue-router": "^4.6.4"
},
"devDependencies": {
"@nuxt/eslint-config": "^1.9.0",
@@ -41,6 +41,22 @@ describe('useFormErrors', () => {
expect(hasErrors.value).toBe(true)
})
it('setServerErrors surcharge un message technique (erreur de type) par la cle i18n', () => {
const { errors, setServerErrors } = useFormErrors()
const mapped = setServerErrors({
violations: [
// Code Symfony Type::INVALID_TYPE_ERROR (date non parsable) : surcharge.
{ propertyPath: 'foundedAt', message: 'Cette valeur doit être de type DateTimeImmutable|null.', code: 'ba785a8c-82cb-4283-967c-3cf342181b40' },
// Violation metier classique : message back conserve.
{ propertyPath: 'companyName', message: 'Obligatoire.', code: 'c1051bb4-d103-4f74-8988-acbcafc7fdc3' },
],
})
expect(mapped).toBe(true)
// Stub i18n -> renvoie la cle telle quelle.
expect(errors.foundedAt).toBe('errors.validation.invalidDate')
expect(errors.companyName).toBe('Obligatoire.')
})
it('setServerErrors retourne false et ne touche rien sans violation', () => {
const { errors, setServerErrors } = useFormErrors()
expect(setServerErrors({})).toBe(false)
@@ -31,21 +31,9 @@ export interface AddressSuggestion {
city: string
}
/** Coordonnees WGS84 d'une adresse geocodee (chaines decimales, format API). */
export interface GeocodedCoordinates {
latitude: string
longitude: string
}
export interface AddressAutocomplete {
searchCity(postalCode: string): Promise<CitySuggestion[]>
searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]>
/**
* Geocode une adresse complete en coordonnees (M6.1) previsualisation du
* pin cote front uniquement : la valeur persistee reste celle du geocodage
* serveur (BanGeocoder) au save. `null` si la BAN ne trouve rien.
*/
geocode(query: string): Promise<GeocodedCoordinates | null>
}
/** Erreur signalant que le service d'autocompletion BAN n'est pas disponible. */
@@ -69,11 +57,7 @@ interface BanFeatureProperties {
/** Reponse GeoJSON FeatureCollection de la BAN. */
interface BanResponse {
features?: {
properties?: BanFeatureProperties
/** GeoJSON : coordinates = [longitude, latitude]. */
geometry?: { coordinates?: [number, number] }
}[]
features?: { properties?: BanFeatureProperties }[]
}
export function useAddressAutocomplete(): AddressAutocomplete {
@@ -129,32 +113,5 @@ export function useAddressAutocomplete(): AddressAutocomplete {
}
})
},
async geocode(query: string): Promise<GeocodedCoordinates | null> {
if (query.trim().length < 3) {
return null
}
let res: BanResponse
try {
res = await httpExternal<BanResponse>(BAN_SEARCH_URL, {
query: { q: query, limit: '1' },
})
}
catch {
throw new AddressAutocompleteUnavailableError()
}
const coordinates = res.features?.[0]?.geometry?.coordinates
if (!coordinates || coordinates.length < 2) {
return null
}
// GeoJSON = [longitude, latitude] ; 7 decimales = format NUMERIC(10,7).
return {
latitude: coordinates[1].toFixed(7),
longitude: coordinates[0].toFixed(7),
}
},
}
}
+10 -7
View File
@@ -17,7 +17,7 @@
* appel par ligne), utiliser directement `mapViolationsToRecord` par ligne.
*/
import { computed, reactive } from 'vue'
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
import { extractApiErrorMessage, extractApiViolations, resolveViolationMessage } from '~/shared/utils/api'
/**
* Erreur HTTP capturee par ofetch. On n'expose que les champs lus ici (status
@@ -69,13 +69,16 @@ export function useFormErrors() {
* violation exploitable).
*/
function setServerErrors(data: unknown): boolean {
const mapped = mapViolationsToRecord(data)
const keys = Object.keys(mapped)
if (keys.length === 0) return false
for (const key of keys) {
errors[key] = mapped[key]
const violations = extractApiViolations(data)
let mapped = false
for (const v of violations) {
if (!v.propertyPath) continue
// Message back tel quel, sauf code surcharge par une cle i18n (ex.
// erreur de type sur une date non parsable -> « Date invalide »).
errors[v.propertyPath] = resolveViolationMessage(v, t)
mapped = true
}
return true
return mapped
}
/**
+28 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { mapViolationsToRecord } from '../api'
import { mapViolationsToRecord, resolveViolationMessage } from '../api'
/**
* Tests de `mapViolationsToRecord` fondation du mapping erreurchamp des
@@ -56,3 +56,30 @@ describe('mapViolationsToRecord', () => {
expect(mapViolationsToRecord(data)).toEqual({ name: 'Second message.' })
})
})
/**
* Tests de `resolveViolationMessage` surcharge i18n d'un message back par code
* de violation. Le back peut renvoyer un message technique (erreur de type sur
* une date non parsable) : on le remplace via le `code` Symfony (stable) par une
* cle i18n, sans toucher au back. Le `t` ici renvoie la cle telle quelle.
*/
describe('resolveViolationMessage', () => {
const t = (key: string) => key
// Code Symfony Constraints\Type::INVALID_TYPE_ERROR (fige).
const TYPE_ERROR = 'ba785a8c-82cb-4283-967c-3cf342181b40'
it('surcharge le message technique d\'une erreur de type par la cle i18n', () => {
const v = { propertyPath: 'foundedAt', message: 'Cette valeur doit être de type DateTimeImmutable|null.', code: TYPE_ERROR }
expect(resolveViolationMessage(v, t)).toBe('errors.validation.invalidDate')
})
it('renvoie le message back tel quel quand le code n\'est pas surcharge', () => {
const v = { propertyPath: 'companyName', message: 'Le nom est obligatoire.', code: 'c1051bb4-d103-4f74-8988-acbcafc7fdc3' }
expect(resolveViolationMessage(v, t)).toBe('Le nom est obligatoire.')
})
it('renvoie le message back tel quel quand il n\'y a pas de code', () => {
const v = { propertyPath: 'siren', message: 'SIREN deja utilise.', code: '' }
expect(resolveViolationMessage(v, t)).toBe('SIREN deja utilise.')
})
})
+45 -1
View File
@@ -34,11 +34,15 @@ export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
/**
* Une violation de contrainte API Platform (reponse 422). Le `propertyPath`
* pointe le champ concerne, `message` est le libelle a afficher.
* pointe le champ concerne, `message` est le libelle a afficher, `code` est le
* code de contrainte Symfony (UUID stable, independant de la langue) il sert
* a surcharger un message back technique par une cle i18n (cf.
* `VIOLATION_MESSAGE_I18N` / `resolveViolationMessage`).
*/
export interface ApiViolation {
propertyPath: string
message: string
code: string
}
/**
@@ -61,6 +65,7 @@ export function extractApiViolations(data: unknown): ApiViolation[] {
out.push({
propertyPath: String(obj.propertyPath ?? ''),
message: String(obj.message ?? ''),
code: String(obj.code ?? ''),
})
}
return out
@@ -85,6 +90,45 @@ export function mapViolationsToRecord(data: unknown): Record<string, string> {
return out
}
/**
* Surcharge i18n d'un message back par CODE de violation.
*
* La plupart des contraintes back portent deja un message FR explicite (ex.
* `#[Assert\NotBlank(message: '...')]`) : on l'affiche tel quel. Mais certaines
* 422 portent un message TECHNIQUE non montrable a l'utilisateur typiquement
* l'erreur de TYPE renvoyee par API Platform quand le back ne peut pas
* denormaliser la valeur (date non parsable envoyee sur un champ
* `DateTimeImmutable` : « Cette valeur doit être de type DateTimeImmutable|null. »,
* voire en anglais selon la negociation de langue).
*
* Plutot que de traduire/maquiller cote back, on surcharge ces messages cote
* front via leur `code` de violation. Ce code est un UUID Symfony FIGE (contrat
* de compatibilite : il ne change pas entre versions), donc bien plus robuste
* qu'un match sur le texte du message (qui depend de la langue). La table
* associe un code -> une cle i18n ; `resolveViolationMessage` l'applique.
*
* Limite a connaitre : le code de type-error est GENERIQUE (toute valeur de
* mauvais type). Dans nos formulaires, seul un champ date saisi en texte libre
* (MalioDate, qui forwarde la saisie brute invalide) le declenche, d'ou le
* libelle « Date invalide ». Si un autre champ typé en saisie libre apparait,
* affiner la resolution via `propertyPath` plutot que par code seul.
*/
export const VIOLATION_MESSAGE_I18N: Record<string, string> = {
// Symfony `Constraints\Type::INVALID_TYPE_ERROR` — valeur de mauvais type.
'ba785a8c-82cb-4283-967c-3cf342181b40': 'errors.validation.invalidDate',
}
/**
* Resout le message a afficher pour une violation : si son `code` est surcharge
* par `VIOLATION_MESSAGE_I18N`, renvoie la traduction de la cle associee ;
* sinon, le message back tel quel (cas nominal). `t` est passe par l'appelant
* (les utils sont purs, sans acces a useI18n).
*/
export function resolveViolationMessage(v: ApiViolation, t: (key: string) => string): string {
const i18nKey = VIOLATION_MESSAGE_I18N[v.code]
return i18nKey ? t(i18nKey) : v.message
}
/**
* Extrait un message d'erreur lisible depuis un payload Hydra / JSON
* d'erreur API Platform. Essaie les champs courants dans l'ordre :
-6
View File
@@ -84,12 +84,6 @@ export const personas: Record<PersonaKey, Persona> = {
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
'commercial.suppliers.archive',
// FieldSales — Tournees (M6, ERP-123). Mappe sur le persona "tout",
// pas de nouveau persona (regle ABSOLUE n°7). La section "Tournées"
// n'est pas dans Administration, donc expectedAdminLinks inchange.
// Miroir de SeedE2ECommand.php.
'field_sales.tours.view',
'field_sales.tours.manage',
],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
},
-73
View File
@@ -1,73 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Commercial geolocalisation des adresses Tiers (M6.1 / ERP-122, spec M6
* § 3.2 / § 4.1).
*
* Ajoute sur `client_address` ET `supplier_address` :
* - latitude / longitude NUMERIC(10,7) null : coordonnees WGS84, alimentees
* par le geocodage BAN automatique ou par le pin manuel ;
* - geo_manual BOOLEAN default false : RG-6.08, un pin corrige a la main fige
* les coordonnees (le geocodage auto ne reecrit plus) ;
* - geocoded_at TIMESTAMPTZ null : date du dernier geocodage auto reussi.
*
* Migration au namespace racine `DoctrineMigrations` (pratique effective du
* projet : le namespace modulaire Commercial n'est pas enregistre dans
* doctrine_migrations.yaml et souffrirait du tri FQCN inter-namespaces sur
* base vide cf. regle ABSOLUE n°11 / architecture.md § Migrations).
*/
final class Version20260611130000 extends AbstractMigration
{
private const array TABLES = ['client_address', 'supplier_address'];
public function getDescription(): string
{
return 'Commercial : geolocalisation des adresses Tiers (latitude/longitude/geo_manual/geocoded_at sur client_address et supplier_address).';
}
public function up(Schema $schema): void
{
foreach (self::TABLES as $table) {
$this->addSql(sprintf('ALTER TABLE %s ADD COLUMN latitude NUMERIC(10, 7) DEFAULT NULL', $table));
$this->addSql(sprintf('ALTER TABLE %s ADD COLUMN longitude NUMERIC(10, 7) DEFAULT NULL', $table));
$this->addSql(sprintf('ALTER TABLE %s ADD COLUMN geo_manual BOOLEAN DEFAULT false NOT NULL', $table));
$this->addSql(sprintf('ALTER TABLE %s ADD COLUMN geocoded_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL', $table));
$this->comment($table, 'latitude', 'Latitude WGS84 de l adresse (geocodage BAN ou pin manuel). NULL = non geolocalisee, exclue du calcul de tournee (RG-6.05).');
$this->comment($table, 'longitude', 'Longitude WGS84 de l adresse (geocodage BAN ou pin manuel). NULL = non geolocalisee.');
$this->comment($table, 'geo_manual', 'Pin positionne/corrige a la main : si vrai, le geocodage auto ne reecrit plus les coordonnees (RG-6.08). Faux par defaut.');
$this->comment($table, 'geocoded_at', 'Date du dernier geocodage automatique reussi (NULL si jamais geocode ou pin 100% manuel).');
}
}
public function down(Schema $schema): void
{
foreach (self::TABLES as $table) {
$this->addSql(sprintf('ALTER TABLE %s DROP COLUMN latitude', $table));
$this->addSql(sprintf('ALTER TABLE %s DROP COLUMN longitude', $table));
$this->addSql(sprintf('ALTER TABLE %s DROP COLUMN geo_manual', $table));
$this->addSql(sprintf('ALTER TABLE %s DROP COLUMN geocoded_at', $table));
}
}
/**
* Emet un `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour
* eviter tout echappement.
*/
private function comment(string $table, string $column, string $description): void
{
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
'"'.str_replace('"', '""', $table).'"',
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
-218
View File
@@ -1,218 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M6.3 (ERP-124) Tournees commerciales terrain : creation des tables `tour`
* (tournee) et `tour_stop` (etape) du module FieldSales.
*
* SCOPE REDUIT (V0.2) : pas de rapport de visite -> `tour_stop` SANS report_id ni
* arrived_at / check-in.
*
* Particularites de modelisation :
* - tour.owner_id : FK -> "user".id, ON DELETE RESTRICT (tournee personnelle,
* RG-6.01 ; un user proprietaire d'une tournee ne peut etre supprime).
* - tour_stop.tier_id / address_id : entiers SANS FK. La cible d'une etape est
* polymorphe (Client M1 / Fournisseur M2 / point custom) resolue via
* tier_type ; aucune FK unique possible (RG-6.07 : pas d'unicite sur tier_id).
* - Unicite (tour_id, position) : un seul ordre par tournee (uq_tour_stop_position).
* - tour_stop.tour_id : FK -> tour.id, ON DELETE CASCADE.
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et non
* modulaire : la migration cree des FK cross-module (vers "user"). Avec plusieurs
* migrations_paths, Doctrine Migrations 3.x trie par FQCN alphabetique un
* namespace modulaire s'executerait avant la creation de "user" sur base vide.
* Le namespace racine garantit l'ordre par timestamp.
*
* Style DDL aligne sur M1/M2 (INT GENERATED BY DEFAULT AS IDENTITY,
* TIMESTAMP(0) WITHOUT TIME ZONE car le trait T/B mappe datetime_immutable),
* pour que `schema:update` reste un no-op. Chaque colonne porte son
* `COMMENT ON COLUMN` (regle ABSOLUE n°12) ; les 4 colonnes T/B via le catalogue
* partage. Les tables sont egalement mirorees dans ColumnCommentsCatalog pour
* que `app:apply-column-comments` rejoue les COMMENT apres le schema:update du
* setup de test (qui les drope sur les tables mappees par l'ORM).
*/
final class Version20260611140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-124 (M6.3) : tables tour + tour_stop (module FieldSales), sans rapport de visite (scope reduit V0.2).';
}
public function up(Schema $schema): void
{
$this->createTourTable();
$this->createTourStopTable();
}
public function down(Schema $schema): void
{
// Ordre inverse des dependances FK : tour_stop (FK -> tour) puis tour.
$this->addSql('DROP TABLE IF EXISTS tour_stop');
$this->addSql('DROP TABLE IF EXISTS tour');
}
// =================================================================
// Table `tour`
// =================================================================
private function createTourTable(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE tour (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
owner_id INT NOT NULL,
label VARCHAR(120) NOT NULL,
tour_date DATE NOT NULL,
departure_time TIME(0) WITHOUT TIME ZONE NOT NULL,
start_latitude NUMERIC(10, 7) DEFAULT NULL,
start_longitude NUMERIC(10, 7) DEFAULT NULL,
start_label VARCHAR(180) DEFAULT NULL,
default_visit_minutes SMALLINT DEFAULT 30 NOT NULL,
status VARCHAR(20) DEFAULT 'draft' NOT NULL,
total_distance_m INT DEFAULT NULL,
total_duration_s INT DEFAULT NULL,
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_tour_owner
FOREIGN KEY (owner_id) REFERENCES "user" (id) ON DELETE RESTRICT,
CONSTRAINT fk_tour_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_tour_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
$this->addSql('CREATE INDEX idx_tour_owner ON tour (owner_id)');
$this->addSql('CREATE INDEX idx_tour_status ON tour (status)');
$this->addSql('CREATE INDEX idx_tour_deleted_at ON tour (deleted_at)');
$this->addSql('CREATE INDEX idx_tour_created_by ON tour (created_by)');
$this->addSql('CREATE INDEX idx_tour_updated_by ON tour (updated_by)');
$this->comment('tour', '_table', 'Tournees commerciales terrain (M6 FieldSales) — personnelles (owner), soft-deletables (deleted_at).');
$this->comment('tour', 'id', 'Identifiant interne auto-incremente.');
$this->comment('tour', 'owner_id', 'Commercial proprietaire de la tournee (RG-6.01, personnelle) — FK -> "user".id, ON DELETE RESTRICT. Pose au POST par le TourProcessor.');
$this->comment('tour', 'label', 'Nom libre de la tournee (NotBlank, <= 120 caracteres).');
$this->comment('tour', 'tour_date', 'Date de realisation de la tournee (NotNull).');
$this->comment('tour', 'departure_time', 'Heure de depart, alimente les ETA (RG-6.11). Defaut applicatif 08:00 (constructeur).');
$this->comment('tour', 'start_latitude', 'Latitude WGS84 du point de depart (site commercial ou adresse libre). NULL -> depart = 1re etape.');
$this->comment('tour', 'start_longitude', 'Longitude WGS84 du point de depart. NULL -> depart = 1re etape.');
$this->comment('tour', 'start_label', 'Libelle affichable du point de depart (<= 180 caracteres). Optionnel.');
$this->comment('tour', 'default_visit_minutes', 'Duree de visite par defaut d une etape, en minutes (defaut 30) — utilisee si l etape ne fixe pas sa propre duree.');
$this->comment('tour', 'status', 'Cycle de vie (RG-6.02) : draft | planned | in_progress | done (enum TourStatus). Transitions libres en V1. Defaut draft.');
$this->comment('tour', 'total_distance_m', 'Cache d affichage : derniere distance totale calculee, en metres (RG-6.11). Lecture seule API, alimente par le moteur de trajet (M6.4).');
$this->comment('tour', 'total_duration_s', 'Cache d affichage : derniere duree totale calculee, en secondes (RG-6.11). Lecture seule API.');
$this->comment('tour', 'deleted_at', 'Horodatage du soft-delete — pose par le DELETE API. Null = tournee active.');
$this->addTimestampableBlamableComments('tour');
}
// =================================================================
// Table `tour_stop`
// =================================================================
private function createTourStopTable(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE tour_stop (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tour_id INT NOT NULL,
tier_type VARCHAR(30) NOT NULL,
tier_id INT DEFAULT NULL,
address_id INT DEFAULT NULL,
custom_label VARCHAR(180) DEFAULT NULL,
custom_address VARCHAR(255) DEFAULT NULL,
custom_latitude NUMERIC(10, 7) DEFAULT NULL,
custom_longitude NUMERIC(10, 7) DEFAULT NULL,
position SMALLINT NOT NULL,
visit_minutes SMALLINT DEFAULT NULL,
leg_distance_m INT DEFAULT NULL,
leg_duration_s INT DEFAULT NULL,
eta TIME(0) WITHOUT TIME ZONE DEFAULT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_tour_stop_tour
FOREIGN KEY (tour_id) REFERENCES tour (id) ON DELETE CASCADE,
CONSTRAINT fk_tour_stop_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_tour_stop_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
$this->addSql('CREATE INDEX idx_tour_stop_tour ON tour_stop (tour_id)');
$this->addSql('CREATE INDEX idx_tour_stop_created_by ON tour_stop (created_by)');
$this->addSql('CREATE INDEX idx_tour_stop_updated_by ON tour_stop (updated_by)');
// RG-6.07 : pas d unicite sur tier_id (deux etapes peuvent viser le meme
// Tiers). Unicite uniquement sur l ordre dans la tournee.
$this->addSql('CREATE UNIQUE INDEX uq_tour_stop_position ON tour_stop (tour_id, position)');
$this->comment('tour_stop', '_table', 'Etapes ordonnees d une tournee (M6) — cible polymorphe (Tiers referentiel ou point custom). Pas de rapport (scope reduit V0.2).');
$this->comment('tour_stop', 'id', 'Identifiant interne auto-incremente.');
$this->comment('tour_stop', 'tour_id', 'FK -> tour.id, ON DELETE CASCADE — tournee proprietaire de l etape.');
$this->comment('tour_stop', 'tier_type', 'Type de cible : client | supplier | ... | custom (point libre). Resolu via VisitableInterface. Chaine ouverte (Assert\\Choice).');
$this->comment('tour_stop', 'tier_id', 'Identifiant du Tiers referentiel cible (NULL si custom). Sans FK (cible polymorphe). RG-6.07 : aucune unicite.');
$this->comment('tour_stop', 'address_id', 'Adresse precise visitee chez le Tiers (NULL si custom). Sans FK (client_address OU supplier_address). RG-6.03 : doit appartenir au Tiers.');
$this->comment('tour_stop', 'custom_label', 'Libelle du point libre — obligatoire ssi tier_type = custom (RG-6.12), sinon NULL.');
$this->comment('tour_stop', 'custom_address', 'Adresse texte du point libre (geocodee) — renseignee uniquement si custom.');
$this->comment('tour_stop', 'custom_latitude', 'Latitude WGS84 du point libre (pin ajustable) — obligatoire ssi custom (RG-6.12).');
$this->comment('tour_stop', 'custom_longitude', 'Longitude WGS84 du point libre — obligatoire ssi custom (RG-6.12).');
$this->comment('tour_stop', 'position', 'Ordre de l etape dans la tournee (drag & drop). Unique par tournee (uq_tour_stop_position).');
$this->comment('tour_stop', 'visit_minutes', 'Duree de visite specifique a l etape, en minutes — sinon tour.default_visit_minutes.');
$this->comment('tour_stop', 'leg_distance_m', 'Cache : distance depuis l etape precedente, en metres (calcule). Lecture seule API (M6.4).');
$this->comment('tour_stop', 'leg_duration_s', 'Cache : temps depuis l etape precedente, en secondes (calcule). Lecture seule API (M6.4).');
$this->comment('tour_stop', 'eta', 'Heure d arrivee estimee a l etape (RG-6.11, calculee). Lecture seule API.');
$this->addTimestampableBlamableComments('tour_stop');
}
// =================================================================
// Helpers
// =================================================================
/**
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
* en reutilisant le catalogue partage (source unique).
*/
private function addTimestampableBlamableComments(string $table): void
{
foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
$this->comment($table, $column, $description);
}
}
/**
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou
* `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour eviter
* tout echappement d apostrophe.
*/
private function comment(string $table, string $column, string $description): void
{
$quotedTable = '"'.str_replace('"', '""', $table).'"';
if ('_table' === $column) {
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
return;
}
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
$quotedTable,
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
+20 -20
View File
@@ -17,14 +17,15 @@ use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Contract\VisitableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -95,6 +96,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
security: "is_granted('commercial.clients.manage')",
normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']],
denormalizationContext: ['groups' => ['client:write:main']],
// Une valeur de mauvais type (ex. date non parsable sur foundedAt)
// doit produire un 422 porte sur le champ (violations[].propertyPath,
// mappable inline par useFormErrors) plutot qu'un 400 generique non
// exploitable. Le front (MalioDate, MUI-44) forwarde la saisie brute
// invalide : le back reste la couche autoritaire du format (ERP-101).
collectDenormalizationErrors: true,
processor: ClientProcessor::class,
),
new Patch(
@@ -118,6 +125,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
'client:write:accounting',
'client:write:archive',
]],
// Cf. Post : date non parsable (foundedAt) -> 422 porte sur le champ
// au lieu d'un 400 generique. Indispensable au mapping inline du
// front (MalioDate MUI-44 forwarde la saisie brute invalide).
collectDenormalizationErrors: true,
provider: ClientProvider::class,
processor: ClientProcessor::class,
),
@@ -136,7 +147,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Index(name: 'idx_client_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_client_updated_by', columns: ['updated_by'])]
#[Auditable]
class Client implements TimestampableInterface, BlamableInterface, VisitableInterface
class Client implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
@@ -207,6 +218,13 @@ class Client implements TimestampableInterface, BlamableInterface, VisitableInte
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['client:read', 'client:write:information'])]
// Format d'ENTREE strict ISO `Y-m-d` (le `!` remet l'heure a 00:00:00). Sans
// ce format, PHP DateTime accepte des formes ambigues : « 12/25/2026 » (que
// le front MalioDate juge invalide en JJ/MM/AAAA) serait sinon interprete en
// M/J/AAAA -> 25 decembre 2026, et accepte a tort. Avec le format, toute
// saisie brute non-ISO (forwardee par MalioDate sur date invalide) echoue la
// denormalisation -> 422 sur foundedAt (cf. collectDenormalizationErrors).
#[Context(denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
private ?DateTimeImmutable $foundedAt = null;
#[ORM\Column(nullable: true)]
@@ -322,24 +340,6 @@ class Client implements TimestampableInterface, BlamableInterface, VisitableInte
return $this;
}
/**
* Libelle affichable du Tiers pour le module FieldSales (carte/etapes).
* La raison sociale est NotBlank (RG M1), le fallback chaine vide ne sert
* qu'a honorer le type non-nullable du contrat VisitableInterface.
*/
public function getDisplayName(): string
{
return $this->companyName ?? '';
}
/**
* Type stable porte par tour_stop.tier_type pour un Client (cf. M6 § 3.1).
*/
public function getVisitableType(): string
{
return 'client';
}
public function getDistributor(): ?Client
{
return $this->distributor;
@@ -15,11 +15,9 @@ use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientAddressRepositor
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\GeolocatableAddressInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -91,7 +89,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Table(name: 'client_address')]
#[ORM\Index(name: 'idx_client_address_client', columns: ['client_id'])]
#[Auditable]
class ClientAddress implements TimestampableInterface, BlamableInterface, GeolocatableAddressInterface
class ClientAddress implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
@@ -193,35 +191,6 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Geoloc
#[Groups(['client_address:read', 'client_address:write'])]
private int $position = 0;
// Geolocalisation portee par l'adresse (M6.1, spec § 3.2 / § 4.1) :
// coordonnees WGS84 alimentees par le geocodage BAN automatique
// (AddressGeocoder, appele par le processor si geoManual = false) ou par le
// pin manuel cote front (PATCH latitude/longitude + geoManual = true).
// Doctrine decimal -> chaine PHP ; setter tolerant (le JSON porte un nombre).
#[ORM\Column(type: 'decimal', precision: 10, scale: 7, nullable: true)]
#[Assert\Range(notInRangeMessage: 'La latitude doit être comprise entre {{ min }} et {{ max }}.', min: -90, max: 90)]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $latitude = null;
#[ORM\Column(type: 'decimal', precision: 10, scale: 7, nullable: true)]
#[Assert\Range(notInRangeMessage: 'La longitude doit être comprise entre {{ min }} et {{ max }}.', min: -180, max: 180)]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $longitude = null;
// RG-6.08 : pin corrige a la main -> le geocodage auto ne reecrit plus les
// coordonnees. Groupe d'ECRITURE seul sur la propriete ; la LECTURE est
// portee par le getter isGeoManual() + SerializedName (meme piege booleen
// que isProspect : sans cela la cle serait droppee du JSON).
#[ORM\Column(name: 'geo_manual', options: ['default' => false])]
#[Groups(['client_address:write'])]
private bool $geoManual = false;
// Date du dernier geocodage automatique reussi — posee par AddressGeocoder,
// jamais ecrite par le client (lecture seule API).
#[ORM\Column(name: 'geocoded_at', type: 'datetimetz_immutable', nullable: true)]
#[Groups(['client_address:read'])]
private ?DateTimeImmutable $geocodedAt = null;
// RG-1.10 : au moins un site rattache a chaque adresse.
/** @var Collection<int, SiteInterface> */
#[ORM\ManyToMany(targetEntity: SiteInterface::class)]
@@ -571,70 +540,6 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Geoloc
return $this;
}
public function getLatitude(): ?string
{
return $this->latitude;
}
public function setLatitude(float|string|null $latitude): static
{
$this->latitude = null === $latitude ? null : (string) $latitude;
return $this;
}
public function getLongitude(): ?string
{
return $this->longitude;
}
public function setLongitude(float|string|null $longitude): static
{
$this->longitude = null === $longitude ? null : (string) $longitude;
return $this;
}
// Groupe de lecture + nom serialise explicite (cf. note sur la propriete) :
// meme pattern que isProspect pour garantir la cle `geoManual` dans le JSON.
#[Groups(['client_address:read'])]
#[SerializedName('geoManual')]
public function isGeoManual(): bool
{
return $this->geoManual;
}
public function setGeoManual(bool $geoManual): static
{
$this->geoManual = $geoManual;
return $this;
}
public function getGeocodedAt(): ?DateTimeImmutable
{
return $this->geocodedAt;
}
public function setGeocodedAt(?DateTimeImmutable $geocodedAt): static
{
$this->geocodedAt = $geocodedAt;
return $this;
}
/**
* Adresse postale affichable / geocodable : « rue, code postal ville ». Le
* complement (etage, batiment) est volontairement exclu il bruite le
* geocodage BAN (contrat GeolocatableAddressInterface, M6.1).
*/
public function getDisplayLabel(): string
{
$locality = trim(implode(' ', array_filter([$this->postalCode, $this->city])));
return implode(', ', array_filter([$this->street, '' !== $locality ? $locality : null]));
}
/** @return Collection<int, SiteInterface> */
public function getSites(): Collection
{
@@ -17,14 +17,15 @@ use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Contract\VisitableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -95,6 +96,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
security: "is_granted('commercial.suppliers.manage')",
normalizationContext: ['groups' => ['supplier:read', 'default:read', 'category:read', 'site:read']],
denormalizationContext: ['groups' => ['supplier:write:main']],
// Une valeur de mauvais type (ex. date non parsable sur foundedAt)
// doit produire un 422 porte sur le champ (violations[].propertyPath,
// mappable inline par useFormErrors) plutot qu'un 400 generique. Le
// front (MalioDate, MUI-44) forwarde la saisie brute invalide : le
// back reste la couche autoritaire du format (ERP-101). Cf. Client.
collectDenormalizationErrors: true,
processor: SupplierProcessor::class,
),
new Patch(
@@ -114,6 +121,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
'supplier:write:accounting',
'supplier:write:archive',
]],
// Cf. Post : date non parsable (foundedAt) -> 422 porte sur le champ
// au lieu d'un 400 generique. Indispensable au mapping inline du
// front (MalioDate MUI-44 forwarde la saisie brute invalide).
collectDenormalizationErrors: true,
provider: SupplierProvider::class,
processor: SupplierProcessor::class,
),
@@ -131,7 +142,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Index(name: 'idx_supplier_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_supplier_updated_by', columns: ['updated_by'])]
#[Auditable]
class Supplier implements TimestampableInterface, BlamableInterface, VisitableInterface
class Supplier implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
@@ -188,6 +199,11 @@ class Supplier implements TimestampableInterface, BlamableInterface, VisitableIn
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['supplier:read', 'supplier:write:information'])]
// Format d'ENTREE strict ISO `Y-m-d` : sans lui, PHP DateTime accepte des
// formes ambigues (« 12/25/2026 », jugee invalide par MalioDate en JJ/MM/AAAA,
// serait lue en M/J -> 25 decembre et acceptee a tort). Avec le format, toute
// saisie brute non-ISO echoue -> 422 sur foundedAt. Cf. Client.
#[Context(denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
private ?DateTimeImmutable $foundedAt = null;
#[ORM\Column(nullable: true)]
@@ -376,24 +392,6 @@ class Supplier implements TimestampableInterface, BlamableInterface, VisitableIn
return $this;
}
/**
* Libelle affichable du Tiers pour le module FieldSales (carte/etapes).
* La raison sociale est NotBlank (RG M2), le fallback chaine vide ne sert
* qu'a honorer le type non-nullable du contrat VisitableInterface.
*/
public function getDisplayName(): string
{
return $this->companyName ?? '';
}
/**
* Type stable porte par tour_stop.tier_type pour un Fournisseur (M6 § 3.1).
*/
public function getVisitableType(): string
{
return 'supplier';
}
/** @return Collection<int, CategoryInterface> */
public function getCategories(): Collection
{
@@ -15,11 +15,9 @@ use App\Module\Commercial\Infrastructure\Doctrine\DoctrineSupplierAddressReposit
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\GeolocatableAddressInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -98,7 +96,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Table(name: 'supplier_address')]
#[ORM\Index(name: 'idx_supplier_address_supplier', columns: ['supplier_id'])]
#[Auditable]
class SupplierAddress implements TimestampableInterface, BlamableInterface, GeolocatableAddressInterface
class SupplierAddress implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
@@ -183,35 +181,6 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Geol
#[ORM\Column(options: ['default' => 0])]
private int $position = 0;
// Geolocalisation portee par l'adresse (M6.1, spec § 3.2 / § 4.1) :
// coordonnees WGS84 alimentees par le geocodage BAN automatique
// (AddressGeocoder, appele par le processor si geoManual = false) ou par le
// pin manuel cote front (PATCH latitude/longitude + geoManual = true).
// Doctrine decimal -> chaine PHP ; setter tolerant (le JSON porte un nombre).
#[ORM\Column(type: 'decimal', precision: 10, scale: 7, nullable: true)]
#[Assert\Range(notInRangeMessage: 'La latitude doit être comprise entre {{ min }} et {{ max }}.', min: -90, max: 90)]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
private ?string $latitude = null;
#[ORM\Column(type: 'decimal', precision: 10, scale: 7, nullable: true)]
#[Assert\Range(notInRangeMessage: 'La longitude doit être comprise entre {{ min }} et {{ max }}.', min: -180, max: 180)]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
private ?string $longitude = null;
// RG-6.08 : pin corrige a la main -> le geocodage auto ne reecrit plus les
// coordonnees. Groupe d'ECRITURE seul sur la propriete ; la LECTURE est
// portee par le getter isGeoManual() + SerializedName (meme piege booleen
// que triageProvider : sans cela la cle serait droppee du JSON).
#[ORM\Column(name: 'geo_manual', options: ['default' => false])]
#[Groups(['supplier:write:addresses'])]
private bool $geoManual = false;
// Date du dernier geocodage automatique reussi — posee par AddressGeocoder,
// jamais ecrite par le client (lecture seule API).
#[ORM\Column(name: 'geocoded_at', type: 'datetimetz_immutable', nullable: true)]
#[Groups(['supplier:item:read'])]
private ?DateTimeImmutable $geocodedAt = null;
// RG-2.06 : au moins un site rattache a chaque adresse.
/** @var Collection<int, SiteInterface> */
#[ORM\ManyToMany(targetEntity: SiteInterface::class)]
@@ -403,70 +372,6 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Geol
return $this;
}
public function getLatitude(): ?string
{
return $this->latitude;
}
public function setLatitude(float|string|null $latitude): static
{
$this->latitude = null === $latitude ? null : (string) $latitude;
return $this;
}
public function getLongitude(): ?string
{
return $this->longitude;
}
public function setLongitude(float|string|null $longitude): static
{
$this->longitude = null === $longitude ? null : (string) $longitude;
return $this;
}
// Groupe de lecture + nom serialise explicite (cf. note sur la propriete) :
// meme pattern que triageProvider pour garantir la cle `geoManual` dans le JSON.
#[Groups(['supplier:item:read'])]
#[SerializedName('geoManual')]
public function isGeoManual(): bool
{
return $this->geoManual;
}
public function setGeoManual(bool $geoManual): static
{
$this->geoManual = $geoManual;
return $this;
}
public function getGeocodedAt(): ?DateTimeImmutable
{
return $this->geocodedAt;
}
public function setGeocodedAt(?DateTimeImmutable $geocodedAt): static
{
$this->geocodedAt = $geocodedAt;
return $this;
}
/**
* Adresse postale affichable / geocodable : « rue, code postal ville ». Le
* complement (etage, batiment) est volontairement exclu il bruite le
* geocodage BAN (contrat GeolocatableAddressInterface, M6.1).
*/
public function getDisplayLabel(): string
{
$locality = trim(implode(' ', array_filter([$this->postalCode, $this->city])));
return implode(', ', array_filter([$this->street, '' !== $locality ? $locality : null]));
}
/** @return Collection<int, SiteInterface> */
public function getSites(): Collection
{
@@ -10,7 +10,6 @@ use ApiPlatform\State\ProcessorInterface;
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\ClientAddress;
use App\Shared\Application\Service\AddressGeocoder;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -20,9 +19,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
*
* Sequence :
* - POST / PATCH : normalisation serveur du billingEmail en lowercase (RG-1.21)
* via le ClientFieldNormalizer partage, puis geocodage automatique BAN
* (AddressGeocoder, M6.1) no-op si le pin a ete corrige a la main
* (geoManual = true, RG-6.08). Les autres regles de l'onglet Adresse
* via le ClientFieldNormalizer partage. Les autres regles de l'onglet Adresse
* sont deja garanties en amont : RG-1.09 (code postal) et RG-1.10 (>= 1 site)
* par des contraintes Assert sur l'entite, RG-1.06/07/08/11 par des CHECK BDD.
* - DELETE : aucune regle metier specifique (suppression physique directe).
@@ -40,7 +37,6 @@ final class ClientAddressProcessor implements ProcessorInterface
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly ClientFieldNormalizer $normalizer,
private readonly AddressGeocoder $addressGeocoder,
private readonly EntityManagerInterface $em,
) {}
@@ -56,7 +52,6 @@ final class ClientAddressProcessor implements ProcessorInterface
$this->linkParent($data, $uriVariables);
$this->normalize($data);
$this->addressGeocoder->geocode($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
@@ -9,7 +9,6 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Commercial\Domain\Entity\SupplierAddress;
use App\Shared\Application\Service\AddressGeocoder;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -20,9 +19,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
* perimetre ERP-88.
*
* Sequence :
* - POST / PATCH : rattachement au fournisseur parent, puis geocodage
* automatique BAN (AddressGeocoder, M6.1) no-op si le pin a ete corrige a
* la main (geoManual = true, RG-6.08). Aucune normalisation
* - POST / PATCH : rattachement au fournisseur parent. Aucune normalisation
* specifique (pas d'email de facturation au M2). Les regles de l'onglet
* Adresse sont garanties en amont par des contraintes sur l'entite, jouees
* par API Platform avant ce processor : RG-2.05 (code postal, Assert\Regex),
@@ -43,7 +40,6 @@ final class SupplierAddressProcessor implements ProcessorInterface
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly AddressGeocoder $addressGeocoder,
private readonly EntityManagerInterface $em,
) {}
@@ -58,7 +54,6 @@ final class SupplierAddressProcessor implements ProcessorInterface
}
$this->linkParent($data, $uriVariables);
$this->addressGeocoder->geocode($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
@@ -66,8 +66,6 @@ final class RbacSeeder
// Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite).
'commercial.suppliers.view',
'commercial.suppliers.manage',
// Tournees (M6 § 8, ERP-123) : Bureau = consultation seule.
'field_sales.tours.view',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
@@ -98,9 +96,6 @@ final class RbacSeeder
// (onglet Comptabilite masque/filtre pour la Commerciale).
'commercial.suppliers.view',
'commercial.suppliers.manage',
// Tournees (M6 § 8, ERP-123) : Commerciale = view + manage.
'field_sales.tours.view',
'field_sales.tours.manage',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
@@ -203,10 +203,6 @@ final class SeedE2ECommand extends Command
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
'commercial.suppliers.archive',
// FieldSales — Tournees (M6, ERP-123). Mappe sur le persona
// "tout". Miroir de frontend/tests/e2e/_fixtures/personas.ts.
'field_sales.tours.view',
'field_sales.tours.manage',
],
],
[
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Application\DTO;
/**
* DTO de sortie d'un « Tiers visitable » geolocalise (M6 § 5,
* GET /api/visitable_tiers) : un pin de la carte = une adresse geolocalisee
* d'un Tiers du referentiel (Client M1 / Fournisseur M2, extensible).
*
* Readonly : aucune mutation apres hydration. La resource API Platform expose
* directement ce DTO (pas d'entite ORM lecture DBAL pure du schema partage,
* regle ABSOLUE n°1 : FieldSales n'importe aucune classe de Commercial).
*/
final readonly class VisitableTierOutput
{
public function __construct(
/** Identifiant synthetique stable « {type}-{addressId} » (ex: client-42) pour l'IRI Hydra. */
public string $id,
/** Type de Tiers visitable : client | supplier (extensible). */
public string $tierType,
/** ID du Tiers dans son referentiel (client.id / supplier.id). */
public int $tierId,
/** ID de l'adresse precise geolocalisee (client_address.id / supplier_address.id). */
public int $addressId,
/** Raison sociale du Tiers (libelle du pin). */
public string $displayName,
/** Adresse formatee sur une ligne (rue, CP ville). */
public string $address,
/** Latitude WGS84 du pin. */
public float $latitude,
/** Longitude WGS84 du pin. */
public float $longitude,
) {}
}
@@ -1,68 +0,0 @@
<?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;
}
}
@@ -1,289 +0,0 @@
<?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);
}
/**
* Reordonne les etapes selon la liste d'ids fournie (drag & drop cote front),
* puis recompute. Les ids inconnus sont ignores ; les etapes absentes de la
* liste sont conservees en fin (ordre courant). Persiste en deux temps
* (reassignPositions) pour ne pas heurter l'unique (tour_id, position).
*
* @param list<int> $orderedStopIds
*/
public function reorder(Tour $tour, array $orderedStopIds): void
{
$stops = $this->orderedStops($tour);
$byId = [];
foreach ($stops as $stop) {
$byId[$stop->getId()] = $stop;
}
$ordered = [];
$seen = [];
foreach ($orderedStopIds as $id) {
if (isset($byId[$id]) && !isset($seen[$id])) {
$ordered[] = $byId[$id];
$seen[$id] = true;
}
}
// Etapes non citees dans la liste : placees en fin, ordre courant preserve.
foreach ($stops as $stop) {
if (!isset($seen[$stop->getId()])) {
$ordered[] = $stop;
}
}
if (count($ordered) > 1) {
$this->reassignPositions($ordered);
}
$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);
}
}
@@ -1,416 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
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\Processor\TourReorderProcessor;
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Provider\TourProvider;
use App\Module\FieldSales\Infrastructure\Doctrine\DoctrineTourRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Tournee commerciale terrain (M6 § 4.2). Entite racine du module FieldSales :
* porte le point de depart, l'heure de depart, la duree de visite par defaut,
* le statut (cycle de vie RG-6.02) et la liste ordonnee d'etapes (TourStop).
*
* Decisions structurantes :
* - Tournee PERSONNELLE (RG-6.01) : `owner` = commercial proprietaire, pose par
* le TourProcessor au POST (jamais ecrit par le client). Le TourProvider filtre
* la collection sur l'owner courant (admin / Bureau voient tout).
* - owner reference l'utilisateur via UserInterface + resolve_target_entities
* (-> User du module Core), comme le createdBy du trait Blamable : aucun import
* direct du module Core (regle ABSOLUE n°1).
* - status : enum PHP TourStatus stocke en chaine (Assert\Choice sur les valeurs
* de l'enum -> 422 FR si valeur invalide). Defaut Draft a la creation.
* - Soft delete (`deletedAt`) : le DELETE API pose deletedAt (TourProcessor),
* le TourProvider exclut les tournees supprimees.
* - total_distance_m / total_duration_s : cache d'affichage des derniers totaux
* calcules (RG-6.11, lecture seule cote API ; alimente par le moteur de trajet
* au ticket M6.4).
*
* Audite (#[Auditable]) + Timestampable/Blamable.
*
* @phpstan-ignore-next-line owner est resolu en User (getId()) via resolve_target_entities
*/
#[ApiResource(
shortName: 'Tour',
operations: [
new GetCollection(
security: "is_granted('field_sales.tours.view')",
normalizationContext: ['groups' => ['tour:read', 'default:read']],
provider: TourProvider::class,
),
new Get(
security: "is_granted('field_sales.tours.view')",
// Detail : la tournee + ses etapes embarquees (tour:item:read porte
// getStops(), tour_stop:read le contenu de chaque etape).
normalizationContext: ['groups' => ['tour:read', 'tour:item:read', 'tour_stop:read', 'default:read']],
provider: TourProvider::class,
),
new Post(
security: "is_granted('field_sales.tours.manage')",
normalizationContext: ['groups' => ['tour:read', 'default:read']],
denormalizationContext: ['groups' => ['tour:write']],
processor: TourProcessor::class,
),
new Patch(
security: "is_granted('field_sales.tours.manage')",
normalizationContext: ['groups' => ['tour:read', 'default:read']],
denormalizationContext: ['groups' => ['tour:write']],
provider: TourProvider::class,
processor: TourProcessor::class,
),
new Delete(
// DELETE = soft delete (pose deletedAt) — cf. TourProcessor.
security: "is_granted('field_sales.tours.manage')",
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 selon l'ordre fourni (drag & drop) puis recompute.
// Corps {stopIds: [ids dans le nouvel ordre]}. La renumerotation est
// atomique (anti-collision unique (tour_id, position)), cf. processor.
new Post(
uriTemplate: '/tours/{id}/reorder',
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: TourReorderProcessor::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)]
#[ORM\Table(name: 'tour')]
#[ORM\Index(name: 'idx_tour_owner', columns: ['owner_id'])]
#[ORM\Index(name: 'idx_tour_status', columns: ['status'])]
#[ORM\Index(name: 'idx_tour_deleted_at', columns: ['deleted_at'])]
#[ORM\Index(name: 'idx_tour_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_tour_updated_by', columns: ['updated_by'])]
#[Auditable]
class Tour implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['tour:read'])]
private ?int $id = null;
// Commercial proprietaire (RG-6.01). Pose par le TourProcessor au POST, donc
// PAS de groupe d'ecriture et PAS d'Assert\NotNull (la validation s'execute
// avant le processor) — la colonne NOT NULL en base est le garde-fou final.
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'owner_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
#[Groups(['tour:read'])]
private ?UserInterface $owner = null;
#[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'Le nom de la tournée est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'Le nom de la tournée ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['tour:read', 'tour:write'])]
private ?string $label = null;
#[ORM\Column(name: 'tour_date', type: 'date_immutable')]
#[Assert\NotNull(message: 'La date de la tournée est obligatoire.')]
#[Groups(['tour:read', 'tour:write'])]
private ?DateTimeImmutable $tourDate = null;
// Heure de depart (alimente les ETA, RG-6.11). Defaut 08:00 (pose dans le
// constructeur). Colonne TIME -> DateTimeImmutable (partie date 1970 ignoree).
#[ORM\Column(name: 'departure_time', type: 'time_immutable')]
#[Groups(['tour:read', 'tour:write'])]
private DateTimeImmutable $departureTime;
// Point de depart (site commercial ou adresse libre). NULL -> depart = 1re etape.
#[ORM\Column(name: 'start_latitude', type: 'decimal', precision: 10, scale: 7, nullable: true)]
#[Assert\Range(notInRangeMessage: 'La latitude doit être comprise entre {{ min }} et {{ max }}.', min: -90, max: 90)]
#[Groups(['tour:read', 'tour:write'])]
private ?string $startLatitude = null;
#[ORM\Column(name: 'start_longitude', type: 'decimal', precision: 10, scale: 7, nullable: true)]
#[Assert\Range(notInRangeMessage: 'La longitude doit être comprise entre {{ min }} et {{ max }}.', min: -180, max: 180)]
#[Groups(['tour:read', 'tour:write'])]
private ?string $startLongitude = null;
#[ORM\Column(name: 'start_label', length: 180, nullable: true)]
#[Assert\Length(max: 180, maxMessage: 'Le libellé du point de départ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['tour:read', 'tour:write'])]
private ?string $startLabel = null;
#[ORM\Column(name: 'default_visit_minutes', type: 'smallint', options: ['default' => 30])]
#[Assert\Positive(message: 'La durée de visite par défaut doit être un nombre positif.')]
#[Groups(['tour:read', 'tour:write'])]
private int $defaultVisitMinutes = 30;
// Statut stocke en chaine ; valeurs bornees a l'enum TourStatus (RG-6.02).
#[ORM\Column(length: 20, options: ['default' => TourStatus::Draft->value])]
#[Assert\Choice(callback: [TourStatus::class, 'values'], message: 'Le statut de la tournée est invalide.')]
#[Groups(['tour:read', 'tour:write'])]
private string $status = TourStatus::Draft->value;
// Derniers totaux calcules (cache d'affichage, RG-6.11). Lecture seule cote
// API : alimentes par le moteur de trajet (M6.4), jamais ecrits par le client.
#[ORM\Column(name: 'total_distance_m', nullable: true)]
#[Groups(['tour:read'])]
private ?int $totalDistanceM = null;
#[ORM\Column(name: 'total_duration_s', nullable: true)]
#[Groups(['tour:read'])]
private ?int $totalDurationS = null;
/** @var Collection<int, TourStop> */
#[ORM\OneToMany(mappedBy: 'tour', targetEntity: TourStop::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['position' => 'ASC'])]
private Collection $stops;
// Soft delete : pose par le TourProcessor sur DELETE, jamais expose en
// ecriture. Le TourProvider exclut les tournees dont deletedAt n'est pas null.
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
private ?DateTimeImmutable $deletedAt = null;
public function __construct()
{
$this->stops = new ArrayCollection();
$this->departureTime = new DateTimeImmutable('1970-01-01 08:00:00');
}
public function getId(): ?int
{
return $this->id;
}
public function getOwner(): ?UserInterface
{
return $this->owner;
}
public function setOwner(?UserInterface $owner): static
{
$this->owner = $owner;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(?string $label): static
{
$this->label = $label;
return $this;
}
public function getTourDate(): ?DateTimeImmutable
{
return $this->tourDate;
}
public function setTourDate(?DateTimeImmutable $tourDate): static
{
$this->tourDate = $tourDate;
return $this;
}
public function getDepartureTime(): DateTimeImmutable
{
return $this->departureTime;
}
public function setDepartureTime(DateTimeImmutable $departureTime): static
{
$this->departureTime = $departureTime;
return $this;
}
public function getStartLatitude(): ?string
{
return $this->startLatitude;
}
public function setStartLatitude(float|string|null $startLatitude): static
{
$this->startLatitude = null === $startLatitude ? null : (string) $startLatitude;
return $this;
}
public function getStartLongitude(): ?string
{
return $this->startLongitude;
}
public function setStartLongitude(float|string|null $startLongitude): static
{
$this->startLongitude = null === $startLongitude ? null : (string) $startLongitude;
return $this;
}
public function getStartLabel(): ?string
{
return $this->startLabel;
}
public function setStartLabel(?string $startLabel): static
{
$this->startLabel = $startLabel;
return $this;
}
public function getDefaultVisitMinutes(): int
{
return $this->defaultVisitMinutes;
}
public function setDefaultVisitMinutes(int $defaultVisitMinutes): static
{
$this->defaultVisitMinutes = $defaultVisitMinutes;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): static
{
$this->status = $status;
return $this;
}
public function getTotalDistanceM(): ?int
{
return $this->totalDistanceM;
}
public function setTotalDistanceM(?int $totalDistanceM): static
{
$this->totalDistanceM = $totalDistanceM;
return $this;
}
public function getTotalDurationS(): ?int
{
return $this->totalDurationS;
}
public function setTotalDurationS(?int $totalDurationS): static
{
$this->totalDurationS = $totalDurationS;
return $this;
}
/** @return Collection<int, TourStop> */
#[Groups(['tour:item:read'])]
public function getStops(): Collection
{
return $this->stops;
}
public function addStop(TourStop $stop): static
{
if (!$this->stops->contains($stop)) {
$this->stops->add($stop);
$stop->setTour($this);
}
return $this;
}
public function removeStop(TourStop $stop): static
{
if ($this->stops->removeElement($stop) && $stop->getTour() === $this) {
$stop->setTour(null);
}
return $this;
}
public function getDeletedAt(): ?DateTimeImmutable
{
return $this->deletedAt;
}
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
}
@@ -1,406 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor\TourStopProcessor;
use App\Module\FieldSales\Infrastructure\Doctrine\DoctrineTourStopRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* Etape d'une tournee (M6 § 4.3). Une etape vise soit un Tiers du referentiel
* (Client M1, Fournisseur M2, futur Prestataire) resolu de facon polymorphe via
* le couple (`tierType`, `tierId`), soit un point libre `custom` (prospect / RDV
* sans fiche : libelle + adresse + coordonnees saisis a la main).
*
* Choix de modelisation (spec § 3.1.bis) :
* - PAS d'association Doctrine vers le Tiers ni vers l'adresse : la cible est
* polymorphe (client_address OU supplier_address selon tierType), donc tierId
* et addressId sont de simples entiers. La coherence « l'adresse appartient au
* Tiers » (RG-6.03) est verifiee cote TourStopProcessor (acces lecture seule au
* schema partage, sans import inter-module regle ABSOLUE n°1).
* - tierType est une chaine OUVERTE (Assert\Choice = types Visitable connus +
* `custom`), extensible aux futurs Tiers sans toucher au module FieldSales.
*
* SCOPE REDUIT (V0.2) : pas de rapport de visite -> PAS de report_id, PAS de
* arrived_at / check-in.
*
* Audite (#[Auditable]) + Timestampable/Blamable. Unicite (tour_id, position).
*
* Sous-ressource API (spec § 5, pattern ClientAddress) :
* - POST /api/tours/{tourId}/stops : creation rattachee a la tournee parente
* (Link toProperty 'tour', read:false), security field_sales.tours.manage.
* - PATCH /api/tour_stops/{id} : edition (dont position = drag & drop).
* - DELETE /api/tour_stops/{id} : suppression d'une etape.
* - GET /api/tour_stops/{id} : lecture unitaire (security view). La lecture
* courante des etapes passe par le detail de la tournee parente.
*/
#[ApiResource(
shortName: 'TourStop',
operations: [
new Get(
security: "is_granted('field_sales.tours.view')",
normalizationContext: ['groups' => ['tour_stop:read']],
),
new Post(
uriTemplate: '/tours/{tourId}/stops',
uriVariables: [
'tourId' => new Link(fromClass: Tour::class, toProperty: 'tour'),
],
// read:false : comme ClientAddress, le Link toProperty resoudrait
// l'enfant (SELECT WHERE tour = :id) et casserait en NonUniqueResult
// des >= 2 etapes. La tournee parente est rattachee manuellement par
// TourStopProcessor::linkParent (404 si absente).
read: false,
security: "is_granted('field_sales.tours.manage')",
normalizationContext: ['groups' => ['tour_stop:read']],
denormalizationContext: ['groups' => ['tour_stop:write']],
processor: TourStopProcessor::class,
),
new Patch(
security: "is_granted('field_sales.tours.manage')",
normalizationContext: ['groups' => ['tour_stop:read']],
denormalizationContext: ['groups' => ['tour_stop:write']],
processor: TourStopProcessor::class,
),
new Delete(
security: "is_granted('field_sales.tours.manage')",
processor: TourStopProcessor::class,
),
],
)]
#[ORM\Entity(repositoryClass: DoctrineTourStopRepository::class)]
#[ORM\Table(name: 'tour_stop')]
#[ORM\UniqueConstraint(name: 'uq_tour_stop_position', columns: ['tour_id', 'position'])]
#[ORM\Index(name: 'idx_tour_stop_tour', columns: ['tour_id'])]
#[ORM\Index(name: 'idx_tour_stop_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_tour_stop_updated_by', columns: ['updated_by'])]
#[Auditable]
class TourStop implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
/** Point libre (prospect / RDV sans fiche Tiers) — cf. RG-6.12. */
public const string TIER_TYPE_CUSTOM = 'custom';
/**
* Valeurs autorisees de `tierType` : types Visitable connus du referentiel
* (client, supplier) + le point libre `custom`. Liste OUVERTE par nature
* (de simples chaines, aucun import de classe d'un autre module) : un futur
* Tiers (prestataire...) s'ajoute ici sans autre changement.
*/
public const array TIER_TYPES = ['client', 'supplier', self::TIER_TYPE_CUSTOM];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['tour_stop:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Tour::class, inversedBy: 'stops')]
#[ORM\JoinColumn(name: 'tour_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Tour $tour = null;
#[ORM\Column(name: 'tier_type', length: 30)]
#[Assert\NotBlank(message: 'Le type de cible de l\'étape est obligatoire.')]
#[Assert\Choice(choices: self::TIER_TYPES, message: 'Le type de cible de l\'étape est invalide.')]
#[Groups(['tour_stop:read', 'tour_stop:write'])]
private ?string $tierType = null;
// Identifiant du Tiers referentiel (NULL si custom). Pas de FK : cible
// polymorphe resolue via tierType (RG-6.07 : aucune unicite sur tierId).
#[ORM\Column(name: 'tier_id', nullable: true)]
#[Groups(['tour_stop:read', 'tour_stop:write'])]
private ?int $tierId = null;
// Adresse precise visitee chez le Tiers (NULL si custom). Pas de FK : cible
// polymorphe (client_address OU supplier_address). RG-6.03 : doit appartenir
// au Tiers (verifie par le TourStopProcessor).
#[ORM\Column(name: 'address_id', nullable: true)]
#[Groups(['tour_stop:read', 'tour_stop:write'])]
private ?int $addressId = null;
#[ORM\Column(name: 'custom_label', length: 180, nullable: true)]
#[Assert\Length(max: 180, maxMessage: 'Le libellé du point libre ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['tour_stop:read', 'tour_stop:write'])]
private ?string $customLabel = null;
#[ORM\Column(name: 'custom_address', length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'L\'adresse du point libre ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['tour_stop:read', 'tour_stop:write'])]
private ?string $customAddress = null;
#[ORM\Column(name: 'custom_latitude', type: 'decimal', precision: 10, scale: 7, nullable: true)]
#[Assert\Range(notInRangeMessage: 'La latitude doit être comprise entre {{ min }} et {{ max }}.', min: -90, max: 90)]
#[Groups(['tour_stop:read', 'tour_stop:write'])]
private ?string $customLatitude = null;
#[ORM\Column(name: 'custom_longitude', type: 'decimal', precision: 10, scale: 7, nullable: true)]
#[Assert\Range(notInRangeMessage: 'La longitude doit être comprise entre {{ min }} et {{ max }}.', min: -180, max: 180)]
#[Groups(['tour_stop:read', 'tour_stop:write'])]
private ?string $customLongitude = null;
#[ORM\Column(type: 'smallint')]
#[Assert\PositiveOrZero(message: 'La position de l\'étape doit être un nombre positif ou nul.')]
#[Groups(['tour_stop:read', 'tour_stop:write'])]
private int $position = 0;
// Duree de visite specifique (sinon tour.default_visit_minutes).
#[ORM\Column(name: 'visit_minutes', type: 'smallint', nullable: true)]
#[Assert\Positive(message: 'La durée de visite doit être un nombre positif.')]
#[Groups(['tour_stop:read', 'tour_stop:write'])]
private ?int $visitMinutes = null;
// Distance / temps depuis l'etape precedente (calcules — lecture seule API,
// alimentes par le moteur de trajet au M6.4).
#[ORM\Column(name: 'leg_distance_m', nullable: true)]
#[Groups(['tour_stop:read'])]
private ?int $legDistanceM = null;
#[ORM\Column(name: 'leg_duration_s', nullable: true)]
#[Groups(['tour_stop:read'])]
private ?int $legDurationS = null;
// Heure d'arrivee estimee (calculee, RG-6.11). Lecture seule API.
#[ORM\Column(name: 'eta', type: 'time_immutable', nullable: true)]
#[Groups(['tour_stop:read'])]
private ?DateTimeImmutable $eta = null;
/**
* RG-6.12 : coherence du point libre vs Tiers referentiel.
* - `custom` : tierId / addressId doivent etre NULL ; customLabel et les
* coordonnees (customLatitude / customLongitude) sont obligatoires.
* - non-`custom` : tierId est obligatoire (cible du referentiel) et les
* champs custom_* n'ont pas de sens (doivent rester NULL).
*
* Note : la coherence « l'adresse appartient au Tiers » (RG-6.03) n'est PAS
* verifiable ici (acces BDD requis) -> portee par le TourStopProcessor.
*/
#[Assert\Callback]
public function validateCustomCoherence(ExecutionContextInterface $context): void
{
if (self::TIER_TYPE_CUSTOM === $this->tierType) {
if (null !== $this->tierId) {
$context->buildViolation('Un point libre ne peut pas référencer un Tiers.')
->atPath('tierId')->addViolation()
;
}
if (null !== $this->addressId) {
$context->buildViolation('Un point libre ne peut pas référencer une adresse.')
->atPath('addressId')->addViolation()
;
}
if (null === $this->customLabel || '' === trim($this->customLabel)) {
$context->buildViolation('Le libellé du point libre est obligatoire.')
->atPath('customLabel')->addViolation()
;
}
if (null === $this->customLatitude) {
$context->buildViolation('La latitude du point libre est obligatoire.')
->atPath('customLatitude')->addViolation()
;
}
if (null === $this->customLongitude) {
$context->buildViolation('La longitude du point libre est obligatoire.')
->atPath('customLongitude')->addViolation()
;
}
return;
}
// Etape sur Tiers referentiel : tierId + addressId obligatoires (l'etape
// vise une adresse precise du Tiers, RG-6.03), champs custom interdits.
if (null === $this->tierId) {
$context->buildViolation('Le Tiers de l\'étape est obligatoire.')
->atPath('tierId')->addViolation()
;
}
if (null === $this->addressId) {
$context->buildViolation('L\'adresse de l\'étape est obligatoire.')
->atPath('addressId')->addViolation()
;
}
if (null !== $this->customLabel && '' !== trim($this->customLabel)) {
$context->buildViolation('Un libellé de point libre n\'est autorisé que sur une étape « custom ».')
->atPath('customLabel')->addViolation()
;
}
}
public function getId(): ?int
{
return $this->id;
}
public function getTour(): ?Tour
{
return $this->tour;
}
public function setTour(?Tour $tour): static
{
$this->tour = $tour;
return $this;
}
public function getTierType(): ?string
{
return $this->tierType;
}
public function setTierType(?string $tierType): static
{
$this->tierType = $tierType;
return $this;
}
public function getTierId(): ?int
{
return $this->tierId;
}
public function setTierId(?int $tierId): static
{
$this->tierId = $tierId;
return $this;
}
public function getAddressId(): ?int
{
return $this->addressId;
}
public function setAddressId(?int $addressId): static
{
$this->addressId = $addressId;
return $this;
}
public function getCustomLabel(): ?string
{
return $this->customLabel;
}
public function setCustomLabel(?string $customLabel): static
{
$this->customLabel = $customLabel;
return $this;
}
public function getCustomAddress(): ?string
{
return $this->customAddress;
}
public function setCustomAddress(?string $customAddress): static
{
$this->customAddress = $customAddress;
return $this;
}
public function getCustomLatitude(): ?string
{
return $this->customLatitude;
}
public function setCustomLatitude(float|string|null $customLatitude): static
{
$this->customLatitude = null === $customLatitude ? null : (string) $customLatitude;
return $this;
}
public function getCustomLongitude(): ?string
{
return $this->customLongitude;
}
public function setCustomLongitude(float|string|null $customLongitude): static
{
$this->customLongitude = null === $customLongitude ? null : (string) $customLongitude;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
public function getVisitMinutes(): ?int
{
return $this->visitMinutes;
}
public function setVisitMinutes(?int $visitMinutes): static
{
$this->visitMinutes = $visitMinutes;
return $this;
}
public function getLegDistanceM(): ?int
{
return $this->legDistanceM;
}
public function setLegDistanceM(?int $legDistanceM): static
{
$this->legDistanceM = $legDistanceM;
return $this;
}
public function getLegDurationS(): ?int
{
return $this->legDurationS;
}
public function setLegDurationS(?int $legDurationS): static
{
$this->legDurationS = $legDurationS;
return $this;
}
public function getEta(): ?DateTimeImmutable
{
return $this->eta;
}
public function setEta(?DateTimeImmutable $eta): static
{
$this->eta = $eta;
return $this;
}
}
@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Domain\Enum;
/**
* Cycle de vie d'une tournee (M6 § 4.2, RG-6.02). Transitions libres en V1
* (aucune machine a etats imposee) : la valeur est simplement contrainte a
* l'une des quatre etapes ci-dessous.
*
* Enum backed string : sert a la fois de source de verite des valeurs
* autorisees (cf. values(), consommee par l'Assert\Choice de Tour::$status) et
* de constantes typees pour le code metier (defaut Draft a la creation).
*/
enum TourStatus: string
{
/** Brouillon — tournee en cours de construction (etat initial au POST). */
case Draft = 'draft';
/** Planifiee — etapes posees, prete a etre realisee. */
case Planned = 'planned';
/** En cours — la tournee est en train d'etre effectuee. */
case InProgress = 'in_progress';
/** Terminee — tournee realisee. */
case Done = 'done';
/**
* Liste des valeurs autorisees (cle stockee en base), pour l'Assert\Choice
* de l'entite Tour. Source unique : ajouter un case suffit.
*
* @return list<string>
*/
public static function values(): array
{
return array_map(static fn (self $case): string => $case->value, self::cases());
}
}
@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Domain\Repository;
use App\Module\FieldSales\Domain\Entity\Tour;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Contrat du repository des tournees (M6). L'implementation Doctrine vit dans
* Infrastructure/Doctrine (DoctrineTourRepository).
*/
interface TourRepositoryInterface
{
public function findById(int $id): ?Tour;
public function save(Tour $tour): void;
/**
* QueryBuilder de liste des tournees actives (deletedAt IS NULL), triees par
* date decroissante puis libelle. Si $owner est fourni, filtre sur le
* proprietaire (RG-6.01 : la Commerciale ne voit que les siennes) ; null =
* toutes les tournees (admin / Bureau).
*/
public function createListQueryBuilder(?UserInterface $owner = null): QueryBuilder;
}
@@ -1,61 +0,0 @@
<?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;
}
@@ -1,20 +0,0 @@
<?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,
) {}
}
@@ -1,22 +0,0 @@
<?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,
) {}
}
@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales;
final class FieldSalesModule
{
public const string ID = 'field_sales';
public const string LABEL = 'Tournées';
public const bool REQUIRED = false;
/**
* Liste declarative des permissions RBAC exposees par le module FieldSales.
*
* Consommee par la commande `app:sync-permissions` (SyncPermissionsCommand)
* qui upserte ces entrees dans la table `permission`, reactive les codes
* precedemment orphelins et marque comme orphelins ceux disparus du code.
*
* La cle `module` est auto-injectee par le sync command a partir de
* `self::ID`, inutile de la repeter dans chaque entree.
*
* Convention de nommage : `module.resource[.sub].action` en snake_case, le
* prefixe devant correspondre exactement a `self::ID`.
*
* Scope V0.2 (spec M6 § 8) : UNIQUEMENT les tournees (le volet « rapport de
* visite » a ete retire, plus aucune permission `reports.*`). Granularite
* view/manage, alignee sur Core/Commercial. Attribution (matrice § 8) :
* Commerciale + Admin = manage ; Bureau = view ; Compta exclue.
*
* @return array<int, array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'field_sales.tours.view', 'label' => 'Voir les tournées et l\'onglet Carte'],
['code' => 'field_sales.tours.manage', 'label' => 'Créer / modifier / optimiser / dupliquer / supprimer une tournée'],
];
}
}
@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\FieldSales\Application\DTO\VisitableTierOutput;
use App\Module\FieldSales\Infrastructure\ApiPlatform\State\Provider\VisitableTierProvider;
/**
* Resource API Platform en lecture seule : les « Tiers visitables » geolocalises
* (M6 § 5). Alimente les pins de la carte interactive de planification de
* tournee (M6.5).
*
* Un item = une adresse geolocalisee d'un Tiers (Client M1 / Fournisseur M2).
* Le provider lit via DBAL le schema partage (regle ABSOLUE n°1 : aucun import
* d'une classe Commercial) et retourne des `VisitableTierOutput`. Aucune entite
* ORM derriere pas d'ecriture exposee.
*
* Filtres query-param (cf. provider) :
* ?bbox=minLng,minLat,maxLng,maxLat zone visible de la carte (Leaflet getBounds().toBBoxString())
* ?q=durand recherche raison sociale / ville (ILIKE)
* ?type=client,supplier restreint les types de Tiers
*
* Pagination : standard global (10/page, max 50). La carte charge en general
* tous les pins de la bbox via l'echappatoire `?pagination=false` (la bbox borne
* deja le volume) gere par le provider, comme TourProvider.
*
* L'operation Get item (par id synthetique « {type}-{addressId} ») existe pour
* que API Platform genere l'IRI Hydra (`@id`) de chaque membre de la collection
* JSON-LD (meme contrainte que AuditLogResource).
*/
#[ApiResource(
shortName: 'VisitableTier',
operations: [
new GetCollection(
uriTemplate: '/visitable_tiers',
security: "is_granted('field_sales.tours.view')",
provider: VisitableTierProvider::class,
),
new Get(
uriTemplate: '/visitable_tiers/{id}',
requirements: ['id' => '[a-z_]+-[0-9]+'],
security: "is_granted('field_sales.tours.view')",
provider: VisitableTierProvider::class,
),
],
output: VisitableTierOutput::class,
)]
final class VisitableTierResource {}
@@ -1,40 +0,0 @@
<?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;
}
}
@@ -1,88 +0,0 @@
<?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);
}
}
@@ -1,39 +0,0 @@
<?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;
}
}
@@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\FieldSales\Domain\Entity\Tour;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Processor d'ecriture des tournees (M6 § 5).
*
* Sequence :
* - POST : pose l'owner = utilisateur courant (RG-6.01, tournee personnelle).
* L'owner n'est jamais accepte dans le payload (pas de groupe d'ecriture).
* - PATCH : aucune reaffectation d'owner.
* - DELETE : soft delete (pose deletedAt) au lieu d'une suppression physique.
*
* La security (field_sales.tours.manage) et la validation Symfony sont deja
* appliquees en amont par API Platform.
*
* @implements ProcessorInterface<Tour, null|Tour>
*/
final class TourProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly Security $security,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Tour) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
// DELETE = soft delete : on pose deletedAt et on re-persiste (pas de
// suppression physique) — le TourProvider exclut ensuite la tournee.
if ($operation instanceof DeleteOperationInterface) {
$data->setDeletedAt(new DateTimeImmutable());
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
// POST : la tournee est personnelle -> owner = utilisateur courant.
if (null === $data->getOwner()) {
$data->setOwner($this->security->getUser());
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
}
@@ -1,88 +0,0 @@
<?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\Route\TourRouteCalculator;
use App\Module\FieldSales\Domain\Entity\Tour;
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}/reorder (M6 § 5, drag & drop).
*
* La tournee est chargee par TourProvider (RG-6.01). Le corps porte l'ordre
* souhaite des etapes (`stopIds` : liste d'ids dans le nouvel ordre). On delegue
* la renumerotation atomique (deux flushes, anti-collision unique (tour_id,
* position)) + le recalcul a {@see TourRouteCalculator::reorder()}, puis on
* retourne la tournee recalculee (200).
*
* Operation deserialize:false : on lit `stopIds` manuellement et on leve une 422
* (propertyPath `stopIds`) si absente ou invalide consommable par useFormErrors.
*
* @implements ProcessorInterface<Tour, Tour>
*/
final class TourReorderProcessor implements ProcessorInterface
{
public function __construct(
private readonly TourRouteCalculator $calculator,
private readonly EntityManagerInterface $em,
private readonly RequestStack $requestStack,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Tour
{
assert($data instanceof Tour);
$this->calculator->reorder($data, $this->readStopIds());
$this->em->flush();
return $data;
}
/**
* Lit et valide `stopIds` depuis le corps JSON : liste non vide d'entiers.
* Leve une 422 portee sur `stopIds` si absente ou malformee.
*
* @return list<int>
*/
private function readStopIds(): array
{
$request = $this->requestStack->getCurrentRequest();
$payload = null !== $request ? json_decode($request->getContent(), true) : null;
$raw = is_array($payload) ? ($payload['stopIds'] ?? null) : null;
if (!is_array($raw) || [] === $raw) {
$this->throwViolation('La liste ordonnée des étapes (stopIds) est obligatoire.');
}
$ids = [];
foreach ($raw as $value) {
if (!is_int($value) && !(is_string($value) && ctype_digit($value))) {
$this->throwViolation('La liste des étapes doit ne contenir que des identifiants entiers.');
}
$ids[] = (int) $value;
}
return $ids;
}
/**
* @return never
*/
private function throwViolation(string $message): void
{
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation($message, null, [], null, 'stopIds', null));
throw new ValidationException($violations);
}
}
@@ -1,129 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\FieldSales\Domain\Entity\Tour;
use App\Module\FieldSales\Domain\Entity\TourStop;
use App\Module\FieldSales\Infrastructure\Tier\TierAddressResolver;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture de la sous-ressource Etape d'une tournee (M6 § 5).
*
* Sequence :
* - POST : rattache l'etape a la tournee parente (Link toProperty 'tour' non
* peuple en ecriture, cf. pattern ClientAddressProcessor::linkParent), puis
* verifie RG-6.03.
* - PATCH : revalide RG-6.03 si la cible/adresse change.
* - DELETE : suppression physique de l'etape.
*
* RG-6.03 (l'adresse appartient au Tiers) : non verifiable par une Assert sur
* l'entite (acces BDD requis). Le TierAddressResolver interroge le schema partage
* en lecture seule (sans import Commercial) ; en cas d'incoherence on leve une
* ValidationException (422) portee sur `addressId`, consommable par useFormErrors.
*
* @implements ProcessorInterface<TourStop, null|TourStop>
*/
final class TourStopProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly TierAddressResolver $tierAddressResolver,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof TourStop) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
$this->validateAddressBelongsToTier($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache l'etape a la tournee parente de la sous-ressource POST
* (/tours/{tourId}/stops). Sur PATCH, no-op (la tournee est deja resolue).
*/
private function linkParent(TourStop $stop, array $uriVariables): void
{
if (null !== $stop->getTour()) {
return;
}
$tourId = $uriVariables['tourId'] ?? null;
if (null === $tourId) {
return;
}
$tour = $tourId instanceof Tour
? $tourId
: $this->em->getRepository(Tour::class)->find($tourId);
// read:false sur le POST : un parent introuvable n'est plus intercepte en
// amont -> 404 explicite (sinon 500 au persist sur tour_id NOT NULL).
if (!$tour instanceof Tour || null !== $tour->getDeletedAt()) {
throw new NotFoundHttpException('Tournée introuvable.');
}
$stop->setTour($tour);
}
/**
* RG-6.03 : pour une etape sur Tiers referentiel (tierType != custom), si une
* adresse est ciblee, elle doit appartenir au Tiers. Le point libre (custom)
* n'a pas d'adresse referentielle -> non concerne (l'entite a deja garanti
* addressId null en custom via le callback).
*/
private function validateAddressBelongsToTier(TourStop $stop): void
{
$tierType = $stop->getTierType();
$tierId = $stop->getTierId();
$addressId = $stop->getAddressId();
// Hors perimetre RG-6.03 : custom, ou champs incomplets (deja couverts par
// le callback RG-6.12), ou type non resoluble en table d'adresses.
if (null === $tierType
|| null === $tierId
|| null === $addressId
|| !$this->tierAddressResolver->isResolvableTierType($tierType)) {
return;
}
if ($this->tierAddressResolver->addressBelongsToTier($tierType, $tierId, $addressId)) {
return;
}
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'L\'adresse sélectionnée n\'appartient pas au Tiers de l\'étape.',
null,
[],
$stop,
'addressId',
$addressId,
));
throw new ValidationException($violations);
}
}
@@ -1,112 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\FieldSales\Domain\Entity\Tour;
use App\Module\FieldSales\Domain\Repository\TourRepositoryInterface;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Security\BusinessRoles;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider des tournees (M6 § 5). Applique RG-6.01 (tournee personnelle) :
* - Collection (GET /api/tours) : filtree sur l'owner courant, sauf admin ou
* role metier Bureau qui voient toutes les tournees. Toujours paginee.
* - Item (GET / PATCH / DELETE /api/tours/{id}) : 404 si soft-deletee, et 404
* si la tournee appartient a un autre commercial (sauf admin / Bureau).
*
* @implements ProviderInterface<Tour>
*/
final class TourProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'App\Module\FieldSales\Infrastructure\Doctrine\DoctrineTourRepository')]
private readonly TourRepositoryInterface $repository,
private readonly Pagination $pagination,
private readonly Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Tour|null
{
if ($operation instanceof CollectionOperationInterface) {
return $this->provideCollection($operation, $context);
}
return $this->provideItem($uriVariables);
}
/**
* @param array<string, mixed> $context
*
* @return list<Tour>|Paginator<Tour>
*/
private function provideCollection(Operation $operation, array $context): array|Paginator
{
// RG-6.01 : la Commerciale ne voit que ses tournees ; admin / Bureau tout.
$ownerFilter = $this->canSeeAll() ? null : $this->security->getUser();
$qb = $this->repository->createListQueryBuilder($ownerFilter);
// Echappatoire ?pagination=false (convention ERP-72).
if (!$this->pagination->isEnabled($operation, $context)) {
// @var list<Tour> $tours
return $qb->getQuery()->getResult();
}
$limit = $this->pagination->getLimit($operation, $context);
$page = max(1, $this->pagination->getPage($context));
$offset = ($page - 1) * $limit;
$qb->setFirstResult($offset)->setMaxResults($limit);
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
}
/**
* @param array<string, mixed> $uriVariables
*/
private function provideItem(array $uriVariables): ?Tour
{
$id = $uriVariables['id'] ?? null;
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
return null;
}
$tour = $this->repository->findById((int) $id);
if (null === $tour || null !== $tour->getDeletedAt()) {
return null;
}
// RG-6.01 : une Commerciale ne peut pas acceder a la tournee d'autrui.
if (!$this->canSeeAll() && $tour->getOwner() !== $this->security->getUser()) {
return null;
}
return $tour;
}
/**
* Vrai si l'utilisateur courant voit/edite toutes les tournees : admin
* (ROLE_ADMIN) ou role metier Bureau (RG-6.01).
*/
private function canSeeAll(): bool
{
if ($this->security->isGranted('ROLE_ADMIN')) {
return true;
}
$user = $this->security->getUser();
return $user instanceof BusinessRoleAwareInterface
&& $user->hasBusinessRole(BusinessRoles::BUREAU);
}
}
@@ -1,322 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Core\Infrastructure\ApiPlatform\Pagination\DbalPaginator;
use App\Module\FieldSales\Application\DTO\VisitableTierOutput;
use Doctrine\DBAL\Connection;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Provider de la resource VisitableTier (M6 § 5, pins de la carte de tournee).
*
* Lit en DBAL pur le schema PARTAGE (client_address + supplier_address jointes a
* client/supplier) aucune classe du module Commercial n'est importee (regle
* ABSOLUE n°1). Les types visitables sont une whitelist de constantes
* (self::SOURCES) ; seuls les Tiers actifs (non archives, non soft-deletes) avec
* une adresse geolocalisee (latitude ET longitude non nulles, RG-6.05) sont
* exposes.
*
* Collection : DbalPaginator (hydra:view auto) ou, sur `?pagination=false`,
* la liste complete bornee par la bbox (la carte affiche TOUS les pins de la
* zone visible la bbox limite le volume, pas la pagination).
*
* Extensible : ajouter un type Visitable = une entree dans self::SOURCES.
*
* @implements ProviderInterface<VisitableTierOutput>
*/
final readonly class VisitableTierProvider implements ProviderInterface
{
/**
* Mapping tierType -> tables/colonnes du schema partage. Identifiants issus
* d'une whitelist de constantes (jamais de l'entree utilisateur) -> aucun
* risque d'injection ; seules les valeurs (bbox, q) sont parametrees.
*
* @var array<string, array{addressTable: string, ownerColumn: string, tierTable: string}>
*/
private const array SOURCES = [
'client' => ['addressTable' => 'client_address', 'ownerColumn' => 'client_id', 'tierTable' => 'client'],
'supplier' => ['addressTable' => 'supplier_address', 'ownerColumn' => 'supplier_id', 'tierTable' => 'supplier'],
];
public function __construct(
#[Autowire(service: 'doctrine.dbal.default_connection')]
private Connection $connection,
private Pagination $pagination,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|DbalPaginator|VisitableTierOutput|null
{
if (!$operation instanceof CollectionOperationInterface) {
return $this->provideItem((string) ($uriVariables['id'] ?? ''));
}
return $this->provideCollection($operation, $context);
}
private function provideItem(string $id): ?VisitableTierOutput
{
// id synthetique « {type}-{addressId} » (cf. VisitableTierOutput::$id).
if (1 !== preg_match('/^([a-z_]+)-([0-9]+)$/', $id, $m)) {
return null;
}
$type = $m[1];
$addressId = (int) $m[2];
$source = self::SOURCES[$type] ?? null;
if (null === $source) {
return null;
}
$sql = $this->buildSelect($source, $type).' AND a.id = :addressId';
/** @var array<string, mixed>|false $row */
$row = $this->connection->fetchAssociative($sql, ['addressId' => $addressId]);
return false === $row ? null : $this->hydrate($row);
}
/**
* @param array<string, mixed> $context
*
* @return DbalPaginator|list<VisitableTierOutput>
*/
private function provideCollection(Operation $operation, array $context): array|DbalPaginator
{
$filters = $context['filters'] ?? [];
$types = $this->extractTypes($filters);
$bbox = $this->extractBbox($filters);
$search = $this->extractSearch($filters);
// Aucun type resoluble demande -> collection vide.
if ([] === $types) {
return $this->pagination->isEnabled($operation, $context)
? new DbalPaginator([], 1, $this->pagination->getLimit($operation, $context), 0)
: [];
}
[$unionSql, $params] = $this->buildUnion($types, $bbox, $search);
// Echappatoire ?pagination=false (convention ERP-72) : la carte charge
// tous les pins de la bbox d'un coup (volume borne par la zone visible).
if (!$this->pagination->isEnabled($operation, $context)) {
/** @var list<array<string, mixed>> $rows */
$rows = $this->connection->fetchAllAssociative(
sprintf('SELECT * FROM (%s) sub ORDER BY display_name ASC, address_id ASC', $unionSql),
$params,
);
return array_map($this->hydrate(...), $rows);
}
$page = max(1, $this->pagination->getPage($context));
$itemsPerPage = $this->pagination->getLimit($operation, $context);
$offset = ($page - 1) * $itemsPerPage;
/** @var list<array<string, mixed>> $rows */
$rows = $this->connection->fetchAllAssociative(
sprintf(
'SELECT * FROM (%s) sub ORDER BY display_name ASC, address_id ASC LIMIT :limit OFFSET :offset',
$unionSql,
),
[...$params, 'limit' => $itemsPerPage, 'offset' => $offset],
);
$totalItems = (int) $this->connection->fetchOne(
sprintf('SELECT COUNT(*) FROM (%s) sub', $unionSql),
$params,
);
$items = array_map($this->hydrate(...), $rows);
return new DbalPaginator($items, $page, $itemsPerPage, $totalItems);
}
/**
* Construit l'UNION ALL des SELECT par type demande, en partageant les memes
* parametres nommes (bbox/q) sur chaque moitie.
*
* @param list<string> $types
* @param null|array{minLng: float, minLat: float, maxLng: float, maxLat: float} $bbox
*
* @return array{0: string, 1: array<string, mixed>}
*/
private function buildUnion(array $types, ?array $bbox, ?string $search): array
{
$halves = [];
foreach ($types as $type) {
$halves[] = $this->buildSelect(self::SOURCES[$type], $type, $bbox, null !== $search);
}
$params = [];
if (null !== $bbox) {
$params += $bbox;
}
if (null !== $search) {
// Echappe %, _ et \ pour un ILIKE « contient » litteral.
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $search);
$params['q'] = '%'.$escaped.'%';
}
return [implode(' UNION ALL ', $halves), $params];
}
/**
* SELECT d'une source (table d'adresses + Tiers). Filtre toujours sur Tiers
* actif + adresse geolocalisee ; ajoute bbox/q selon les arguments.
*
* @param array{addressTable: string, ownerColumn: string, tierTable: string} $source
* @param null|array{minLng: float, minLat: float, maxLng: float, maxLat: float} $bbox
*/
private function buildSelect(array $source, string $type, ?array $bbox = null, bool $withSearch = false): string
{
$sql = sprintf(
"SELECT '%s' AS tier_type, a.%s AS tier_id, a.id AS address_id, "
.'t.company_name AS display_name, a.street, a.street_complement, a.postal_code, a.city, '
.'a.latitude, a.longitude '
.'FROM %s a JOIN %s t ON t.id = a.%s '
.'WHERE a.latitude IS NOT NULL AND a.longitude IS NOT NULL '
.'AND t.is_archived = FALSE AND t.deleted_at IS NULL',
$type,
$source['ownerColumn'],
$source['addressTable'],
$source['tierTable'],
$source['ownerColumn'],
);
if (null !== $bbox) {
$sql .= ' AND a.latitude BETWEEN :minLat AND :maxLat AND a.longitude BETWEEN :minLng AND :maxLng';
}
if ($withSearch) {
$sql .= ' AND (t.company_name ILIKE :q OR a.city ILIKE :q)';
}
return $sql;
}
/**
* Types demandes (?type=client,supplier), intersectes avec la whitelist.
* Defaut = tous les types resolubles. Un type inconnu -> 400 explicite.
*
* @param array<string, mixed> $filters
*
* @return list<string>
*/
private function extractTypes(array $filters): array
{
$raw = $filters['type'] ?? null;
if (null === $raw || '' === $raw) {
return array_keys(self::SOURCES);
}
$requested = is_array($raw) ? $raw : explode(',', (string) $raw);
$types = [];
foreach ($requested as $t) {
$t = trim((string) $t);
if ('' === $t) {
continue;
}
if (!isset(self::SOURCES[$t])) {
throw new BadRequestHttpException(sprintf(
'Filtre "type" invalide : "%s". Valeurs autorisees : %s.',
$t,
implode(', ', array_keys(self::SOURCES)),
));
}
$types[$t] = true;
}
return array_keys($types);
}
/**
* Parse ?bbox=minLng,minLat,maxLng,maxLat (format Leaflet
* getBounds().toBBoxString() = west,south,east,north). Absent -> null (pas de
* filtre geo). Malforme -> 400.
*
* @param array<string, mixed> $filters
*
* @return null|array{minLng: float, minLat: float, maxLng: float, maxLat: float}
*/
private function extractBbox(array $filters): ?array
{
$raw = $filters['bbox'] ?? null;
if (null === $raw || '' === $raw) {
return null;
}
$parts = explode(',', (string) $raw);
if (4 !== count($parts)) {
throw new BadRequestHttpException('Filtre "bbox" invalide : 4 valeurs attendues (minLng,minLat,maxLng,maxLat).');
}
foreach ($parts as $p) {
if (!is_numeric(trim($p))) {
throw new BadRequestHttpException('Filtre "bbox" invalide : coordonnees numeriques attendues.');
}
}
return [
'minLng' => (float) $parts[0],
'minLat' => (float) $parts[1],
'maxLng' => (float) $parts[2],
'maxLat' => (float) $parts[3],
];
}
/**
* @param array<string, mixed> $filters
*/
private function extractSearch(array $filters): ?string
{
$raw = $filters['q'] ?? null;
return is_string($raw) && '' !== trim($raw) ? trim($raw) : null;
}
/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): VisitableTierOutput
{
$type = (string) $row['tier_type'];
$addressId = (int) $row['address_id'];
return new VisitableTierOutput(
id: sprintf('%s-%d', $type, $addressId),
tierType: $type,
tierId: (int) $row['tier_id'],
addressId: $addressId,
displayName: (string) $row['display_name'],
address: $this->formatAddress($row),
latitude: (float) $row['latitude'],
longitude: (float) $row['longitude'],
);
}
/**
* Adresse sur une ligne : « rue [complement], CP ville ».
*
* @param array<string, mixed> $row
*/
private function formatAddress(array $row): string
{
$street = trim(implode(' ', array_filter([
null !== $row['street'] ? (string) $row['street'] : null,
null !== $row['street_complement'] ? (string) $row['street_complement'] : null,
])));
$cityLine = trim(implode(' ', array_filter([
null !== $row['postal_code'] ? (string) $row['postal_code'] : null,
null !== $row['city'] ? (string) $row['city'] : null,
])));
return trim(implode(', ', array_filter([$street, $cityLine])));
}
}
@@ -1,217 +0,0 @@
<?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 (, 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;
}
}
@@ -1,51 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\Doctrine;
use App\Module\FieldSales\Domain\Entity\Tour;
use App\Module\FieldSales\Domain\Repository\TourRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @extends ServiceEntityRepository<Tour>
*/
class DoctrineTourRepository extends ServiceEntityRepository implements TourRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Tour::class);
}
public function findById(int $id): ?Tour
{
return $this->find($id);
}
public function save(Tour $tour): void
{
$this->getEntityManager()->persist($tour);
$this->getEntityManager()->flush();
}
public function createListQueryBuilder(?UserInterface $owner = null): QueryBuilder
{
// Exclut toujours les tournees soft-deletees (RG : deletedAt IS NULL).
$qb = $this->createQueryBuilder('t')
->andWhere('t.deletedAt IS NULL')
->orderBy('t.tourDate', 'DESC')
->addOrderBy('t.label', 'ASC')
;
// RG-6.01 : filtre proprietaire pour la Commerciale (owner non null).
if (null !== $owner) {
$qb->andWhere('t.owner = :owner')->setParameter('owner', $owner);
}
return $qb;
}
}
@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\Doctrine;
use App\Module\FieldSales\Domain\Entity\TourStop;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TourStop>
*/
class DoctrineTourStopRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TourStop::class);
}
}
@@ -1,149 +0,0 @@
<?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);
}
}
@@ -1,143 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales\Infrastructure\Tier;
use Doctrine\DBAL\Connection;
/**
* Verifie qu'une adresse appartient bien a un Tiers du referentiel (RG-6.03),
* sans importer aucune classe des modules Commercial (Client / Supplier) regle
* ABSOLUE n°1.
*
* Approche : lecture seule du schema PARTAGE par nom de table. Une etape de
* tournee cible un Tiers polymorphe (tierType -> table d'adresses + colonne FK
* du proprietaire). On interroge la table d'adresses correspondante en DBAL pur :
* aucune dependance de code vers Commercial, seulement une lecture du schema
* commun (integration « shared database » assumee du monolithe modulaire).
*
* Extensible : ajouter un type Visitable (ex: prestataire) = une entree dans
* self::ADDRESS_TABLES.
*/
final class TierAddressResolver
{
/**
* Mapping tierType -> [table d'adresses, colonne FK du Tiers proprietaire].
* Aligne sur les tables M1 (client_address.client_id) et M2
* (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, tierTable: string}>
*/
private const array ADDRESS_TABLES = [
'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) {}
/**
* Vrai si l'adresse $addressId existe ET appartient au Tiers ($tierType,
* $tierId). Faux si l'adresse n'existe pas, appartient a un autre Tiers, ou
* si le type n'est pas resoluble en table d'adresses (ex: custom).
*/
public function addressBelongsToTier(string $tierType, int $tierId, int $addressId): bool
{
$mapping = self::ADDRESS_TABLES[$tierType] ?? null;
if (null === $mapping) {
return false;
}
// Noms de table/colonne issus d'une whitelist de constantes (jamais de
// l'entree utilisateur) ; seuls les ids sont parametres.
$sql = sprintf(
'SELECT 1 FROM %s WHERE id = :addressId AND %s = :tierId',
$mapping['table'],
$mapping['ownerColumn'],
);
$found = $this->connection->fetchOne($sql, [
'addressId' => $addressId,
'tierId' => $tierId,
]);
return false !== $found;
}
/**
* Vrai si le tierType cible un Tiers du referentiel adressable (par
* opposition au point libre `custom`, qui n'a pas de table d'adresses).
*/
public function isResolvableTierType(string $tierType): bool
{
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,
];
}
}
@@ -1,49 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Shared\Application\Service;
use App\Shared\Domain\Contract\GeocoderInterface;
use App\Shared\Domain\Contract\GeolocatableAddressInterface;
use DateTimeImmutable;
/**
* Geocodage automatique d'une adresse Tiers a la creation / mise a jour
* (M6.1, spec § 7). Appele par les processors d'adresse (ClientAddressProcessor
* / SupplierAddressProcessor) AVANT le persist.
*
* RG-6.08 : si le pin a ete corrige a la main (geoManual = true), les
* coordonnees sont FIGEES aucun geocodage, aucune reecriture. Le front
* « re-geocode » en repassant geoManual a false (le prochain save regeocode).
*
* En cas d'echec du geocodage (BAN indisponible, adresse introuvable), les
* coordonnees existantes sont CONSERVEES en l'etat : pas de retour en arriere
* silencieux, l'adresse reste sauvegardable (badge « a geolocaliser » cote
* front si elle n'a aucune coordonnee).
*/
final class AddressGeocoder
{
public function __construct(
private readonly GeocoderInterface $geocoder,
) {}
public function geocode(GeolocatableAddressInterface $address): void
{
// RG-6.08 : pin manuel -> coordonnees figees.
if ($address->isGeoManual()) {
return;
}
$coordinates = $this->geocoder->geocode($address->getDisplayLabel());
if (null === $coordinates) {
return;
}
$address
->setLatitude($coordinates->latitude)
->setLongitude($coordinates->longitude)
->setGeocodedAt(new DateTimeImmutable())
;
}
}
@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
use App\Shared\Domain\ValueObject\Coordinates;
/**
* Geocodage d'une adresse postale en coordonnees WGS84 (M6.1, spec § 7).
*
* Encapsule le fournisseur de geocodage (decision Q-M6-3 : BAN
* api-adresse.data.gouv.fr) derriere un contrat pour pouvoir en changer sans
* toucher aux appelants.
*
* Contrat d'erreur : `null` si l'adresse n'est pas geocodable (aucun resultat,
* score trop faible) OU si le fournisseur est indisponible (reseau, 5xx). Le
* geocodage ne doit JAMAIS bloquer la sauvegarde d'une adresse une adresse
* sans coordonnees reste valide (badge « a geolocaliser », spec § 3.2).
*/
interface GeocoderInterface
{
public function geocode(string $address): ?Coordinates;
}
@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
use DateTimeImmutable;
/**
* Adresse Tiers geolocalisable (M6.1, spec § 3.2 / § 4.1).
*
* Implementee par ClientAddress et SupplierAddress (module Commercial) ; le
* futur module FieldSales consommera ce contrat pour router les tournees sans
* importer le module Commercial (regle ABSOLUE n°1).
*
* Les setters (latitude / longitude / geocodedAt) sont la surface d'ecriture
* du service de geocodage automatique (AddressGeocoder) ; isGeoManual() porte
* la RG-6.08 un pin corrige a la main fige les coordonnees, le geocodage
* auto ne les reecrit plus.
*/
interface GeolocatableAddressInterface
{
/** Latitude WGS84 en chaine decimale NUMERIC(10,7), null si non geolocalisee. */
public function getLatitude(): ?string;
/** Longitude WGS84 en chaine decimale NUMERIC(10,7), null si non geolocalisee. */
public function getLongitude(): ?string;
/** Adresse postale affichable / geocodable (rue, code postal, ville). */
public function getDisplayLabel(): string;
/** RG-6.08 : pin corrige a la main — le geocodage auto ne reecrit plus. */
public function isGeoManual(): bool;
public function setLatitude(float|string|null $latitude): static;
public function setLongitude(float|string|null $longitude): static;
/** Date du dernier geocodage automatique reussi (posee par AddressGeocoder). */
public function setGeocodedAt(?DateTimeImmutable $geocodedAt): static;
}
@@ -1,31 +0,0 @@
<?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;
}
@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat partage rendant un Tiers « visitable » par le module FieldSales (M6),
* sans creer de couplage direct FieldSales -> Commercial (regle ABSOLUE n°1).
*
* Implemente par les Tiers du referentiel : Client (M1) et Supplier (M2), et
* extensible aux futurs types (Prestataire...) sans toucher au module FieldSales.
*
* Resolution polymorphe : une etape de tournee (TourStop, ERP-124) cible un Tiers
* via le couple (`tier_type`, `tier_id`) plutot que via une association Doctrine.
* `getVisitableType()` fournit la valeur stable de `tier_type` ('client',
* 'supplier', ...) qui permet, cote service, de retrouver l'implementation
* concrete a partir de l'id. Cette interface n'est donc PAS une cible
* `resolve_target_entities` (qui ne mappe qu'un contrat -> une seule classe,
* alors qu'ici plusieurs entites l'implementent) : cf. note dans doctrine.yaml.
*/
interface VisitableInterface
{
/**
* Identifiant du Tiers (null tant que non persiste).
*/
public function getId(): ?int;
/**
* Libelle affichable du Tiers (ex: raison sociale), pour les pins de la carte
* et la liste d'etapes d'une tournee. Jamais null (chaine vide a defaut).
*/
public function getDisplayName(): string;
/**
* Type stable du Tiers, valeur portee par `tour_stop.tier_type`
* ('client' | 'supplier' | ... ). Volontairement une string (et non un enum
* ferme) pour rester ouvert aux futurs types Visitable + au point `custom`.
*/
public function getVisitableType(): string;
}

Some files were not shown because too many files have changed in this diff Show More