Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e607cccf08 | |||
| 8b8fb8c2aa | |||
| f9fec3e908 | |||
| 4f8ed075b6 | |||
| 1e783bd753 | |||
| 9f4f45f761 | |||
| e99747ac72 | |||
| 36edd11854 | |||
| 45cb5c834c | |||
| 2689b85ebe | |||
| f4bbc79550 | |||
| f057866e75 | |||
| 19fdb50cec | |||
| 368bb50ffb | |||
| 6a83adc00a | |||
| c76c447aa2 | |||
| 19ac8833eb | |||
| c25c33116d | |||
| 17aa61d014 | |||
| 3d4ae391fe | |||
| 04c794addb | |||
| c1e45cd582 | |||
| a6f01400ba | |||
| d0e9f48983 | |||
| c1206fa29c | |||
| 090ea5eb49 | |||
| ee1f344764 | |||
| 3fe0f676f6 | |||
| d5462bcf42 | |||
| 54d8327fa5 | |||
| 09a4b9d464 | |||
| d97b9ce6d0 | |||
| b36520d3b1 | |||
| a340d8139a |
@@ -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`.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+1
-446
@@ -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": "b029c1484227c926d39dfd3ae5cb0699",
|
||||
"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",
|
||||
@@ -9081,149 +8779,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",
|
||||
|
||||
+4
-2
@@ -4,13 +4,15 @@ 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;
|
||||
use App\Module\Technique\TechniqueModule;
|
||||
use App\Module\Transport\TransportModule;
|
||||
|
||||
return [
|
||||
CoreModule::class,
|
||||
CommercialModule::class,
|
||||
SitesModule::class,
|
||||
CatalogModule::class,
|
||||
FieldSalesModule::class,
|
||||
TechniqueModule::class,
|
||||
TransportModule::class,
|
||||
];
|
||||
|
||||
@@ -12,11 +12,9 @@ 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'
|
||||
# Entites techniques partagees portant un #[ApiResource]
|
||||
# (UploadedDocument — infra upload generique ERP-154).
|
||||
- '%kernel.project_dir%/src/Shared/Domain/Entity'
|
||||
formats:
|
||||
jsonld: ['application/ld+json']
|
||||
json: ['application/json']
|
||||
|
||||
@@ -8,16 +8,24 @@ doctrine:
|
||||
default:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
# Exclut `audit_log` de toute operation de comparaison de schema
|
||||
# (doctrine:schema:update, schema:validate, diff de migrations...).
|
||||
# Cette table n'a volontairement aucune entite mappee : elle est
|
||||
# append-only via DBAL brut (AuditLogWriter) pour eviter la
|
||||
# recursion du listener Doctrine. Sans ce filtre, schema:update
|
||||
# la considere comme "orpheline" et genere un `DROP TABLE
|
||||
# audit_log` qui casse la base de test apres chaque
|
||||
# `make test-db-setup`. La creation / suppression de la table
|
||||
# reste pilotee par les migrations (cf. Version20260420202749).
|
||||
schema_filter: '~^(?!audit_log$).+~'
|
||||
# Exclut certaines tables de toute operation de comparaison de
|
||||
# schema (doctrine:schema:update, schema:validate, diff de
|
||||
# migrations...). Ces tables n'ont volontairement aucune entite
|
||||
# mappee :
|
||||
# - `audit_log` : append-only via DBAL brut (AuditLogWriter) pour
|
||||
# eviter la recursion du listener Doctrine.
|
||||
# - `qualimat_carrier` / `qualimat_sync_log` : referentiel
|
||||
# transporteurs synchronise en DBAL brut (upsert `ON CONFLICT`)
|
||||
# par `app:qualimat:sync`, hors ORM.
|
||||
# - `idtf_product` / `idtf_sync_log` : referentiel codes IDTF
|
||||
# synchronise en DBAL brut par `app:idtf:sync`, hors ORM.
|
||||
# Sans ce filtre, schema:update les considere comme "orphelines" et
|
||||
# genere un `DROP TABLE` qui casse la base de test apres chaque
|
||||
# `make test-db-setup` (la migration les a creees, schema:update les
|
||||
# supprime juste apres). Creation / suppression restent pilotees par
|
||||
# les migrations (audit_log : Version20260420202749 ; qualimat :
|
||||
# Version20260612150000 ; idtf : Version20260612160000).
|
||||
schema_filter: '~^(?!(?:audit_log|qualimat_carrier|qualimat_sync_log|idtf_product|idtf_sync_log)$).+~'
|
||||
audit:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
orm:
|
||||
@@ -41,14 +49,17 @@ 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:
|
||||
# Mapping des entites techniques partagees (src/Shared/Domain/Entity).
|
||||
# Premier occupant : UploadedDocument (infra upload generique ERP-154).
|
||||
# Necessaire car les entites Shared ne sont pas couvertes par
|
||||
# l'auto_mapping (qui ne cible que les bundles).
|
||||
Shared:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Shared/Domain/Entity'
|
||||
prefix: 'App\Shared\Domain\Entity'
|
||||
alias: Shared
|
||||
Core:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
@@ -87,16 +98,16 @@ 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:
|
||||
# Mapping inconditionnel du module Technique (meme logique que Commercial) :
|
||||
# les tables prestataires (provider + sous-collections + jointures M2M)
|
||||
# creees par la migration M3 (Version20260612100000) doivent etre connues
|
||||
# de l'ORM. L'activation fonctionnelle passe par config/modules.php.
|
||||
Technique:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/FieldSales/Domain/Entity'
|
||||
prefix: 'App\Module\FieldSales\Domain\Entity'
|
||||
alias: FieldSales
|
||||
dir: '%kernel.project_dir%/src/Module/Technique/Domain/Entity'
|
||||
prefix: 'App\Module\Technique\Domain\Entity'
|
||||
alias: Technique
|
||||
controller_resolver:
|
||||
auto_mapping: false
|
||||
|
||||
|
||||
@@ -2,4 +2,5 @@ doctrine_migrations:
|
||||
migrations_paths:
|
||||
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
||||
'App\Module\Core\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/Core/Infrastructure/Doctrine/Migrations'
|
||||
'App\Module\Transport\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/Transport/Infrastructure/Doctrine/Migrations'
|
||||
enable_profiler: false
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Active le composant HTTP Client (symfony/http-client) et enregistre
|
||||
# l'autowiring de HttpClientInterface. Utilise par les commandes de
|
||||
# synchronisation de referentiels externes (QUALIMAT, IDTF...).
|
||||
#
|
||||
# User-Agent navigateur neutre : les sources (qualimat.org sous WordPress/WAF,
|
||||
# icrt-idtf.com) filtrent souvent les UA de bibliotheque/vides ; un UA de type
|
||||
# navigateur evite les blocages anti-bot sans reveler l'application.
|
||||
framework:
|
||||
http_client:
|
||||
default_options:
|
||||
timeout: 30
|
||||
headers:
|
||||
User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
||||
@@ -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
|
||||
|
||||
+28
-11
@@ -61,20 +61,37 @@ 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.
|
||||
// Section "Technique" (M3, ERP-138) : pole distinct du Commercial, porte le
|
||||
// repertoire prestataires. L'item est gate par `technique.providers.view` ;
|
||||
// la section disparait automatiquement (SidebarProvider) si le module
|
||||
// `technique` est desactive ou si l'user n'a pas la permission.
|
||||
[
|
||||
'label' => 'sidebar.field_sales.section',
|
||||
'icon' => 'mdi:map-marker-path',
|
||||
'label' => 'sidebar.technique.section',
|
||||
'icon' => 'mdi:account-convert-outline',
|
||||
'items' => [
|
||||
[
|
||||
'label' => 'sidebar.field_sales.tours',
|
||||
'to' => '/tours',
|
||||
'icon' => 'mdi:map-marker-path',
|
||||
'module' => 'field_sales',
|
||||
'permission' => 'field_sales.tours.view',
|
||||
'label' => 'sidebar.technique.providers',
|
||||
'to' => '/providers',
|
||||
'icon' => 'mdi:account-wrench-outline',
|
||||
'module' => 'technique',
|
||||
'permission' => 'technique.providers.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Transport" (M4, ERP-153) : pole logistique, porte le repertoire
|
||||
// transporteurs. L'item est gate par `transport.carriers.view` ; la section
|
||||
// disparait automatiquement (SidebarProvider) si le module `transport` est
|
||||
// desactive ou si l'user n'a pas la permission (Compta / Usine).
|
||||
[
|
||||
'label' => 'sidebar.transport.section',
|
||||
'icon' => 'mdi:truck-outline',
|
||||
'items' => [
|
||||
[
|
||||
'label' => 'sidebar.transport.carriers',
|
||||
'to' => '/carriers',
|
||||
'icon' => 'mdi:truck-outline',
|
||||
'module' => 'transport',
|
||||
'permission' => 'transport.carriers.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.109'
|
||||
app.version: '0.1.126'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,339 @@
|
||||
---
|
||||
# === IDENTITÉ ===
|
||||
module: M3
|
||||
nom: "Répertoire prestataires"
|
||||
ecran: repertoire-prestataires
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V0.2
|
||||
date_redaction: 2026-06-11
|
||||
# Historique :
|
||||
# V0.2 (2026-06-11) — Restitution Markdown du docx « M3-reportoire-prestataires.docx » (04/06/2026).
|
||||
# Alignement refonte-contact (comme M1/M2) : le contact principal inline du formulaire principal
|
||||
# du PDF V0.1 (Nom contact / Prénom contact / Téléphone + / Email) est RETIRÉ — saisie via
|
||||
# l'onglet Contacts uniquement (décision Matthieu, 11/06 : « oublie le contact inline, comme client »).
|
||||
# RG-3.01 / RG-3.02 (contact inline + max 2 tél sur le formulaire principal) supprimées en conséquence.
|
||||
# V0.1 (PDF) — version fonctionnelle plus ancienne, NON retenue (contact inline sur le formulaire principal).
|
||||
|
||||
# === LIENS ===
|
||||
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-42090&p=f&m=dev"
|
||||
regles_metier: [RG-3.03, RG-3.04, RG-3.05, RG-3.06, RG-3.07, RG-3.08, RG-3.09, RG-3.10, RG-3.11, RG-3.12, RG-3.13, RG-3.14, RG-3.15, RG-3.16, RG-3.17]
|
||||
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||
lien_spec_back: ./spec-back.md
|
||||
|
||||
# === VALIDATION CLIENT ===
|
||||
client_validation_1:
|
||||
statut: validee
|
||||
date: 2026-05-22
|
||||
version: V0
|
||||
valide_par: "Matthieu (CP MALIO)"
|
||||
client_validation_2:
|
||||
statut: validee
|
||||
date: 2026-06-01
|
||||
version: V0.1
|
||||
valide_par: "Matthieu (CP MALIO)"
|
||||
client_validation_3:
|
||||
statut: a_valider
|
||||
date: 2026-06-04
|
||||
version: V0.2
|
||||
resume: "Module 3 — Répertoire prestataires. Pôle Technique (nouvelle section sidebar). Datatable + 3 écrans (Ajouter / Consulter / Modifier). Création par onglets : Contact / Adresse / Comptabilité (Rapports, Échanges = placeholders 'À venir'). PAS d'onglet Information. Sélecteur de site aussi sur le formulaire principal."
|
||||
trace_archivee: "uploads/M3-reportoire-prestataires.docx (V0.2) + M3-reportoire-prestataires-V01.pdf (V0.1, obsolète)"
|
||||
|
||||
# === LIEN LESSTIME ===
|
||||
lesstime_taskgroup_id: 29 # M3 — Répertoire prestataires (projet STARSEED #6)
|
||||
lesstime_project_id: 6
|
||||
statut_global: en_dev
|
||||
---
|
||||
|
||||
# Module 3 — Répertoire prestataires (V0.2 front)
|
||||
|
||||
> **Origine** : spec fonctionnelle `M3-reportoire-prestataires.docx` (V0.2 du 04/06/2026 ; historique V0 22/05 → V0.1 01/06). Restitution Markdown pour intégration au workflow MALIO. Le contenu fonctionnel original n'est pas modifié, **sauf** l'alignement refonte-contact (cf. ci-dessous). Toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M3 réutilise massivement le pattern et les composants posés au [M1 clients](../M1-clients/spec-front.md) et au [M2 fournisseurs](../M2-suppliers/spec-front.md).
|
||||
|
||||
> **⚠️ Alignement refonte-contact (décision Matthieu, 11/06/2026)** : le PDF V0.1 portait un **contact principal inline** sur le formulaire principal (Nom du contact / Prénom du contact / Téléphone + bouton + / Email) avec RG-3.01 (Nom OU Prénom) et RG-3.02 (max 2 téléphones). Ce contact inline est **retiré**, exactement comme l'a fait M1/M2 (refonte-contact). Les coordonnées du contact se saisissent **uniquement dans l'onglet Contacts**. **RG-3.01 et RG-3.02 sont donc supprimées du formulaire principal** ; la garantie « au moins un contact nommé » est portée par RG-3.04 + RG-3.12, et le « maximum 2 téléphones » s'applique aux blocs Contact.
|
||||
|
||||
> **⚠️ Décision d'architecture (à confirmer) — pôle « Technique »** : le docx place le répertoire prestataires dans un **Module « Technique »**. Confirmé par Matthieu (11/06) : c'est bien un **nouveau pôle Technique**, distinct du Commercial. Côté front cela se traduit par une **nouvelle section sidebar « Technique »** (route `/providers`). Côté back, voir [`spec-back.md § 2.1`](./spec-back.md) (nouveau module `Technique`, entités jumelles du fournisseur, référentiels comptables consommés en relation ORM partagée).
|
||||
|
||||
## But
|
||||
|
||||
Lister tous les prestataires de l'organisation et accéder rapidement à leurs fiches : consultation, création, modification, archivage. C'est la **porte d'entrée du pôle Technique**.
|
||||
|
||||
## Accès
|
||||
|
||||
- **Depuis** : menu principal → section **Technique** → entrée « Répertoire prestataires » (route `/providers`).
|
||||
- **Rôles autorisés** (tableau « Rôles & permissions » du docx) :
|
||||
|
||||
| Rôle | Consultation | Création / Modification | Archivage |
|
||||
|---|---|---|---|
|
||||
| **Admin** | ✅ Tout | ✅ Tout | ✅ |
|
||||
| **Bureau** | ✅ Tout | ✅ Tout sauf onglet Comptabilité | ❌ |
|
||||
| **Compta** | ✅ Tout | ✅ Onglet Comptabilité uniquement | ❌ |
|
||||
| **Commerciale** | ✅ Tout sauf Comptabilité | ✅ Tout sauf Comptabilité | ❌ |
|
||||
| **Usine** | ✅ Son site uniquement | — | ❌ |
|
||||
|
||||
> **Notes** :
|
||||
> - RBAC transposée sur `technique.providers.*` (cf. [`spec-back.md § 2.9 / § 5`](./spec-back.md)). Compta édite uniquement l'onglet Comptabilité d'un prestataire existant ; Compta ne peut pas **créer** un prestataire. **L'archivage est réservé à Admin**.
|
||||
> - **Cloisonnement par site (décision 11/06 — DANS LE PÉRIMÈTRE M3)** : « Tout » vs « son site uniquement » n'est **pas porté par le rôle** mais par l'**utilisateur**. Chaque user a un site courant ; **par défaut il ne voit que les prestataires rattachés à son site**. Les profils qui doivent voir tous les sites (Admin, et par défaut Bureau / Compta / Commerciale) ont la permission `sites.bypass_scope` (Admin l'a automatiquement). **Usine** n'a pas le bypass → cloisonnée à son site. Filtrage **automatique côté back** (cf. [`spec-back.md § 2.13`](./spec-back.md)) — aucun filtre à coder côté front.
|
||||
|
||||
## Navigation
|
||||
|
||||
Page d'entrée du pôle **Technique** (route `/providers`). Titre : « **Répertoire prestataires** ».
|
||||
|
||||
- Affichage principal : un **datatable** listant tous les prestataires **actifs** (les archivés sont masqués par défaut — toggle/filtre dédié).
|
||||
- **Clic sur une ligne** → écran **Consultation prestataire** (page dédiée).
|
||||
- **Bouton « + Ajouter »** (haut droite) → écran **Ajouter un prestataire**.
|
||||
- **Bouton « Filtrer »** (haut droite) → panneau de filtres (cf. ci-dessous).
|
||||
- **Bouton « Exporter »** (haut droite) → télécharge un **XLSX** des prestataires **affichés** (cf. filtres actifs). Format dans [`spec-back.md § 4.6`](./spec-back.md).
|
||||
|
||||
### Panneau de filtres (bouton « Filtrer »)
|
||||
|
||||
Réutilise le pattern M1/M2. Filtres branchés sur les query params de `GET /api/providers` (cf. [`spec-back.md § 4.1`](./spec-back.md)) :
|
||||
|
||||
| Filtre | Composant | Query param back |
|
||||
|---|---|---|
|
||||
| **Recherche** (nom entreprise / contact / email) | `<MalioInputText>` | `?search=` |
|
||||
| **Catégorie** | `<MalioSelectCheckbox>` (multi, type PRESTATAIRE) | `?categoryCode=` |
|
||||
| **Site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | `?siteId=` |
|
||||
| **Inclure les archivés** | `<MalioCheckbox>` | `?includeArchived=true` |
|
||||
|
||||
- À l'application des filtres → `setFilters(...)` de `usePaginatedList` (retombe en **page 1**), qui relance `GET /api/providers`.
|
||||
- **État 100 % local** (jamais dans l'URL — règle ABSOLUE n°6).
|
||||
|
||||
## Datatable du Répertoire
|
||||
|
||||
Composant : `<MalioDataTable>` branché sur `usePaginatedList<Provider>({ url: '/providers' })` (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes (alignées M2) :
|
||||
|
||||
| Colonne | Source | Tri |
|
||||
|---|---|---|
|
||||
| **Nom** | `provider.companyName` | ASC par défaut |
|
||||
| **Catégories** | `provider.categories[].name` (embarquées en liste — cohérence M1/M2 ; libellé = `name`, pas `label`) | Non |
|
||||
| **Site** | `provider.sites[].name` (sites du prestataire — cf. note ci-dessous) | Non |
|
||||
| **Dernière activité** | `provider.updatedAt` (format `JJ-MM-AAAA`) — exposé dans `provider:read` | Oui |
|
||||
|
||||
> **Source de la colonne « Site »** : le M3 porte un sélecteur de site **sur le formulaire principal** (RG-3.03) — donc `provider.sites[]` est une relation **directe** du prestataire (≠ M2 où les sites venaient de l'agrégat des adresses). La colonne liste affiche ces sites directs. Voir [`spec-back.md § 2.12`](./spec-back.md).
|
||||
> **Clic sur une ligne** → écran Consultation. **Pagination** : standard Starseed 10 / 25 / 50 (défaut 10). Tri serveur `companyName ASC` par défaut.
|
||||
|
||||
## Écran « Ajouter un prestataire »
|
||||
|
||||
Création par **onglets successifs avec validation incrémentale** : pour passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant** ; les champs validés passent en lecture seule + bouton « Valider » désactivé (disabled). Cf. [`spec-back.md § 2.10`](./spec-back.md) (PATCH partiels par groupe de sérialisation).
|
||||
|
||||
**Accès** : bouton « + Ajouter » du Répertoire. **Rôles** : Admin, Bureau.
|
||||
|
||||
**Barre d'onglets en création (3 onglets)** : `Contact` · `Adresse` · `Comptabilité`. Les onglets `Rapports` et `Échanges` **n'apparaissent PAS dans le flux de création** — ils ne sont présents qu'en Consultation / Modification (placeholders « À venir »).
|
||||
|
||||
> **Différence majeure avec M2 : PAS d'onglet « Information ».** Le M3 n'a aucun champ Description / Concurrent / Date création / Salariés / CA / Dirigeant / Résultat / Volume. Le formulaire principal est minimal (3 champs).
|
||||
|
||||
> **Règle « placeholder par défaut » (convention MALIO)** : tout onglet ou écran que la spec ne détaille pas explicitement (ici **Rapports** et **Échanges**) est livré en **placeholder « À venir »** (frame vide, navigable, pas de validation ni d'API), à l'identique des autres modules (M1/M2). Aucun champ inventé hors spec.
|
||||
|
||||
### Formulaire principal (pré-onglets)
|
||||
|
||||
1er bloc à remplir. Sans validation, les onglets ne sont pas accessibles. Une fois validé → POST `/api/providers`, puis bascule sur l'onglet Contact ; les champs passent en readonly.
|
||||
|
||||
| Champ | Type composant | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom du prestataire (Entreprise)** | `<MalioInputText>` | Oui | RG-3.11 (UPPERCASE serveur) ; RG-3.10 (unicité) |
|
||||
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | `Category` de **type PRESTATAIRE** via `GET /api/categories?typeCode=PRESTATAIRE` (RG-3.09). Libellé affiché = `category.name`. |
|
||||
| **Sélecteur de site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | Oui | RG-3.03 — ≥ 1 site. Les 3 cases = les 3 `Site` fixes ; libellés « 86/17/82 » = **préfixe du `postalCode`** (86100 / 17400 / 82400), pas un `Site.code` (qui n'existe pas). La sélection stocke des **IDs de Site** (M2M `provider_site`). |
|
||||
|
||||
**Action** : « Valider » (`<MalioButton>`) → POST `/api/providers` ([`spec-back.md § 4.3`](./spec-back.md)). Succès → onglet « Contact ».
|
||||
|
||||
### Onglet « Contact »
|
||||
|
||||
Saisir un ou plusieurs contacts. Au moins un bloc Contact valide est requis (RG-3.12). **(Refonte-contact : pas de pré-remplissage depuis le formulaire principal ; les coordonnées du contact se saisissent directement ici.)**
|
||||
|
||||
**Bloc Contact** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom** | `<MalioInputText>` | Conditionnel | RG-3.04 + RG-3.11 (Capitalize) |
|
||||
| **Prénom** | `<MalioInputText>` | Conditionnel | RG-3.04 + RG-3.11 (Capitalize) |
|
||||
| **Fonction** | `<MalioInputText>` | Non | — |
|
||||
| **Téléphone** (x1, +1 possible, **max 2**) | `<MalioInputText>` | Non | RG-3.11 (format) ; max 2 téléphones par contact |
|
||||
| **Email** | `<MalioInputText>` type email | Non | RG-3.11 (lowercase) |
|
||||
|
||||
**RG-3.04 / RG-3.12** : un bloc Contact est valide dès qu'au moins 1 champ est rempli ; au moins 1 bloc Contact valide pour finaliser l'onglet — l'onglet Contact ne peut pas être validé vide.
|
||||
|
||||
**Actions** :
|
||||
- « + Nouveau contact » : ajoute un bloc. **Désactivé tant que le bloc précédent n'a pas au moins 1 champ rempli** (RG-3.04).
|
||||
- « Supprimer » (icône) : modal de confirmation, puis suppression du bloc.
|
||||
- « Valider » → PATCH `/api/providers/{id}/contacts`.
|
||||
|
||||
### Onglet « Adresse »
|
||||
|
||||
Saisir une ou plusieurs adresses, rattachées à un ou plusieurs sites (86 / 17 / 82) et à des contacts.
|
||||
|
||||
**Bloc Adresse** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Sélecteur de site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | Oui | RG-3.05 — ≥ 1 site. Stocke des IDs de Site (M2M `provider_address_site`). |
|
||||
| **Adresse** | `<MalioInputText>` (saisie assistée) | Oui | RG-3.06 — autocomplete BAN |
|
||||
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
|
||||
| **Code postal** | `<MalioInputText>` (saisie assistée) | Oui | RG-3.06 — déclenche autocomplete ville (BAN) |
|
||||
| **Ville** | `<MalioSelect>` (saisie assistée) | Oui | RG-3.06 — alimentée par api-adresse.data.gouv.fr suivant le CP ; si plusieurs villes, choix dans le select |
|
||||
| **Pays** | `<MalioSelect>` (préremplie « France ») | Oui | — |
|
||||
| **Catégories** | `<MalioSelectCheckbox>` (multi) | Oui | Catégories de type PRESTATAIRE (RG-3.09) |
|
||||
| **Contact** | `<MalioSelectCheckbox>` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact |
|
||||
|
||||
> **Différence avec M2** : l'adresse prestataire n'a **PAS** de Type d'adresse (Prospect/Départ/Rendu), **PAS** de Bennes, **PAS** de Prestation de triage. C'est une adresse « simple » (site + adresse postale + catégories + contacts).
|
||||
|
||||
**Actions** :
|
||||
- « + Nouvelle Adresse » : ajoute un bloc identique au premier.
|
||||
- « Supprimer » (icône) : modal de confirmation puis suppression.
|
||||
- « Valider » → PATCH `/api/providers/{id}/addresses`.
|
||||
|
||||
### Onglet « Comptabilité »
|
||||
|
||||
⚠ **Accessible aux rôles avec `technique.providers.accounting.view`** (Admin + Compta). Bureau et Commerciale ne voient pas l'onglet. **Compta peut éditer** cet onglet (`accounting.manage`). Compta ne peut pas créer un prestataire (pas de `manage` global).
|
||||
|
||||
**Champs comptables** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **SIREN** | `<MalioInputText>` (masque 9 chiffres) | Oui | 9 chiffres. **Pas d'unicité** (cf. [`spec-back.md § 2.6`](./spec-back.md)) |
|
||||
| **Numéro de compte** | `<MalioInputText>` | Oui | — |
|
||||
| **Mode de TVA** | `<MalioSelect>` | Oui | Liste depuis `/api/tva_modes` (référentiel partagé M1) |
|
||||
| **N° de TVA** | `<MalioInputText>` | Oui | — |
|
||||
| **Délai de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_delays` |
|
||||
| **Type de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_types` |
|
||||
| **Banque** | `<MalioSelect>` | Conditionnel | RG-3.07 — visible et obligatoire **si** Type de règlement = `VIREMENT`. Liste depuis `/api/banks` (SG / CIC / CA). |
|
||||
|
||||
**Bloc RIB** (0..n, présence obligatoire conditionnée par RG-3.08) :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Libellé** | `<MalioInputText>` | Oui (si LCR) | RG-3.08 |
|
||||
| **BIC** | `<MalioInputText>` | Oui (si LCR) | RG-3.08 |
|
||||
| **IBAN** | `<MalioInputText>` | Oui (si LCR) | RG-3.08 |
|
||||
|
||||
**Actions** :
|
||||
- « + RIB » : ajoute un bloc.
|
||||
- « Supprimer » (icône) : modal de confirmation.
|
||||
- « Valider » → PATCH `/api/providers/{id}` (groupe `provider:write:accounting`) + sous-ressource RIBs.
|
||||
|
||||
## Écran « Consultation prestataire »
|
||||
|
||||
Tous les champs en **lecture seule**. La page s'ouvre par défaut sur l'onglet **Contacts**. Layout identique à l'écran Ajouter mais sans bouton « Valider », sans `+` pour ajouter des blocs.
|
||||
|
||||
- **Flèche retour** (gauche) → revient au Répertoire.
|
||||
- **Bouton « Modifier »** (droite, visible si `technique.providers.manage`) → écran Modification.
|
||||
- **Bouton « Archiver »** (droite, visible **uniquement Admin** via `technique.providers.archive`) → modal de confirmation, puis PATCH `/api/providers/{id}` `{ "isArchived": true }`.
|
||||
|
||||
> Un prestataire archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé.
|
||||
|
||||
### Onglets affichés en consultation
|
||||
|
||||
`Contacts` · `Adresse` · `Rapports` · `Échanges` · `Comptabilité`. Navigation **libre** entre onglets (pas de séquence forcée). `Rapports` et `Échanges` = placeholders « À venir ». `Comptabilité` selon permission.
|
||||
|
||||
- **Onglet Contacts** : un bloc par contact, 5 champs en lecture seule (Nom / Prénom / Fonction / Téléphone / Email).
|
||||
- **Onglet Adresse** : un bloc par adresse, en lecture seule (Sélecteur de site / Adresse / Adresse complémentaire / Code postal / Ville / Pays / Catégorie / Contact).
|
||||
- **Onglet Comptabilité** : bloc principal (champs comptables) + un bloc par RIB. Le champ **Banque** n'apparaît que si Type de règlement = Virement (RG-3.07).
|
||||
|
||||
## Écran « Modification prestataire »
|
||||
|
||||
Comportement identique à l'écran Ajouter (mêmes formulaires, mêmes RG-3.03 → RG-3.08) sauf :
|
||||
- **Pas de formulaire principal** réaffiché (champs principaux édités via l'onglet correspondant / pré-remplis).
|
||||
- Les champs sont **pré-remplis** avec les valeurs actuelles du prestataire.
|
||||
- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel).
|
||||
- Les onglets pour lesquels l'utilisateur n'a **pas** la permission `manage` (ou `accounting.manage`) restent en **lecture seule** (pas de bouton Valider, pas d'icône suppression).
|
||||
- **Accès** : Admin, Bureau (Compta pour l'onglet Comptabilité uniquement).
|
||||
|
||||
## Composants UI à utiliser (`@malio/layer-ui`)
|
||||
|
||||
- **Datatable** : `<MalioDataTable>` (+ `usePaginatedList`)
|
||||
- **Input texte** : `<MalioInputText>`
|
||||
- **Select simple** : `<MalioSelect>` (Pays, Ville, référentiels comptables)
|
||||
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (Catégorie, Sites, Contacts rattachés)
|
||||
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
|
||||
- **Toasts** : standards via `useApi()`
|
||||
- **Validation par champ** : `useFormErrors` (mapping 422 inline — règle frontend obligatoire)
|
||||
|
||||
**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) :
|
||||
- Modal de confirmation : `<MalioModal>` ou wrapper partagé dans `frontend/shared/` (réutiliser celui du M1/M2).
|
||||
|
||||
## Composables & appels API
|
||||
|
||||
- `usePaginatedList<Provider>({ url: '/providers' })` — liste paginée (obligatoire). La liste consomme `categories[]` (libellé = `name`) et `sites[]` (libellé = `name`, pas de `code`) **embarqués** + `updatedAt` (cf. [`spec-back.md § 2.12 / § 4.0`](./spec-back.md)).
|
||||
- `useProvider(id)` — charge le détail via `GET /api/providers/{id}`, qui **embarque** `contacts`, `addresses` (avec `sites` / `categories` / `contacts` imbriqués) et, si permission, `ribs` + scalaires compta. Écrans Consultation et Modification peuplés depuis cette seule réponse (RETEX M1 §2 : embed borné, pas de N+1). **DoD avant intégration** : vérifier que le JSON réel contient ces blocs (cf. [`spec-back.md § 4.0.bis`](./spec-back.md)).
|
||||
- `useProviderForm()` — workflow par onglet (POST principal + PATCH partiels par groupe), miroir de `useSupplierForm()`.
|
||||
- `useAddressAutocomplete()` — **réutilisé du M1/M2** (BAN), pas de réécriture.
|
||||
- `usePermissions()` — masque l'onglet Comptabilité et le bouton Archiver.
|
||||
- Tous les appels passent par `useApi()` (jamais `$fetch` direct — règle ABSOLUE n°4).
|
||||
- Filter `formatPhoneFR()` — **réutilisé** pour l'affichage `XX XX XX XX XX`.
|
||||
|
||||
## Règles de formatage et normalisation
|
||||
|
||||
Le serveur normalise systématiquement (RG-3.11 — cf. [`spec-back.md`](./spec-back.md)) :
|
||||
|
||||
| Champ | Normalisation serveur | Affichage front |
|
||||
|---|---|---|
|
||||
| Nom prestataire (`companyName`) | UPPERCASE intégral | UPPERCASE |
|
||||
| Nom + Prénom contact | Capitalize | identique |
|
||||
| Téléphones (blocs `ProviderContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` (filter Vue) |
|
||||
| Email | lowercase intégral | identique |
|
||||
|
||||
> Le front **ne normalise pas** : il envoie la valeur saisie, le serveur normalise et renvoie la valeur normalisée que l'UI affiche.
|
||||
|
||||
## API adresse postale
|
||||
|
||||
Code postal + Ville + Adresse branchés sur **api-adresse.data.gouv.fr** (BAN) via le composable `useAddressAutocomplete()` **déjà créé au M1/M2** (réutilisé tel quel) :
|
||||
- À la saisie du CP (5 chiffres) : `GET https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville (RG-3.06 : si plusieurs villes, choix dans le select).
|
||||
- À la saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions.
|
||||
- Cas dégradé (timeout / offline) : Ville en `<MalioInputText>` libre + toast d'avertissement.
|
||||
|
||||
## Différences notables avec le M2 (fournisseurs)
|
||||
|
||||
| Zone | M2 fournisseurs | M3 prestataires |
|
||||
|---|---|---|
|
||||
| Onglet Information | 8 champs (Description … Volume) | **Absent** (aucun champ Information) |
|
||||
| Sélecteur de site sur formulaire principal | Non (sites uniquement via adresses) | **Oui** (RG-3.03 — relation directe `provider.sites`) |
|
||||
| Type d'adresse | Radio Prospect / Départ / Rendu (RG-2.09) | **Absent** |
|
||||
| Bennes / Prestation de triage (adresse) | Présents | **Absents** |
|
||||
| Onglet Transport | Placeholder | **Absent** |
|
||||
| Onglet Statistiques | Placeholder | **Absent** |
|
||||
| Onglets « À venir » | Transport / Stats / Rapports / Échanges | **Rapports / Échanges** uniquement |
|
||||
| Catégories | type `FOURNISSEUR` | **nouveau type `PRESTATAIRE`** |
|
||||
| Pôle / module | Commercial | **Technique** (nouvelle section sidebar + module back) |
|
||||
| Cloisonnement par site | aucun | **Visibilité par site, pilotée par l'utilisateur** (bypass via `sites.bypass_scope`) — § 2.13 |
|
||||
|
||||
## Points résolus côté back
|
||||
|
||||
| # | Zone d'ombre | Résolution (cf. `spec-back.md`) |
|
||||
|---|---|---|
|
||||
| 1 | Catégorie multi-select | M2M `provider_category`, `Category` de type **PRESTATAIRE** (RG-3.09) |
|
||||
| 2 | Site sur le formulaire principal | M2M `provider_site` (≥ 1 — RG-3.03), distinct de `provider_address_site` (RG-3.05) |
|
||||
| 3 | Onglet Comptabilité : qui édite ? | Admin + Compta (`accounting.manage`) ; Bureau/Commerciale ne le voient pas |
|
||||
| 4 | Workflow par onglet | Sauvegarde incrémentale (POST principal + PATCH partiels) — pas d'état « draft » |
|
||||
| 5 | Onglets « À venir » | Placeholder minimal « À venir » (Rapports / Échanges) |
|
||||
| 6 | Archive vs delete | Flag `is_archived` séparé de `deleted_at` ; archivage Admin seul ; soft delete = HP |
|
||||
| 7 | Unicité métier | Nom de prestataire uniquement (à valider — § 2.6). SIREN/email non uniques |
|
||||
| 8 | Référentiels comptables | Réutilisés M1/M2 (zéro duplication) ; relation ORM partagée |
|
||||
| 9 | API code postal | BAN via `useAddressAutocomplete()` du M1/M2 (RG-3.06) |
|
||||
| 10 | Format export | XLSX uniquement (CSV = HP) |
|
||||
| 11 | Cloisonnement par site (Usine « son site ») | Filtre back automatique par `currentSite` + bypass `sites.bypass_scope` (§ 2.13 / RG-3.17) |
|
||||
|
||||
---
|
||||
|
||||
## 📦 Tickets Lesstime
|
||||
|
||||
**TaskGroup Lesstime** : **#29 — M3 — Répertoire prestataires** (projet `ERP / Starseed`, projectId=6) — créé le 11/06/2026, 16 tickets `ERP-131` → `ERP-146`, statut « Prêt à dev », assignés à **Tristan**.
|
||||
|
||||
| # | Ticket | Réf | Tag |
|
||||
|---|---|---|---|
|
||||
| 1.1 | Créer module Technique + taxonomie PRESTATAIRE | ERP-131 | Backend |
|
||||
| 1.2 | Migrer le schéma BDD M3 (provider + sous-collections) | ERP-132 | Backend |
|
||||
| 1.3 | Créer entités + repositories Provider* | ERP-133 | Backend |
|
||||
| 1.4 | ProviderProvider + ProviderProcessor + cloisonnement site | ERP-134 | Backend |
|
||||
| 1.5 | Sous-ressources Contacts / Adresses / RIBs | ERP-135 | Backend |
|
||||
| 1.6 | Valider les RG métier server-side (RG-3.03→3.09) | ERP-136 | Backend |
|
||||
| 1.7 | Export XLSX des prestataires | ERP-137 | Backend |
|
||||
| 1.8 | RBAC technique.providers.* (3 sources) | ERP-138 | Backend |
|
||||
| 1.9 | PHPUnit RG-3.x + capture contrat JSON | ERP-139 | Backend |
|
||||
| 1.10 | Page Répertoire (/providers) | ERP-140 | Frontend |
|
||||
| 1.11 | Page Ajouter (/providers/new) + formulaire principal | ERP-141 | Frontend |
|
||||
| 1.12 | Onglet Contact | ERP-142 | Frontend |
|
||||
| 1.13 | Onglet Adresse (autocomplete BAN) | ERP-143 | Frontend |
|
||||
| 1.14 | Onglet Comptabilité + RIB | ERP-144 | Frontend |
|
||||
| 1.15 | Pages Consultation + Modification | ERP-145 | Frontend |
|
||||
| 1.16 | i18n + sidebar Technique + libellés audit | ERP-146 | Frontend |
|
||||
|
||||
> Détail back complet → voir [`spec-back.md § Tickets Lesstime`](./spec-back.md#-tickets-lesstime-à-découper).
|
||||
@@ -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/
|
||||
@@ -0,0 +1,80 @@
|
||||
# RETEX M1 (Clients) → à appliquer pour M2 (Fournisseurs)
|
||||
|
||||
> But : éviter de reproduire en M2 les erreurs de **contrat de sérialisation** qui ont bloqué M1.
|
||||
> ~80 % des frictions M1 venaient du contrat API (sérialisation / groupes / sous-ressources), **pas** du métier.
|
||||
> À lire AVANT de rédiger `spec-back.md` et `spec-front.md` du M2, et à garder ouvert pendant la rédaction.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR (les 3 erreurs à ne jamais refaire)
|
||||
|
||||
1. **Affirmer qu'un champ est « embarqué » sans vérifier les 3 maillons de sérialisation.** En M1 : `Category.code` annoncé dans `client:read`, détail annoncé embarquant contacts/adresses/ribs → **faux dans le code**. Résultat : colonnes liste vides, onglets détail impossibles à peupler.
|
||||
2. **Livrer des sous-ressources en POST-only** (pas de `GetCollection`, pas d'embed) → le front ne peut pas lister les enfants de l'agrégat.
|
||||
3. **Écrire la spec/les tickets sur une intention, pas sur le contrat réel.** Le docblock `Client` décrivait un embed jamais implémenté.
|
||||
|
||||
---
|
||||
|
||||
## 1. Contrat de sérialisation : les 3 maillons obligatoires
|
||||
|
||||
Pour **chaque champ affiché** (liste OU détail), la spec back doit prouver les trois maillons. Si un seul manque → le champ sort en quasi-IRI (`@id`/`@type` seulement) ou pas du tout.
|
||||
|
||||
| Maillon | Question | Exemple M1 raté |
|
||||
|---|---|---|
|
||||
| (a) Groupe sur la **propriété** | `#[Groups([...])]` contient-il un read-group ? | `Supplier::$addresses` sans groupe → jamais sérialisé |
|
||||
| (b) Groupe dans le **`normalizationContext` de l'opération** | l'opération (`GetCollection`/`Get`) liste-t-elle ce groupe ? | `GetCollection` en `['client:read','default:read']` |
|
||||
| (c) Read-group de l'**entité imbriquée** dans le contexte parent | pour embarquer les champs d'une relation (catégorie, site…), le contexte parent inclut-il `category:read` / `site:read` ? | `Category.code` ∈ `category:read`, absent du contexte client → pas de `code` |
|
||||
|
||||
**Règle de rédaction** : dans `spec-back.md`, faire un tableau « champ → groupe propriété → groupe(s) à ajouter au contexte de chaque opération » pour la liste ET le détail. Inclure explicitement les **relations imbriquées** (ex. catégories d'une adresse, sites d'une adresse).
|
||||
|
||||
## 2. Collections enfant d'un agrégat : décider embed vs GetCollection, et câbler en ENTIER
|
||||
|
||||
Décision à acter dès la spec back pour chaque sous-collection (contacts, adresses, RIB, lignes…) :
|
||||
|
||||
- **Embed dans le détail (recommandé pour un agrégat DDD)** : poser `#[Groups(['<root>:item:read'])]` sur la propriété + ajouter au `normalizationContext` du `Get` racine les read-groups des entités enfant **et** de leurs relations imbriquées. 1 requête, cohérent avec un composable `useX(id)`. Réservé aux ensembles **bornés** (ne viole pas la règle n°13 : elle vise les collections exposées, pas un embed borné d'item).
|
||||
- **GetCollection sous-ressource** : `/<root>/{id}/children` paginé. À réserver aux collections potentiellement volumineuses. Si choisi, **créer l'opération** (pas seulement POST).
|
||||
|
||||
❌ Anti-pattern M1 : sous-ressources avec `POST` + `Get` unitaire seulement → **aucun moyen de lister** (ids non découvrables). Interdit.
|
||||
|
||||
## 3. Vérifier le contrat sur l'API RÉELLE avant d'écrire les tickets front
|
||||
|
||||
Le blocage M1 (codes/sites/sous-collections) aurait été vu en 5 min. À mettre dans la **definition of done de la spec back** :
|
||||
|
||||
> Créer un enregistrement de test, appeler `GET /api/<resource>` (liste) ET `GET /api/<resource>/{id}` (détail), **coller la réponse JSON réelle** dans la spec. Toute donnée affichée par le front doit apparaître dans ce JSON collé.
|
||||
|
||||
## 4. La spec décrit le RÉEL, pas l'intention
|
||||
|
||||
- Bannir les « devrait être embarqué », « est exposé » non vérifiés. Décrire ce qui existe (ou ce qui sera livré dans le ticket, en le marquant clairement « à livrer »).
|
||||
- Si un docblock/commentaire existant contredit le code, le **corriger**, pas le recopier.
|
||||
|
||||
## 5. Réutiliser les acquis M1 (ne pas réinventer)
|
||||
|
||||
- **Taxonomie ERP-78** : si M2 catégorise les fournisseurs, repartir du modèle **type unique + `code` stable** (slug MAJUSCULE auto-généré, NOT NULL, figé, **lecture seule** `category:read`), filtrage métier via `?categoryCode=`. Réutiliser le contrat partagé `CategoryInterface` (pas d'import inter-module).
|
||||
- **Front** : `usePaginatedList` (listes), composants `Malio*`, `useApi()`, `formatPhoneFR`, blocs réutilisables (Contact/Adresse), pattern de blocs dynamiques + modal de confirmation.
|
||||
- **Archive** : flag `is_archived` **distinct** de `deleted_at` (soft delete). Restauration → gérer le 409 homonyme.
|
||||
- **Normalisation = serveur** (UPPERCASE nom société, Capitalize noms, lowercase email, téléphone en chiffres). Le front envoie la saisie, réaffiche la valeur normalisée renvoyée. À documenter dans la spec.
|
||||
- **Gating fin + mode strict PATCH** : PATCH par groupe de sérialisation ; tout champ hors-permission dans le payload = **403 sur l'intégralité** (pas de filtrage silencieux). Spécifier la matrice rôle × onglet.
|
||||
|
||||
## 6. Règles ABSOLUES transverses à rappeler dans la spec M2
|
||||
|
||||
- **Pagination obligatoire** (règle n°13) sur toute `GetCollection` ; échappatoire `?pagination=false` réservée aux selects de référentiels bornés.
|
||||
- **`COMMENT ON COLUMN`** (règle n°12) sur chaque colonne créée/modifiée (sinon `make test` casse). Helper standard pour les colonnes Timestampable/Blamable.
|
||||
- **Timestampable + Blamable** sur toute nouvelle entité métier (4 colonnes + trait) ; garde-fou archi.
|
||||
- **`#[Auditable]`** sur les entités métier ; **`#[AuditIgnore]`** sur les champs sensibles (équivalents BIC/IBAN/secret).
|
||||
- **`declare(strict_types=1);`** partout ; commentaires FR, code EN.
|
||||
- **Routes front à plat** (pas de préfixe module), état tableau **jamais** dans l'URL.
|
||||
- **3 miroirs RBAC** à toucher ensemble : `config/sidebar.php`, `frontend/tests/e2e/_fixtures/personas.ts`, `SeedE2ECommand.php`.
|
||||
- **Communication inter-module** uniquement via `Shared/Domain/Contract/` ou domain events — jamais d'import direct.
|
||||
|
||||
## 7. Fixtures & seed dès le départ
|
||||
|
||||
M1 a subi un aller-retour (ERP-68) faute de fixtures alignées. Pour M2 : prévoir dès la spec un seed de fournisseurs démo **couvrant tous les cas des règles métier** (relations, catégories codées, archivés, cas comptables) + comptes de rôles démo, pour vérifier le gating et le golden path sans bricolage.
|
||||
|
||||
## 8. Mini-checklist de relecture de la spec M2 (avant de la déclarer prête)
|
||||
|
||||
- [ ] Chaque champ affiché (liste + détail) a ses 3 maillons de sérialisation documentés (propriété, contexte opération, relations imbriquées).
|
||||
- [ ] Chaque sous-collection a une décision **embed vs GetCollection** explicite et **complètement câblée** (pas de POST-only).
|
||||
- [ ] Réponses JSON réelles (liste + détail) collées dans la spec back.
|
||||
- [ ] Matrice RBAC rôle × écran × onglet + mode strict PATCH spécifiés.
|
||||
- [ ] Pagination, COMMENT ON COLUMN, Timestampable/Blamable, Audit, routes à plat : rappelés.
|
||||
- [ ] Réutilisations M1 identifiées (taxonomie code, usePaginatedList, blocs, archive, normalisation).
|
||||
- [ ] Seed/fixtures démo planifiés.
|
||||
+142
-115
@@ -30,6 +30,14 @@
|
||||
"clients": "Répertoire clients",
|
||||
"suppliers": "Répertoire fournisseurs"
|
||||
},
|
||||
"technique": {
|
||||
"section": "Technique",
|
||||
"providers": "Répertoire prestataires"
|
||||
},
|
||||
"transport": {
|
||||
"section": "Transport",
|
||||
"carriers": "Répertoire transporteurs"
|
||||
},
|
||||
"core": {
|
||||
"roles": "Gestion des rôles",
|
||||
"users": "Utilisateurs",
|
||||
@@ -40,123 +48,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 +97,7 @@
|
||||
"accounting": "Comptabilité",
|
||||
"statistics": "Statistiques",
|
||||
"reports": "Rapports",
|
||||
"exchanges": "Échanges",
|
||||
"carte": "Carte"
|
||||
"exchanges": "Échanges"
|
||||
},
|
||||
"action": {
|
||||
"edit": "Modifier",
|
||||
@@ -331,8 +230,7 @@
|
||||
"accounting": "Comptabilité",
|
||||
"statistics": "Statistiques",
|
||||
"reports": "Rapports",
|
||||
"exchanges": "Échanges",
|
||||
"carte": "Carte"
|
||||
"exchanges": "Échanges"
|
||||
},
|
||||
"action": {
|
||||
"edit": "Modifier",
|
||||
@@ -472,6 +370,130 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"technique": {
|
||||
"providers": {
|
||||
"title": "Répertoire prestataires",
|
||||
"add": "Ajouter",
|
||||
"export": "Exporter",
|
||||
"empty": "Aucun prestataire pour l'instant.",
|
||||
"column": {
|
||||
"companyName": "Nom",
|
||||
"categories": "Catégories",
|
||||
"sites": "Site",
|
||||
"lastActivity": "Dernière activité"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
"search": "Recherche",
|
||||
"categories": "Catégories",
|
||||
"sites": "Sites",
|
||||
"status": "Statut",
|
||||
"includeArchived": "Inclure les archivés",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser"
|
||||
},
|
||||
"tab": {
|
||||
"contact": "Contact",
|
||||
"contacts": "Contacts",
|
||||
"address": "Adresse",
|
||||
"reports": "Rapports",
|
||||
"exchanges": "Échanges",
|
||||
"accounting": "Comptabilité"
|
||||
},
|
||||
"action": {
|
||||
"edit": "Modifier",
|
||||
"archive": "Archiver",
|
||||
"restore": "Restaurer"
|
||||
},
|
||||
"consultation": {
|
||||
"title": "Fiche prestataire",
|
||||
"back": "Retour au répertoire",
|
||||
"loading": "Chargement…",
|
||||
"notFound": "Prestataire introuvable.",
|
||||
"confirmArchive": "Archiver ce prestataire ? Il n'apparaîtra plus dans le répertoire actif.",
|
||||
"confirmRestore": "Restaurer ce prestataire ? Il réapparaîtra dans le répertoire actif."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifier le prestataire",
|
||||
"back": "Retour à la fiche",
|
||||
"loading": "Chargement…",
|
||||
"notFound": "Prestataire introuvable.",
|
||||
"save": "Enregistrer"
|
||||
},
|
||||
"form": {
|
||||
"title": "Ajouter un prestataire",
|
||||
"back": "Précédent",
|
||||
"submit": "Valider",
|
||||
"duplicateCompany": "Un prestataire portant ce nom de société existe déjà.",
|
||||
"main": {
|
||||
"companyName": "Nom du prestataire (Entreprise)",
|
||||
"categories": "Catégorie",
|
||||
"sites": "Site"
|
||||
},
|
||||
"errors": {
|
||||
"nameRequired": "Le nom du prestataire est obligatoire.",
|
||||
"siteRequired": "Sélectionnez au moins un site.",
|
||||
"categoryRequired": "Sélectionnez au moins une catégorie."
|
||||
},
|
||||
"contact": {
|
||||
"lastName": "Nom",
|
||||
"firstName": "Prénom",
|
||||
"jobTitle": "Fonction",
|
||||
"email": "Email",
|
||||
"phonePrimary": "Téléphone",
|
||||
"phoneSecondary": "Téléphone (2)",
|
||||
"addPhone": "Ajouter un numéro",
|
||||
"remove": "Supprimer le contact",
|
||||
"add": "Nouveau contact"
|
||||
},
|
||||
"address": {
|
||||
"sites": "Sites",
|
||||
"categories": "Catégorie",
|
||||
"contacts": "Contact(s) rattaché(s)",
|
||||
"country": "Pays",
|
||||
"postalCode": "Code postal",
|
||||
"city": "Ville",
|
||||
"street": "Adresse",
|
||||
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
|
||||
"streetComplement": "Adresse complémentaire",
|
||||
"remove": "Supprimer l'adresse",
|
||||
"add": "Nouvelle adresse",
|
||||
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||
},
|
||||
"accounting": {
|
||||
"siren": "SIREN",
|
||||
"accountNumber": "Numéro de compte",
|
||||
"tvaMode": "Mode de TVA",
|
||||
"nTva": "N° de TVA",
|
||||
"paymentDelay": "Délai de règlement",
|
||||
"paymentType": "Type de règlement",
|
||||
"bank": "Banque",
|
||||
"ribLabel": "Libellé",
|
||||
"ribBic": "BIC",
|
||||
"ribIban": "IBAN",
|
||||
"addRib": "Ajouter un RIB",
|
||||
"removeRib": "Supprimer le RIB"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "Confirmer la suppression",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Supprimer",
|
||||
"contact": "Supprimer ce contact ?",
|
||||
"address": "Supprimer cette adresse ?",
|
||||
"rib": "Supprimer ce RIB ?"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"error": "Une erreur est survenue. Réessayez.",
|
||||
"exportError": "L'export du répertoire prestataires a échoué. Réessayez.",
|
||||
"createSuccess": "Prestataire créé avec succès",
|
||||
"updateSuccess": "Prestataire mis à jour avec succès",
|
||||
"addComplete": "Prestataire ajouté",
|
||||
"archiveSuccess": "Prestataire archivé avec succès",
|
||||
"restoreSuccess": "Prestataire restauré avec succès"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"login": "Connexion",
|
||||
"logout": "Deconnexion",
|
||||
@@ -496,7 +518,10 @@
|
||||
},
|
||||
"title": "Erreur",
|
||||
"generic": "Une erreur est survenue.",
|
||||
"unknown": "Erreur inconnue."
|
||||
"unknown": "Erreur inconnue.",
|
||||
"validation": {
|
||||
"invalidDate": "Date invalide"
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"selector": {
|
||||
@@ -524,8 +549,10 @@
|
||||
"commercial_supplieraddress": "Adresse fournisseur",
|
||||
"commercial_suppliercontact": "Contact fournisseur",
|
||||
"commercial_supplierrib": "RIB fournisseur",
|
||||
"fieldsales_tour": "Tournée",
|
||||
"fieldsales_tourstop": "Étape de tournée"
|
||||
"technique_provider": "Prestataire",
|
||||
"technique_provideraddress": "Adresse prestataire",
|
||||
"technique_providercontact": "Contact prestataire",
|
||||
"technique_providerrib": "RIB prestataire"
|
||||
},
|
||||
"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: '© <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: '© <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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
// (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"
|
||||
@@ -156,12 +157,16 @@
|
||||
<!-- Onglet Contact -->
|
||||
<template #contact>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||
bloc enregistre. -->
|
||||
<ClientContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="contact.id ?? `new-${index}`"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||
:removable="contacts.length > 1"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="businessReadonly"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@@ -198,7 +203,7 @@
|
||||
:site-options="siteOptions"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="addresses.length > 1"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="businessReadonly"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@@ -303,7 +308,7 @@
|
||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -401,7 +406,7 @@ import {
|
||||
mapAddressToDraft,
|
||||
mapRibToDraft,
|
||||
type ClientDetail,
|
||||
} from '~/modules/commercial/utils/clientConsultation'
|
||||
} from '~/modules/commercial/utils/forms/clientConsultation'
|
||||
import {
|
||||
buildAccountingPayload,
|
||||
buildAddressPayload,
|
||||
@@ -417,7 +422,7 @@ import {
|
||||
type ClientEditAbilities,
|
||||
type InformationFormDraft,
|
||||
type MainFormDraft,
|
||||
} from '~/modules/commercial/utils/clientEdit'
|
||||
} from '~/modules/commercial/utils/forms/clientEdit'
|
||||
import {
|
||||
buildClientFormTabKeys,
|
||||
isAddressValid,
|
||||
@@ -429,7 +434,7 @@ import {
|
||||
isRibComplete,
|
||||
isRibRequiredForPaymentType,
|
||||
showsRelationAndTriageFields,
|
||||
} from '~/modules/commercial/utils/clientFormRules'
|
||||
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||
import {
|
||||
emptyAddress,
|
||||
emptyContact,
|
||||
@@ -439,6 +444,7 @@ import {
|
||||
type RibFormDraft,
|
||||
} from '~/modules/commercial/types/clientForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||
|
||||
// Masques de saisie (la normalisation finale reste serveur).
|
||||
@@ -489,10 +495,6 @@ const contacts = ref<ContactFormDraft[]>([])
|
||||
const addresses = ref<AddressFormDraft[]>([])
|
||||
const ribs = ref<RibFormDraft[]>([])
|
||||
|
||||
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
|
||||
const removedContactIds = ref<number[]>([])
|
||||
const removedAddressIds = ref<number[]>([])
|
||||
const removedRibIds = ref<number[]>([])
|
||||
|
||||
const mainSubmitting = ref(false)
|
||||
const tabSubmitting = ref(false)
|
||||
@@ -753,32 +755,31 @@ function addContact(): void {
|
||||
if (canAddContact.value) contacts.value.push(emptyContact())
|
||||
}
|
||||
|
||||
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
|
||||
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
|
||||
// local. Echec serveur : bloc conserve + erreur remontee.
|
||||
function askRemoveContact(index: number): void {
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
|
||||
const removed = contacts.value[index]
|
||||
if (removed?.id != null) removedContactIds.value.push(removed.id)
|
||||
contacts.value.splice(index, 1)
|
||||
contactErrors.value.splice(index, 1)
|
||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
||||
if (contacts.value.length === 0) contacts.value.push(emptyContact())
|
||||
})
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => removeCollectionRow({
|
||||
rows: contacts.value,
|
||||
errors: contactErrors.value,
|
||||
index,
|
||||
endpoint: '/client_contacts',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyContact,
|
||||
onError: showError,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Contact : DELETE des contacts retires (existants), puis
|
||||
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
|
||||
* collection contacts (endpoints client_contact dedies).
|
||||
* Valide l'onglet Contact : POST/PATCH des blocs restants sur la sous-ressource.
|
||||
* Strictement scope a la collection contacts (endpoints client_contact dedies). La
|
||||
* suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
|
||||
*/
|
||||
async function submitContacts(): Promise<void> {
|
||||
if (businessReadonly.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
contactErrors.value = []
|
||||
try {
|
||||
for (const id of removedContactIds.value) {
|
||||
await api.delete(`/client_contacts/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedContactIds.value = []
|
||||
|
||||
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
|
||||
// amorces neuves vides (ex. tous les contacts existants supprimes), on ne
|
||||
// les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom
|
||||
@@ -835,14 +836,15 @@ function addAddress(): void {
|
||||
}
|
||||
|
||||
function askRemoveAddress(index: number): void {
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
|
||||
const removed = addresses.value[index]
|
||||
if (removed?.id != null) removedAddressIds.value.push(removed.id)
|
||||
addresses.value.splice(index, 1)
|
||||
addressErrors.value.splice(index, 1)
|
||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
||||
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
|
||||
})
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => removeCollectionRow({
|
||||
rows: addresses.value,
|
||||
errors: addressErrors.value,
|
||||
index,
|
||||
endpoint: '/client_addresses',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyAddress,
|
||||
onError: showError,
|
||||
}))
|
||||
}
|
||||
|
||||
function onAddressDegraded(): void {
|
||||
@@ -854,17 +856,12 @@ function onAddressDegraded(): void {
|
||||
})
|
||||
}
|
||||
|
||||
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */
|
||||
/** Valide l'onglet Adresse : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
|
||||
async function submitAddresses(): Promise<void> {
|
||||
if (businessReadonly.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
addressErrors.value = []
|
||||
try {
|
||||
for (const id of removedAddressIds.value) {
|
||||
await api.delete(`/client_addresses/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedAddressIds.value = []
|
||||
|
||||
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
|
||||
const hasError = await submitRows(
|
||||
addresses.value,
|
||||
@@ -936,29 +933,32 @@ function addRib(): void {
|
||||
if (canAddRib.value) ribs.value.push(emptyRib())
|
||||
}
|
||||
|
||||
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
|
||||
// d'une LCR (RG-1.13) -> 409 remonte via showError (message back), bloc conserve.
|
||||
function askRemoveRib(index: number): void {
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
|
||||
const removed = ribs.value[index]
|
||||
if (removed?.id != null) removedRibIds.value.push(removed.id)
|
||||
ribs.value.splice(index, 1)
|
||||
ribErrors.value.splice(index, 1)
|
||||
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
|
||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||
})
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => removeCollectionRow({
|
||||
rows: ribs.value,
|
||||
errors: ribErrors.value,
|
||||
index,
|
||||
endpoint: '/client_ribs',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyRib,
|
||||
onError: showError,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
||||
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
|
||||
* back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le back
|
||||
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
||||
* back). Les RIB crees d'abord : le back valide RG-1.13 (LCR => au moins un RIB
|
||||
* persiste) sur le PATCH scalaires.
|
||||
*
|
||||
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
|
||||
* plus de DELETE differe ici.
|
||||
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
||||
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
||||
* (corbeille d'un bloc, toujours sous LCR), plus l'auto-suppression au changement
|
||||
* de type de reglement. Aucun champ main/information dans le payload (mode strict
|
||||
* RG-1.28 : sinon 403 sur tout le payload).
|
||||
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-1.28 :
|
||||
* sinon 403 sur tout le payload).
|
||||
*/
|
||||
async function submitAccounting(): Promise<void> {
|
||||
if (accountingReadonly.value || tabSubmitting.value) return
|
||||
@@ -1012,14 +1012,6 @@ async function submitAccounting(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
|
||||
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
|
||||
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
|
||||
for (const id of removedRibIds.value) {
|
||||
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedRibIds.value = []
|
||||
|
||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||
}
|
||||
catch (e) {
|
||||
|
||||
@@ -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"
|
||||
@@ -155,12 +156,16 @@
|
||||
<!-- Onglet Contact -->
|
||||
<template #contact>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||
bloc enregistre. -->
|
||||
<ClientContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||
:removable="index > 0"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="isValidated('contact')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@@ -197,7 +202,7 @@
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="index > 0"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="isValidated('address')"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@@ -302,7 +307,7 @@
|
||||
>
|
||||
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -401,12 +406,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,
|
||||
@@ -416,6 +421,7 @@ import {
|
||||
type RibFormDraft,
|
||||
} from '~/modules/commercial/types/clientForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||
|
||||
// Masques de saisie (la normalisation finale reste serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
@@ -651,6 +657,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 +674,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"
|
||||
@@ -125,12 +126,16 @@
|
||||
<!-- Onglet Contacts -->
|
||||
<template #contacts>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||
bloc enregistre. -->
|
||||
<SupplierContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="contact.id ?? `new-${index}`"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||
:removable="contacts.length > 1"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="businessReadonly"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@@ -167,7 +172,7 @@
|
||||
:site-options="siteOptions"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="addresses.length > 1"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="businessReadonly"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@@ -272,7 +277,7 @@
|
||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -370,7 +375,7 @@ import {
|
||||
mapAddressToDraft,
|
||||
mapRibToDraft,
|
||||
type SupplierDetail,
|
||||
} from '~/modules/commercial/utils/supplierConsultation'
|
||||
} from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||
import {
|
||||
buildAccountingPayload,
|
||||
buildAddressPayload,
|
||||
@@ -386,7 +391,7 @@ import {
|
||||
type InformationFormDraft,
|
||||
type MainFormDraft,
|
||||
type SupplierEditAbilities,
|
||||
} from '~/modules/commercial/utils/supplierEdit'
|
||||
} from '~/modules/commercial/utils/forms/supplierEdit'
|
||||
import {
|
||||
buildSupplierFormTabKeys,
|
||||
isAddressValid,
|
||||
@@ -396,7 +401,7 @@ import {
|
||||
isRibBlank,
|
||||
isRibComplete,
|
||||
isRibRequiredForPaymentType,
|
||||
} from '~/modules/commercial/utils/supplierFormRules'
|
||||
} from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||
import {
|
||||
emptyAddress,
|
||||
emptyContact,
|
||||
@@ -406,6 +411,7 @@ import {
|
||||
type SupplierRibFormDraft,
|
||||
} from '~/modules/commercial/types/supplierForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||
|
||||
// Masques de saisie (la normalisation finale reste serveur).
|
||||
@@ -455,10 +461,6 @@ const contacts = ref<SupplierContactFormDraft[]>([])
|
||||
const addresses = ref<SupplierAddressFormDraft[]>([])
|
||||
const ribs = ref<SupplierRibFormDraft[]>([])
|
||||
|
||||
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
|
||||
const removedContactIds = ref<number[]>([])
|
||||
const removedAddressIds = ref<number[]>([])
|
||||
const removedRibIds = ref<number[]>([])
|
||||
|
||||
const mainSubmitting = ref(false)
|
||||
const tabSubmitting = ref(false)
|
||||
@@ -652,32 +654,31 @@ function addContact(): void {
|
||||
if (canAddContact.value) contacts.value.push(emptyContact())
|
||||
}
|
||||
|
||||
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
|
||||
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
|
||||
// local. Echec serveur : bloc conserve + erreur remontee.
|
||||
function askRemoveContact(index: number): void {
|
||||
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => {
|
||||
const removed = contacts.value[index]
|
||||
if (removed?.id != null) removedContactIds.value.push(removed.id)
|
||||
contacts.value.splice(index, 1)
|
||||
contactErrors.value.splice(index, 1)
|
||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
||||
if (contacts.value.length === 0) contacts.value.push(emptyContact())
|
||||
})
|
||||
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => removeCollectionRow({
|
||||
rows: contacts.value,
|
||||
errors: contactErrors.value,
|
||||
index,
|
||||
endpoint: '/supplier_contacts',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyContact,
|
||||
onError: showError,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Contacts : DELETE des contacts retires (existants), puis
|
||||
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
|
||||
* collection contacts (endpoints supplier_contact dedies).
|
||||
* Valide l'onglet Contacts : POST/PATCH des blocs restants sur la sous-ressource.
|
||||
* Strictement scope a la collection contacts (endpoints supplier_contact dedies).
|
||||
* La suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
|
||||
*/
|
||||
async function submitContacts(): Promise<void> {
|
||||
if (businessReadonly.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
contactErrors.value = []
|
||||
try {
|
||||
for (const id of removedContactIds.value) {
|
||||
await api.delete(`/supplier_contacts/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedContactIds.value = []
|
||||
|
||||
// RG-2.13 : au moins un contact requis. Si l'onglet ne contient QUE des
|
||||
// amorces neuves vides, on les soumet -> 422 RG-2.04 inline (nom OU prenom).
|
||||
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
|
||||
@@ -725,14 +726,15 @@ function addAddress(): void {
|
||||
}
|
||||
|
||||
function askRemoveAddress(index: number): void {
|
||||
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => {
|
||||
const removed = addresses.value[index]
|
||||
if (removed?.id != null) removedAddressIds.value.push(removed.id)
|
||||
addresses.value.splice(index, 1)
|
||||
addressErrors.value.splice(index, 1)
|
||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
||||
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
|
||||
})
|
||||
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => removeCollectionRow({
|
||||
rows: addresses.value,
|
||||
errors: addressErrors.value,
|
||||
index,
|
||||
endpoint: '/supplier_addresses',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyAddress,
|
||||
onError: showError,
|
||||
}))
|
||||
}
|
||||
|
||||
function onAddressDegraded(): void {
|
||||
@@ -744,17 +746,12 @@ function onAddressDegraded(): void {
|
||||
})
|
||||
}
|
||||
|
||||
/** Valide l'onglet Adresses : DELETE des adresses retirees puis POST/PATCH. */
|
||||
/** Valide l'onglet Adresses : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
|
||||
async function submitAddresses(): Promise<void> {
|
||||
if (businessReadonly.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
addressErrors.value = []
|
||||
try {
|
||||
for (const id of removedAddressIds.value) {
|
||||
await api.delete(`/supplier_addresses/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedAddressIds.value = []
|
||||
|
||||
const hasError = await submitRows(
|
||||
addresses.value,
|
||||
addressErrors,
|
||||
@@ -825,15 +822,18 @@ function addRib(): void {
|
||||
if (canAddRib.value) ribs.value.push(emptyRib())
|
||||
}
|
||||
|
||||
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
|
||||
// d'une LCR (RG-2.08) -> 409 remonte via showError (message back), bloc conserve.
|
||||
function askRemoveRib(index: number): void {
|
||||
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => {
|
||||
const removed = ribs.value[index]
|
||||
if (removed?.id != null) removedRibIds.value.push(removed.id)
|
||||
ribs.value.splice(index, 1)
|
||||
ribErrors.value.splice(index, 1)
|
||||
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
|
||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||
})
|
||||
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => removeCollectionRow({
|
||||
rows: ribs.value,
|
||||
errors: ribErrors.value,
|
||||
index,
|
||||
endpoint: '/supplier_ribs',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyRib,
|
||||
onError: showError,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -842,11 +842,12 @@ function askRemoveRib(index: number): void {
|
||||
* cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
|
||||
* back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
||||
*
|
||||
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
|
||||
* plus de DELETE differe ici.
|
||||
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
||||
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
||||
* (corbeille d'un bloc, toujours sous LCR). Aucun champ main/information dans le
|
||||
* payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
|
||||
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-2.16 :
|
||||
* sinon 403 sur tout le payload).
|
||||
*/
|
||||
async function submitAccounting(): Promise<void> {
|
||||
if (accountingReadonly.value || tabSubmitting.value) return
|
||||
@@ -896,14 +897,6 @@ async function submitAccounting(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
|
||||
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
|
||||
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
|
||||
for (const id of removedRibIds.value) {
|
||||
await api.delete(`/supplier_ribs/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedRibIds.value = []
|
||||
|
||||
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
|
||||
}
|
||||
catch (e) {
|
||||
|
||||
@@ -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"
|
||||
@@ -120,12 +121,16 @@
|
||||
<!-- Onglet Contacts -->
|
||||
<template #contacts>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||
bloc enregistre. -->
|
||||
<SupplierContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||
:removable="index > 0"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="isValidated('contacts')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@@ -162,7 +167,7 @@
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="index > 0"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="isValidated('addresses')"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@@ -266,7 +271,7 @@
|
||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -361,7 +366,7 @@ import {
|
||||
isRibComplete,
|
||||
isRibRequiredForPaymentType,
|
||||
lastFillableTabKey,
|
||||
} from '~/modules/commercial/utils/supplierFormRules'
|
||||
} from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||
import {
|
||||
buildAccountingPayload,
|
||||
buildAddressPayload,
|
||||
@@ -369,7 +374,7 @@ import {
|
||||
buildInformationPayload,
|
||||
buildMainPayload,
|
||||
buildRibPayload,
|
||||
} from '~/modules/commercial/utils/supplierEdit'
|
||||
} from '~/modules/commercial/utils/forms/supplierEdit'
|
||||
import {
|
||||
emptyAddress,
|
||||
emptyContact,
|
||||
@@ -379,6 +384,7 @@ import {
|
||||
type SupplierRibFormDraft,
|
||||
} from '~/modules/commercial/types/supplierForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||
|
||||
// Masques de saisie (la normalisation finale reste serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
@@ -549,6 +555,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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
@@ -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
-2
@@ -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)', () => {
|
||||
-7
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
+14
-8
@@ -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)
|
||||
}
|
||||
|
||||
-7
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
+14
-8
@@ -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: '© <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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
// 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}`
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- Suppression : modal de confirmation cote parent. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.siteIris"
|
||||
:options="siteOptions"
|
||||
:label="t('technique.providers.form.address.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.sites"
|
||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Categories de type PRESTATAIRE (>= 1 obligatoire, RG-3.09). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.categoryIris"
|
||||
:options="categoryOptions"
|
||||
:label="t('technique.providers.form.address.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.categories"
|
||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('technique.providers.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
:model-value="model.country"
|
||||
:options="countryOptions"
|
||||
:label="t('technique.providers.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.postalCode"
|
||||
:label="t('technique.providers.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
|
||||
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
|
||||
<MalioSelect
|
||||
v-if="!degraded"
|
||||
:model-value="model.city"
|
||||
:options="cityOptions"
|
||||
:label="t('technique.providers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.city"
|
||||
:label="t('technique.providers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
|
||||
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
|
||||
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
||||
<div class="col-span-2">
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('technique.providers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('technique.providers.form.address.streetNotFound')"
|
||||
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
||||
@search="onAddressSearch"
|
||||
@select="onAddressSelect"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.street"
|
||||
:label="t('technique.providers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('technique.providers.form.address.streetComplement')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||
import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
|
||||
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
|
||||
|
||||
// Masque code postal FR : 5 chiffres.
|
||||
const POSTAL_CODE_MASK = '#####'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Brouillon de l'adresse (v-model). */
|
||||
modelValue: ProviderAddressFormDraft
|
||||
/** Categories autorisees sur une adresse (type PRESTATAIRE). */
|
||||
categoryOptions: RefOption[]
|
||||
/** Sites Starseed disponibles. */
|
||||
siteOptions: RefOption[]
|
||||
/** Contacts deja saisis, rattachables a l'adresse. */
|
||||
contactOptions: RefOption[]
|
||||
/** Pays disponibles (France par defaut). */
|
||||
countryOptions: RefOption[]
|
||||
removable?: boolean
|
||||
readonly?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ProviderAddressFormDraft]
|
||||
'remove': []
|
||||
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
|
||||
'degraded': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const autocomplete = useAddressAutocomplete()
|
||||
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
// Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable).
|
||||
const degraded = ref(false)
|
||||
let unavailableNotified = false
|
||||
const banCityOptions = ref<RefOption[]>([])
|
||||
const banAddressOptions = ref<RefOption[]>([])
|
||||
|
||||
// Options ville effectives : on garantit que la ville courante figure toujours
|
||||
// dans la liste, sinon MalioSelect afficherait un champ vide en lecture seule.
|
||||
const cityOptions = computed<RefOption[]>(() => {
|
||||
const current = props.modelValue.city
|
||||
if (current && !banCityOptions.value.some(o => o.value === current)) {
|
||||
return [{ value: current, label: current }, ...banCityOptions.value]
|
||||
}
|
||||
return banCityOptions.value
|
||||
})
|
||||
|
||||
// Meme garantie pour le champ Adresse : la rue courante doit toujours figurer
|
||||
// dans les options, sinon MalioInputAutocomplete laisse le champ vide.
|
||||
const addressOptions = computed<RefOption[]>(() => {
|
||||
const current = props.modelValue.street
|
||||
if (current && !banAddressOptions.value.some(o => o.value === current)) {
|
||||
return [{ value: current, label: current }, ...banAddressOptions.value]
|
||||
}
|
||||
return banAddressOptions.value
|
||||
})
|
||||
const addressLoading = ref(false)
|
||||
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
||||
let lastAddressSuggestions: AddressSuggestion[] = []
|
||||
|
||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||
function update<K extends keyof ProviderAddressFormDraft>(field: K, value: ProviderAddressFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
||||
function notifyUnavailable(): void {
|
||||
if (!unavailableNotified) {
|
||||
unavailableNotified = true
|
||||
emit('degraded')
|
||||
}
|
||||
}
|
||||
|
||||
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville (RG-3.06). */
|
||||
async function onPostalCodeChange(value: string): Promise<void> {
|
||||
update('postalCode', value)
|
||||
|
||||
const digits = (value ?? '').replace(/\D/g, '')
|
||||
if (digits.length < 5) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const suggestions = await autocomplete.searchCity(digits)
|
||||
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
|
||||
degraded.value = false
|
||||
}
|
||||
catch {
|
||||
degraded.value = true
|
||||
notifyUnavailable()
|
||||
}
|
||||
}
|
||||
|
||||
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
|
||||
async function onAddressSearch(query: string): Promise<void> {
|
||||
// La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400).
|
||||
if (query.trim().length < 3) {
|
||||
banAddressOptions.value = []
|
||||
return
|
||||
}
|
||||
addressLoading.value = true
|
||||
try {
|
||||
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
|
||||
const suggestions = await autocomplete.searchAddress(query, postalCode)
|
||||
lastAddressSuggestions = suggestions
|
||||
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
|
||||
}
|
||||
catch {
|
||||
// Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie.
|
||||
banAddressOptions.value = []
|
||||
notifyUnavailable()
|
||||
}
|
||||
finally {
|
||||
addressLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Selection d'une suggestion d'adresse → remplit rue + ville + CP. */
|
||||
function onAddressSelect(option: { label: string, value: string | number } | null): void {
|
||||
if (option === null) {
|
||||
return
|
||||
}
|
||||
const suggestion = lastAddressSuggestions.find(s => s.street === option.value)
|
||||
if (!suggestion) {
|
||||
update('street', String(option.value))
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
street: suggestion.street,
|
||||
city: suggestion.city,
|
||||
postalCode: suggestion.postalCode,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||
non supprimable (1er bloc) ou en lecture seule. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.contact.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.lastName"
|
||||
:label="t('technique.providers.form.contact.lastName')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="model.firstName"
|
||||
:label="t('technique.providers.form.contact.firstName')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||
<div class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('technique.providers.form.contact.jobTitle')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
:model-value="model.email"
|
||||
:label="t('technique.providers.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('technique.providers.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('technique.providers.form.contact.addPhone')"
|
||||
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<!-- 2e numero : revele a la demande (max 2 telephones par contact). -->
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('technique.providers.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm'
|
||||
|
||||
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
|
||||
const PHONE_MASK = '## ## ## ## ##'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Brouillon du contact (v-model). */
|
||||
modelValue: ProviderContactFormDraft
|
||||
/** Affiche l'icone de suppression (1er bloc non supprimable). */
|
||||
removable?: boolean
|
||||
/** Bloc en lecture seule (onglet valide). */
|
||||
readonly?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ProviderContactFormDraft]
|
||||
'remove': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Alias local pour la lisibilite du template.
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||
function update<K extends keyof ProviderContactFormDraft>(field: K, value: ProviderContactFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
/** Revele le 2e numero (max 1 secondaire, le « + » disparait). */
|
||||
function revealSecondaryPhone(): void {
|
||||
emit('update:modelValue', { ...props.modelValue, hasSecondaryPhone: true })
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref, computed } from 'vue'
|
||||
import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
|
||||
import ProviderAddressBlock from '../ProviderAddressBlock.vue'
|
||||
|
||||
// Mocks controlables du composable BAN (hoisted), reutilise tel quel du M1/M2.
|
||||
const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
|
||||
searchCityMock: vi.fn(),
|
||||
searchAddressMock: vi.fn(),
|
||||
}))
|
||||
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
|
||||
useAddressAutocomplete: () => ({
|
||||
searchCity: searchCityMock,
|
||||
searchAddress: searchAddressMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
// 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)
|
||||
|
||||
// Stub de MalioInputAutocomplete : expose les `value` des options + allowCreate.
|
||||
const MalioInputAutocompleteStub = defineComponent({
|
||||
name: 'MalioInputAutocomplete',
|
||||
props: {
|
||||
modelValue: { type: [String, Number, null], default: undefined },
|
||||
options: { type: Array as () => { value: string | number, label: string }[], default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
minSearchLength: { type: Number, default: 0 },
|
||||
label: { type: String, default: '' },
|
||||
readonly: { type: Boolean, default: false },
|
||||
allowCreate: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ['update:modelValue', 'search', 'select'],
|
||||
setup(props) {
|
||||
return () => h('div', {
|
||||
'data-testid': 'addr-autocomplete',
|
||||
'data-options': JSON.stringify(props.options.map(o => o.value)),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<string, string>) {
|
||||
return mount(ProviderAddressBlock, {
|
||||
props: {
|
||||
modelValue: { ...emptyProviderAddress(), ...overrides },
|
||||
categoryOptions: [],
|
||||
siteOptions: [],
|
||||
contactOptions: [],
|
||||
countryOptions: [],
|
||||
...(errors ? { errors } : {}),
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
MalioButtonIcon: true,
|
||||
MalioSelect: true,
|
||||
MalioSelectCheckbox: true,
|
||||
MalioInputText: true,
|
||||
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('ProviderAddressBlock — version simplifiee M3 (pas de type/bennes/triage)', () => {
|
||||
it('ne rend NI type d\'adresse, NI bennes, NI prestation de triage (difference M2)', () => {
|
||||
const wrapper = mountBlock()
|
||||
// Pas de stepper (bennes) ni de case a cocher (triage) dans le bloc M3.
|
||||
expect(wrapper.find('malio-input-number-stub').exists()).toBe(false)
|
||||
expect(wrapper.find('malio-checkbox-stub').exists()).toBe(false)
|
||||
// Aucun select ne porte le label « type d'adresse ».
|
||||
const hasAddressType = wrapper.findAll('malio-select-stub').some(
|
||||
el => el.attributes('label') === 'technique.providers.form.address.addressType',
|
||||
)
|
||||
expect(hasAddressType).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProviderAddressBlock — mapping erreur par champ (ERP-101)', () => {
|
||||
it('affiche les erreurs serveur sur sites et categories (RG-3.05 / RG-3.09)', () => {
|
||||
const wrapper = mountBlock({}, {
|
||||
sites: 'Au moins un site est obligatoire.',
|
||||
categories: 'Au moins une catégorie est obligatoire.',
|
||||
})
|
||||
const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
|
||||
const sitesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.sites')
|
||||
const categoriesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.categories')
|
||||
|
||||
expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.')
|
||||
expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.')
|
||||
})
|
||||
|
||||
it('affiche l\'erreur serveur sur le code postal', () => {
|
||||
const wrapper = mountBlock({}, { postalCode: 'Code postal invalide.' })
|
||||
const field = wrapper.findAll('malio-input-text-stub').find(
|
||||
el => el.attributes('label') === 'technique.providers.form.address.postalCode',
|
||||
)
|
||||
expect(field?.attributes('error')).toBe('Code postal invalide.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProviderAddressBlock — autocompletion BAN (RG-3.06)', () => {
|
||||
beforeEach(() => {
|
||||
searchCityMock.mockReset()
|
||||
searchAddressMock.mockReset()
|
||||
})
|
||||
|
||||
it('n\'appelle pas la BAN en deca de 3 caracteres', async () => {
|
||||
const wrapper = mountBlock()
|
||||
wrapper.findComponent(MalioInputAutocompleteStub).vm.$emit('search', 'ab')
|
||||
await flushPromises()
|
||||
expect(searchAddressMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('relance la recherche apres une erreur (pas de bascule definitive)', async () => {
|
||||
searchAddressMock
|
||||
.mockRejectedValueOnce(new Error('BAN indisponible'))
|
||||
.mockResolvedValueOnce([
|
||||
{ label: '1 rue du Test, Châtellerault', street: '1 rue du Test', postalCode: '86100', city: 'Châtellerault' },
|
||||
])
|
||||
const wrapper = mountBlock()
|
||||
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
|
||||
|
||||
auto.vm.$emit('search', 'rue du test')
|
||||
await flushPromises()
|
||||
auto.vm.$emit('search', 'rue du teste')
|
||||
await flushPromises()
|
||||
|
||||
expect(searchAddressMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('cas degrade : la BAN echoue -> emet « degraded » une seule fois (RG-3.06)', async () => {
|
||||
searchAddressMock.mockRejectedValue(new Error('BAN indisponible'))
|
||||
const wrapper = mountBlock()
|
||||
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
|
||||
|
||||
auto.vm.$emit('search', 'rue du test')
|
||||
await flushPromises()
|
||||
auto.vm.$emit('search', 'rue du teste')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('degraded')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => {
|
||||
const wrapper = mountBlock()
|
||||
expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true)
|
||||
})
|
||||
|
||||
it('inclut la rue courante dans les options meme sans recherche BAN', () => {
|
||||
const wrapper = mountBlock({ street: '1 rue du Test' })
|
||||
const values = JSON.parse(wrapper.find('[data-testid="addr-autocomplete"]').attributes('data-options') ?? '[]')
|
||||
expect(values).toContain('1 rue du Test')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref, computed } from 'vue'
|
||||
import { emptyProviderContact } from '~/modules/technique/types/providerForm'
|
||||
import ProviderContactBlock from '../ProviderContactBlock.vue'
|
||||
|
||||
// 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)
|
||||
|
||||
/** Stub d'un champ Malio qui re-expose la prop `error` recue dans un data-* attribut. */
|
||||
function errorProbe(testid: string) {
|
||||
return defineComponent({
|
||||
name: `Probe-${testid}`,
|
||||
props: {
|
||||
modelValue: { type: [String, Number, null], default: undefined },
|
||||
error: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
readonly: { type: Boolean, default: false },
|
||||
},
|
||||
setup(props) {
|
||||
return () => h('div', { 'data-testid': testid, 'data-error': props.error })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function mountBlock(errors?: Record<string, string>) {
|
||||
return mount(ProviderContactBlock, {
|
||||
props: {
|
||||
modelValue: emptyProviderContact(),
|
||||
...(errors ? { errors } : {}),
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
MalioButtonIcon: true,
|
||||
MalioInputPhone: true,
|
||||
MalioInputText: errorProbe('contact-text'),
|
||||
MalioInputEmail: errorProbe('contact-email'),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('ProviderContactBlock — mapping erreur par champ (ERP-101)', () => {
|
||||
it('affiche l\'erreur serveur sur le champ email via la prop errors', () => {
|
||||
const wrapper = mountBlock({ email: 'L\'adresse email n\'est pas valide.' })
|
||||
expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('L\'adresse email n\'est pas valide.')
|
||||
})
|
||||
|
||||
it('laisse les champs sans erreur quand errors est absent', () => {
|
||||
const wrapper = mountBlock()
|
||||
expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,653 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Tests du workflow « Ajouter un prestataire » (M3 Technique, ERP-141).
|
||||
*
|
||||
* `useProviderForm` porte le formulaire principal (Nom + Categorie + Site) et
|
||||
* l'orchestration des onglets de creation. On verifie ici le CONTRAT propre a la
|
||||
* creation :
|
||||
* - RG-3.03 (front) : au moins un site requis ; RG-3.09 : au moins une categorie
|
||||
* -> POST bloque, erreurs inline, aucun appel reseau.
|
||||
* - POST /providers (groupe provider:write:main) : payload IRIs + Accept ld+json
|
||||
* + toast:false ; au succes, verrouillage + bascule sur l'onglet Contact +
|
||||
* reaffichage du nom normalise.
|
||||
* - 409 doublon (RG-3.10) -> erreur inline dediee sur companyName.
|
||||
* - 422 -> mapping inline par champ (propertyPath).
|
||||
* - Onglets : « Comptabilite » present uniquement avec accounting.view ;
|
||||
* completeTab deverrouille/avance et signale le dernier onglet.
|
||||
*/
|
||||
|
||||
const mockPost = vi.hoisted(() => vi.fn())
|
||||
const mockPatch = vi.hoisted(() => vi.fn())
|
||||
// Permissions comptables pilotables par test (presence/edition de l'onglet Comptabilite).
|
||||
const permState = vi.hoisted(() => ({ accountingView: false, accountingManage: false }))
|
||||
|
||||
vi.stubGlobal('useApi', () => ({
|
||||
get: vi.fn(),
|
||||
post: mockPost,
|
||||
put: vi.fn(),
|
||||
patch: mockPatch,
|
||||
delete: vi.fn(),
|
||||
}))
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('useToast', () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
}))
|
||||
vi.stubGlobal('usePermissions', () => ({
|
||||
can: (perm: string) => {
|
||||
if (perm === 'technique.providers.accounting.view') return permState.accountingView
|
||||
if (perm === 'technique.providers.accounting.manage') return permState.accountingManage
|
||||
return true
|
||||
},
|
||||
}))
|
||||
|
||||
const { useProviderForm, buildProviderCreateTabKeys } = await import('../useProviderForm')
|
||||
const { emptyProviderContact, emptyProviderAddress } = await import('~/modules/technique/types/providerForm')
|
||||
type ProviderForm = ReturnType<typeof useProviderForm>
|
||||
|
||||
const SITE_86 = '/api/sites/1'
|
||||
const CAT_MAINT = '/api/categories/7'
|
||||
|
||||
/** Accede a un bloc contact (cast : sous noUncheckedIndexedAccess l'index est optionnel). */
|
||||
function contactAt(form: ProviderForm, index = 0) {
|
||||
return form.contacts.value[index] ?? emptyProviderContact()
|
||||
}
|
||||
|
||||
/** Accede a un bloc adresse (idem). */
|
||||
function addressAt(form: ProviderForm, index = 0) {
|
||||
return form.addresses.value[index] ?? emptyProviderAddress()
|
||||
}
|
||||
|
||||
describe('useProviderForm', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
permState.accountingView = false
|
||||
permState.accountingManage = false
|
||||
})
|
||||
|
||||
it('front : formulaire principal vide -> erreurs sur nom + site + categorie, pas de POST', async () => {
|
||||
const form = useProviderForm()
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired')
|
||||
expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired')
|
||||
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
|
||||
expect(form.mainLocked.value).toBe(false)
|
||||
})
|
||||
|
||||
it('RG-3.03 (front) : un site present sans categorie n\'erre que sur categories', async () => {
|
||||
const form = useProviderForm()
|
||||
form.main.companyName = 'Maintenance Pro'
|
||||
form.main.siteIris = [SITE_86]
|
||||
|
||||
await form.submitMain()
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(form.mainErrors.errors.sites).toBeUndefined()
|
||||
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
|
||||
})
|
||||
|
||||
it('POST /providers avec IRIs + Accept ld+json, verrouille et bascule sur Contact', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 42, companyName: 'MAINTENANCE PRO' })
|
||||
const form = useProviderForm()
|
||||
form.main.companyName = 'Maintenance Pro'
|
||||
form.main.categoryIris = [CAT_MAINT]
|
||||
form.main.siteIris = [SITE_86]
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(true)
|
||||
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||
expect(url).toBe('/providers')
|
||||
expect(body).toEqual({
|
||||
companyName: 'Maintenance Pro',
|
||||
categories: [CAT_MAINT],
|
||||
sites: [SITE_86],
|
||||
})
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
|
||||
expect(form.providerId.value).toBe(42)
|
||||
// RG-3.11 : reaffiche le nom normalise (UPPERCASE) renvoye par le serveur.
|
||||
expect(form.main.companyName).toBe('MAINTENANCE PRO')
|
||||
expect(form.mainLocked.value).toBe(true)
|
||||
expect(form.activeTab.value).toBe('contact')
|
||||
expect(form.unlockedIndex.value).toBe(0)
|
||||
})
|
||||
|
||||
it('front : nom vide/espaces -> erreur inline sur companyName, pas de POST', async () => {
|
||||
const form = useProviderForm()
|
||||
form.main.companyName = ' '
|
||||
form.main.categoryIris = [CAT_MAINT]
|
||||
form.main.siteIris = [SITE_86]
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired')
|
||||
})
|
||||
|
||||
it('409 doublon (RG-3.10) : erreur inline dediee sur companyName, pas de verrouillage', async () => {
|
||||
mockPost.mockRejectedValueOnce({ response: { status: 409 } })
|
||||
const form = useProviderForm()
|
||||
form.main.companyName = 'Doublon'
|
||||
form.main.categoryIris = [CAT_MAINT]
|
||||
form.main.siteIris = [SITE_86]
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany')
|
||||
expect(form.mainLocked.value).toBe(false)
|
||||
})
|
||||
|
||||
it('422 : mappe les violations serveur inline par champ', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'sites', message: 'Au moins un site est requis.' }] },
|
||||
},
|
||||
})
|
||||
const form = useProviderForm()
|
||||
form.main.companyName = 'X'
|
||||
form.main.categoryIris = [CAT_MAINT]
|
||||
form.main.siteIris = [SITE_86]
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(form.mainErrors.errors.sites).toBe('Au moins un site est requis.')
|
||||
})
|
||||
|
||||
it('onglet Comptabilite : absent sans accounting.view, present avec', () => {
|
||||
expect(buildProviderCreateTabKeys(false)).toEqual(['contact', 'address'])
|
||||
expect(buildProviderCreateTabKeys(true)).toEqual(['contact', 'address', 'accounting'])
|
||||
|
||||
permState.accountingView = true
|
||||
const form = useProviderForm()
|
||||
expect(form.tabKeys.value).toEqual(['contact', 'address', 'accounting'])
|
||||
})
|
||||
|
||||
it('completeTab : deverrouille/avance, et signale le dernier onglet du flux', () => {
|
||||
const form = useProviderForm()
|
||||
|
||||
// Contact -> Adresse (pas le dernier).
|
||||
expect(form.completeTab('contact')).toBe(false)
|
||||
expect(form.isValidated('contact')).toBe(true)
|
||||
expect(form.activeTab.value).toBe('address')
|
||||
expect(form.unlockedIndex.value).toBe(1)
|
||||
|
||||
// Adresse = dernier onglet remplissable (sans accounting.view) -> true.
|
||||
expect(form.completeTab('address')).toBe(true)
|
||||
expect(form.isValidated('address')).toBe(true)
|
||||
})
|
||||
|
||||
it('patchProvider : PATCH /providers/{id} en mode strict, no-op avant creation', async () => {
|
||||
const form = useProviderForm()
|
||||
|
||||
await form.patchProvider({ siren: '123456789' })
|
||||
expect(mockPatch).not.toHaveBeenCalled()
|
||||
|
||||
mockPost.mockResolvedValueOnce({ id: 9, companyName: 'ACME' })
|
||||
form.main.companyName = 'Acme'
|
||||
form.main.categoryIris = [CAT_MAINT]
|
||||
form.main.siteIris = [SITE_86]
|
||||
await form.submitMain()
|
||||
|
||||
await form.patchProvider({ siren: '123456789' })
|
||||
expect(mockPatch).toHaveBeenCalledWith('/providers/9', { siren: '123456789' }, { toast: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useProviderForm — onglet Contact (ERP-142)', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
permState.accountingView = false
|
||||
permState.accountingManage = false
|
||||
})
|
||||
|
||||
/** Place le formulaire en etat « prestataire cree » (onglet Contact accessible). */
|
||||
function createdForm() {
|
||||
const form = useProviderForm()
|
||||
form.providerId.value = 7
|
||||
return form
|
||||
}
|
||||
|
||||
it('RG-3.04 : « + Nouveau contact » desactive tant que le dernier bloc est vide', () => {
|
||||
const form = createdForm()
|
||||
expect(form.canAddContact.value).toBe(false)
|
||||
|
||||
// addContact est un no-op tant que le bloc est vide.
|
||||
form.addContact()
|
||||
expect(form.contacts.value).toHaveLength(1)
|
||||
|
||||
contactAt(form).lastName = 'Doe'
|
||||
expect(form.canAddContact.value).toBe(true)
|
||||
form.addContact()
|
||||
expect(form.contacts.value).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('removeContact retire le bloc et son erreur de ligne', () => {
|
||||
const form = createdForm()
|
||||
contactAt(form).lastName = 'Doe'
|
||||
form.addContact()
|
||||
form.contactErrors.value = [{}, { lastName: 'x' }]
|
||||
|
||||
form.removeContact(1)
|
||||
expect(form.contacts.value).toHaveLength(1)
|
||||
expect(form.contactErrors.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('submitContacts : POST des nouveaux, capture id + IRI, finalise l\'onglet', async () => {
|
||||
mockPost.mockResolvedValueOnce({ '@id': '/api/provider_contacts/55', id: 55 })
|
||||
const form = createdForm()
|
||||
contactAt(form).lastName = 'Doe'
|
||||
|
||||
const ok = await form.submitContacts(vi.fn())
|
||||
|
||||
expect(ok).toBe(true)
|
||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||
expect(url).toBe('/providers/7/contacts')
|
||||
expect(body).toMatchObject({ lastName: 'Doe' })
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
expect(contactAt(form).id).toBe(55)
|
||||
expect(contactAt(form).iri).toBe('/api/provider_contacts/55')
|
||||
expect(form.isValidated('contact')).toBe(true)
|
||||
})
|
||||
|
||||
it('submitContacts : PATCH des contacts existants sur /provider_contacts/{id}', async () => {
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
contactAt(form).id = 55
|
||||
contactAt(form).lastName = 'Doe'
|
||||
|
||||
await form.submitContacts(vi.fn())
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(mockPatch).toHaveBeenCalledWith('/provider_contacts/55', expect.objectContaining({ lastName: 'Doe' }), { toast: false })
|
||||
})
|
||||
|
||||
it('RG-3.12 : onglet vide -> soumet l\'amorce pour declencher la 422 firstName inline', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'firstName', message: 'Au moins un champ du contact est obligatoire.' }] },
|
||||
},
|
||||
})
|
||||
const form = createdForm()
|
||||
|
||||
const ok = await form.submitContacts(vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||
expect(form.contactErrors.value[0]?.firstName).toBe('Au moins un champ du contact est obligatoire.')
|
||||
expect(form.isValidated('contact')).toBe(false)
|
||||
})
|
||||
|
||||
it('mappe les erreurs 422 PAR LIGNE (le bloc 2 echoue, le bloc 1 passe)', async () => {
|
||||
mockPost
|
||||
.mockResolvedValueOnce({ '@id': '/api/provider_contacts/1', id: 1 })
|
||||
.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'email', message: 'L\'adresse email n\'est pas valide.' }] },
|
||||
},
|
||||
})
|
||||
const form = createdForm()
|
||||
contactAt(form).lastName = 'Doe'
|
||||
form.addContact()
|
||||
contactAt(form, 1).email = 'invalide'
|
||||
|
||||
const ok = await form.submitContacts(vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.contactErrors.value[0]).toBeUndefined()
|
||||
expect(form.contactErrors.value[1]?.email).toBe('L\'adresse email n\'est pas valide.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useProviderForm — onglet Adresse (ERP-143)', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
permState.accountingView = false
|
||||
permState.accountingManage = false
|
||||
})
|
||||
|
||||
/** Place le formulaire en etat « prestataire cree » (onglet Adresse accessible). */
|
||||
function createdForm() {
|
||||
const form = useProviderForm()
|
||||
form.providerId.value = 7
|
||||
return form
|
||||
}
|
||||
|
||||
/** Remplit un bloc adresse valide (site + categorie + scalaires requis). */
|
||||
function fillValidAddress(form: ProviderForm, index = 0): void {
|
||||
const a = addressAt(form, index)
|
||||
a.siteIris = [SITE_86]
|
||||
a.categoryIris = [CAT_MAINT]
|
||||
a.postalCode = '86100'
|
||||
a.city = 'Châtellerault'
|
||||
a.street = '1 rue du Test'
|
||||
}
|
||||
|
||||
it('RG-3.05 : « + Nouvelle adresse » desactive tant que site + categorie manquent', () => {
|
||||
const form = createdForm()
|
||||
expect(form.canAddAddress.value).toBe(false)
|
||||
|
||||
// no-op tant que l'adresse n'est pas valide.
|
||||
form.addAddress()
|
||||
expect(form.addresses.value).toHaveLength(1)
|
||||
|
||||
addressAt(form).siteIris = [SITE_86]
|
||||
expect(form.canAddAddress.value).toBe(false) // categorie manquante
|
||||
addressAt(form).categoryIris = [CAT_MAINT]
|
||||
expect(form.canAddAddress.value).toBe(true)
|
||||
form.addAddress()
|
||||
expect(form.addresses.value).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('removeAddress retire le bloc et son erreur de ligne', () => {
|
||||
const form = createdForm()
|
||||
fillValidAddress(form)
|
||||
form.addAddress()
|
||||
form.addressErrors.value = [{}, { city: 'x' }]
|
||||
|
||||
form.removeAddress(1)
|
||||
expect(form.addresses.value).toHaveLength(1)
|
||||
expect(form.addressErrors.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('submitAddresses : POST des nouvelles, capture l\'id, finalise l\'onglet', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 88 })
|
||||
const form = createdForm()
|
||||
fillValidAddress(form)
|
||||
|
||||
const ok = await form.submitAddresses(vi.fn())
|
||||
|
||||
expect(ok).toBe(true)
|
||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||
expect(url).toBe('/providers/7/addresses')
|
||||
expect(body).toMatchObject({ sites: [SITE_86], categories: [CAT_MAINT], city: 'Châtellerault' })
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
expect(addressAt(form).id).toBe(88)
|
||||
expect(form.isValidated('address')).toBe(true)
|
||||
})
|
||||
|
||||
it('submitAddresses : PATCH des adresses existantes sur /provider_addresses/{id}', async () => {
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
fillValidAddress(form)
|
||||
addressAt(form).id = 88
|
||||
|
||||
await form.submitAddresses(vi.fn())
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(mockPatch).toHaveBeenCalledWith('/provider_addresses/88', expect.objectContaining({ sites: [SITE_86] }), { toast: false })
|
||||
})
|
||||
|
||||
it('mappe les erreurs 422 PAR LIGNE et ne finalise pas l\'onglet', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'city', message: 'La ville est obligatoire.' }] },
|
||||
},
|
||||
})
|
||||
const form = createdForm()
|
||||
fillValidAddress(form)
|
||||
|
||||
const ok = await form.submitAddresses(vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.addressErrors.value[0]?.city).toBe('La ville est obligatoire.')
|
||||
expect(form.isValidated('address')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useProviderForm — onglet Comptabilite (ERP-144)', () => {
|
||||
const TVA = '/api/tva_modes/1'
|
||||
const DELAY = '/api/payment_delays/1'
|
||||
const TYPE = '/api/payment_types/3'
|
||||
const BANK = '/api/banks/2'
|
||||
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
permState.accountingView = true
|
||||
permState.accountingManage = true
|
||||
})
|
||||
|
||||
/** Prestataire cree, onglet Comptabilite editable (view + manage). */
|
||||
function createdForm() {
|
||||
const form = useProviderForm()
|
||||
form.providerId.value = 7
|
||||
return form
|
||||
}
|
||||
|
||||
/** Remplit les scalaires comptables communs. */
|
||||
function fillScalars(form: ProviderForm): void {
|
||||
form.accounting.siren = '123456789'
|
||||
form.accounting.accountNumber = '4010'
|
||||
form.accounting.tvaModeIri = TVA
|
||||
form.accounting.nTva = 'FR123'
|
||||
form.accounting.paymentDelayIri = DELAY
|
||||
form.accounting.paymentTypeIri = TYPE
|
||||
}
|
||||
|
||||
it('lecture seule sans accounting.manage (Compta consultation / autres roles)', () => {
|
||||
permState.accountingManage = false
|
||||
const form = createdForm()
|
||||
expect(form.accountingReadonly.value).toBe(true)
|
||||
|
||||
permState.accountingManage = true
|
||||
const form2 = createdForm()
|
||||
expect(form2.accountingReadonly.value).toBe(false)
|
||||
})
|
||||
|
||||
it('RG-3.07 : setPaymentType(VIREMENT) garde la banque ; un autre type la vide', () => {
|
||||
const form = createdForm()
|
||||
form.accounting.bankIri = BANK
|
||||
|
||||
// Type VIREMENT -> banque requise, conservee.
|
||||
form.setPaymentType(TYPE, true, false)
|
||||
expect(form.accounting.bankIri).toBe(BANK)
|
||||
|
||||
// Type non-VIREMENT -> banque videe (sans objet).
|
||||
form.setPaymentType(TYPE, false, false)
|
||||
expect(form.accounting.bankIri).toBeNull()
|
||||
})
|
||||
|
||||
it('RG-3.08 : setPaymentType(LCR) garantit au moins un bloc RIB', () => {
|
||||
const form = createdForm()
|
||||
expect(form.ribs.value).toHaveLength(0)
|
||||
|
||||
form.setPaymentType(TYPE, false, true)
|
||||
expect(form.ribs.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('« + RIB » desactive tant que le dernier RIB est incomplet (RG-3.08)', () => {
|
||||
const form = createdForm()
|
||||
form.setPaymentType(TYPE, false, true)
|
||||
expect(form.canAddRib.value).toBe(false)
|
||||
|
||||
const rib = form.ribs.value[0]
|
||||
if (rib) {
|
||||
rib.label = 'Compte'
|
||||
rib.bic = 'BNPAFRPP'
|
||||
rib.iban = 'FR76...'
|
||||
}
|
||||
expect(form.canAddRib.value).toBe(true)
|
||||
})
|
||||
|
||||
it('VIREMENT : PATCH des scalaires avec banque, aucun appel RIB', async () => {
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
fillScalars(form)
|
||||
form.accounting.bankIri = BANK
|
||||
|
||||
const ok = await form.submitAccounting(true, false, vi.fn())
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/providers/7',
|
||||
expect.objectContaining({ paymentType: TYPE, bank: BANK, siren: '123456789' }),
|
||||
{ toast: false },
|
||||
)
|
||||
expect(form.isValidated('accounting')).toBe(true)
|
||||
})
|
||||
|
||||
it('hors VIREMENT : la banque part a null dans le payload (RG-3.07)', async () => {
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
fillScalars(form)
|
||||
form.accounting.bankIri = BANK // residu : doit etre ignore (isBankRequired=false)
|
||||
|
||||
await form.submitAccounting(false, false, vi.fn())
|
||||
|
||||
const body = mockPatch.mock.calls[0]?.[1] as Record<string, unknown>
|
||||
expect(body.bank).toBeNull()
|
||||
})
|
||||
|
||||
it('LCR : POST des RIB AVANT le PATCH des scalaires (ordre RG-3.08)', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 50 })
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
fillScalars(form)
|
||||
form.setPaymentType(TYPE, false, true)
|
||||
const rib = form.ribs.value[0]
|
||||
if (rib) {
|
||||
rib.label = 'Compte'
|
||||
rib.bic = 'BNPAFRPP'
|
||||
rib.iban = 'FR76...'
|
||||
}
|
||||
|
||||
const ok = await form.submitAccounting(false, true, vi.fn())
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/providers/7/ribs',
|
||||
expect.objectContaining({ label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }),
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
expect(form.ribs.value[0]?.id).toBe(50)
|
||||
// Le PATCH des scalaires intervient APRES la creation du RIB.
|
||||
expect(mockPatch).toHaveBeenCalledWith('/providers/7', expect.any(Object), { toast: false })
|
||||
})
|
||||
|
||||
it('422 sur les scalaires (bank) : mapping inline, onglet non finalise', async () => {
|
||||
mockPatch.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'bank', message: 'La banque est obligatoire pour un virement.' }] },
|
||||
},
|
||||
})
|
||||
const form = createdForm()
|
||||
fillScalars(form)
|
||||
|
||||
const ok = await form.submitAccounting(true, false, vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.accountingErrors.errors.bank).toBe('La banque est obligatoire pour un virement.')
|
||||
expect(form.isValidated('accounting')).toBe(false)
|
||||
})
|
||||
|
||||
it('LCR : 422 RIB par ligne -> pas de PATCH des scalaires', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'iban', message: 'L\'IBAN est obligatoire.' }] },
|
||||
},
|
||||
})
|
||||
const form = createdForm()
|
||||
fillScalars(form)
|
||||
form.setPaymentType(TYPE, false, true)
|
||||
const rib = form.ribs.value[0]
|
||||
if (rib) {
|
||||
rib.label = 'Compte'
|
||||
rib.bic = 'BNPAFRPP'
|
||||
}
|
||||
|
||||
const ok = await form.submitAccounting(false, true, vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.ribErrors.value[0]?.iban).toBe('L\'IBAN est obligatoire.')
|
||||
expect(mockPatch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useProviderForm — modification (ERP-145)', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
permState.accountingView = false
|
||||
permState.accountingManage = false
|
||||
})
|
||||
|
||||
it('editMode : completeTab ne verrouille pas et ne bascule pas d\'onglet', () => {
|
||||
const form = useProviderForm()
|
||||
form.editMode.value = true
|
||||
form.activeTab.value = 'contact'
|
||||
|
||||
expect(form.completeTab('contact')).toBe(false)
|
||||
expect(form.isValidated('contact')).toBe(false)
|
||||
expect(form.activeTab.value).toBe('contact')
|
||||
})
|
||||
|
||||
it('updateMain : PATCH /providers/{id} sur le groupe principal (pas de POST)', async () => {
|
||||
mockPatch.mockResolvedValueOnce({ id: 7, companyName: 'MAINTENANCE PRO' })
|
||||
const form = useProviderForm()
|
||||
form.providerId.value = 7
|
||||
form.main.companyName = 'Maintenance Pro'
|
||||
form.main.categoryIris = [CAT_MAINT]
|
||||
form.main.siteIris = [SITE_86]
|
||||
|
||||
const ok = await form.updateMain()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/providers/7',
|
||||
{ companyName: 'Maintenance Pro', categories: [CAT_MAINT], sites: [SITE_86] },
|
||||
{ toast: false },
|
||||
)
|
||||
// Reaffiche le nom normalise renvoye par le serveur.
|
||||
expect(form.main.companyName).toBe('MAINTENANCE PRO')
|
||||
})
|
||||
|
||||
it('updateMain : RG-3.03 front -> bloque le PATCH sans site', async () => {
|
||||
const form = useProviderForm()
|
||||
form.providerId.value = 7
|
||||
form.main.companyName = 'X'
|
||||
form.main.categoryIris = [CAT_MAINT]
|
||||
|
||||
const ok = await form.updateMain()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(mockPatch).not.toHaveBeenCalled()
|
||||
expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired')
|
||||
})
|
||||
|
||||
it('updateMain : 409 doublon -> erreur inline sur companyName', async () => {
|
||||
mockPatch.mockRejectedValueOnce({ response: { status: 409 } })
|
||||
const form = useProviderForm()
|
||||
form.providerId.value = 7
|
||||
form.main.companyName = 'Doublon'
|
||||
form.main.categoryIris = [CAT_MAINT]
|
||||
form.main.siteIris = [SITE_86]
|
||||
|
||||
const ok = await form.updateMain()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useProvidersRepository, type Provider } from '../useProvidersRepository'
|
||||
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
|
||||
/**
|
||||
* Tests du repertoire prestataires (ERP-140).
|
||||
*
|
||||
* `useProvidersRepository` est une fine enveloppe de `usePaginatedList<Provider>`
|
||||
* sur `/providers`. Les invariants generiques de pagination sont deja couverts
|
||||
* par `usePaginatedList.test.ts` ; on verifie ici le CONTRAT propre au repertoire :
|
||||
* - la ressource ciblee est bien `/providers`
|
||||
* - l'enveloppe Hydra (member / totalItems) est consommee
|
||||
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
|
||||
* renvoie un tableau plat sans pagination)
|
||||
* - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `includeArchived` n'est envoye
|
||||
* tant que l'utilisateur ne coche pas le filtre (le back masque alors les
|
||||
* archives) ; le filtre `includeArchived` est bien transmis une fois applique.
|
||||
*/
|
||||
describe('useProvidersRepository', () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset()
|
||||
})
|
||||
|
||||
/** Une page de prestataires Hydra, avec categories[] et sites[] embarques. */
|
||||
const PAGE: Provider[] = [
|
||||
{
|
||||
id: 1,
|
||||
companyName: 'ACME MAINTENANCE',
|
||||
categories: [{ code: 'MAINTENANCE_INDUSTRIELLE', name: 'Maintenance industrielle' }],
|
||||
sites: [{ id: 4, name: 'Chatellerault', color: '#056CF2' }],
|
||||
updatedAt: '2026-06-15T08:12:01+02:00',
|
||||
isArchived: false,
|
||||
},
|
||||
]
|
||||
|
||||
it('cible /providers, consomme l\'enveloppe Hydra et envoie l\'Accept ld+json', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
const repo = useProvidersRepository()
|
||||
|
||||
await repo.fetch()
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
||||
const [url, query, opts] = mockApiGet.mock.calls[0]
|
||||
expect(url).toBe('/providers')
|
||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
|
||||
expect(opts).toMatchObject({
|
||||
toast: false,
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
})
|
||||
expect(repo.items.value).toEqual(PAGE)
|
||||
expect(repo.totalItems.value).toBe(1)
|
||||
})
|
||||
|
||||
it('exclut les archives par defaut : aucun includeArchived au premier fetch', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
const repo = useProvidersRepository()
|
||||
|
||||
await repo.fetch()
|
||||
|
||||
const query = mockApiGet.mock.calls[0][1] as Record<string, unknown>
|
||||
expect(query.includeArchived).toBeUndefined()
|
||||
})
|
||||
|
||||
it('transmet includeArchived une fois le filtre applique (retour page 1)', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
const repo = useProvidersRepository()
|
||||
await repo.fetch()
|
||||
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
await repo.setFilters({ includeArchived: true })
|
||||
|
||||
expect(repo.currentPage.value).toBe(1)
|
||||
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||
expect(query.includeArchived).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ref } from 'vue'
|
||||
import type { ProviderDetail } from '~/modules/technique/utils/forms/providerDetail'
|
||||
|
||||
/**
|
||||
* Chargement et actions d'archivage d'un prestataire unique (ecrans Consultation /
|
||||
* Modification, ERP-145). Miroir de `useSupplier` (M2). Lit le detail embarque via
|
||||
* `GET /api/providers/{id}` (contacts / adresses + leurs sous-collections / ribs
|
||||
* sous `provider:item:read` / `provider:read:accounting`) — une SEULE requete
|
||||
* peuple les deux ecrans (embed borne, pas de N+1).
|
||||
*
|
||||
* L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload Hydra
|
||||
* complet (avec les `@id` des relations embarquees, indispensables au pre-remplissage).
|
||||
*
|
||||
* Etat 100 % local a l'instance (refs). Les erreurs d'archivage / restauration
|
||||
* (notamment le 409 d'homonyme actif a la restauration) sont PROPAGEES a l'appelant,
|
||||
* qui decide du toast a afficher.
|
||||
*/
|
||||
export function useProvider(id: number | string) {
|
||||
const api = useApi()
|
||||
|
||||
const provider = ref<ProviderDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
|
||||
/** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */
|
||||
function fetchDetail(): Promise<ProviderDetail> {
|
||||
return api.get<ProviderDetail>(
|
||||
`/providers/${id}`,
|
||||
{},
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
}
|
||||
|
||||
/** Charge le detail du prestataire. En cas d'echec : `error = true`, `provider = null`. */
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
provider.value = await fetchDetail()
|
||||
}
|
||||
catch {
|
||||
error.value = true
|
||||
provider.value = null
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bascule l'archivage (PATCH `isArchived` SEUL — groupe provider:write:archive ;
|
||||
* tout autre champ => 422), puis RECHARGE le detail complet : la reponse du PATCH
|
||||
* ne porte que `provider:read` (ni l'embed des sous-collections ni les libelles
|
||||
* comptables), un simple merge laisserait l'affichage incoherent. Toute erreur est
|
||||
* propagee a l'appelant AVANT le rechargement.
|
||||
*/
|
||||
async function setArchived(isArchived: boolean): Promise<void> {
|
||||
await api.patch(`/providers/${id}`, { isArchived }, { toast: false })
|
||||
provider.value = await fetchDetail()
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
loading,
|
||||
error,
|
||||
load,
|
||||
archive: () => setArchived(true),
|
||||
restore: () => setArchived(false),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
import { computed, reactive, ref, type Ref } from 'vue'
|
||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
|
||||
import { removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||
import {
|
||||
emptyProviderAccounting,
|
||||
emptyProviderAddress,
|
||||
emptyProviderContact,
|
||||
emptyProviderMain,
|
||||
emptyProviderRib,
|
||||
type ProviderAccountingDraft,
|
||||
type ProviderAddressFormDraft,
|
||||
type ProviderAddressResponse,
|
||||
type ProviderContactFormDraft,
|
||||
type ProviderContactResponse,
|
||||
type ProviderMainDraft,
|
||||
type ProviderMainResponse,
|
||||
type ProviderRibFormDraft,
|
||||
type ProviderRibResponse,
|
||||
} from '~/modules/technique/types/providerForm'
|
||||
import {
|
||||
buildProviderContactPayload,
|
||||
isProviderContactBlank,
|
||||
isProviderContactNamed,
|
||||
} from '~/modules/technique/utils/forms/providerContact'
|
||||
import {
|
||||
buildProviderAddressPayload,
|
||||
isProviderAddressValid,
|
||||
} from '~/modules/technique/utils/forms/providerAddress'
|
||||
import {
|
||||
buildProviderAccountingPayload,
|
||||
buildProviderRibPayload,
|
||||
isRibBlank,
|
||||
isRibComplete,
|
||||
} from '~/modules/technique/utils/forms/providerAccounting'
|
||||
|
||||
/**
|
||||
* Workflow de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) —
|
||||
* miroir conceptuel de la logique de creation fournisseur (M2), extraite ici en
|
||||
* composable.
|
||||
*
|
||||
* Particularites M3 (cf. spec-front § « Ecran Ajouter ») :
|
||||
* - PAS d'onglet « Information » : le formulaire principal est minimal (Nom +
|
||||
* Categorie + Site).
|
||||
* - Selecteur de site SUR le formulaire principal (RG-3.03, relation directe
|
||||
* `provider.sites`).
|
||||
* - Creation incrementale par onglets (Contact · Adresse · Comptabilite) :
|
||||
* POST principal puis PATCH partiels par groupe de serialisation
|
||||
* (`provider:write:*`, mode strict — spec-back § 2.10). Le contenu des onglets
|
||||
* arrive aux tickets ERP-142 → 144 ; ce composable pose le POST principal et
|
||||
* l'orchestration des onglets.
|
||||
*
|
||||
* Etat 100 % local a l'instance (refs/reactive) — aucune persistance URL.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cles des onglets du FLUX DE CREATION. Pas d'onglet « Information » au M3 ;
|
||||
* « Rapports » / « Echanges » n'apparaissent qu'en consultation/modification.
|
||||
* L'onglet « Comptabilite » n'est present que pour les roles qui peuvent le voir
|
||||
* (`technique.providers.accounting.view` — Admin, Compta).
|
||||
*/
|
||||
export function buildProviderCreateTabKeys(canAccountingView: boolean): string[] {
|
||||
return canAccountingView
|
||||
? ['contact', 'address', 'accounting']
|
||||
: ['contact', 'address']
|
||||
}
|
||||
|
||||
export function useProviderForm() {
|
||||
const api = useApi()
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { can } = usePermissions()
|
||||
|
||||
// Erreurs de validation par champ (ERP-101) du formulaire principal.
|
||||
const mainErrors = useFormErrors()
|
||||
|
||||
// ERP-172 : remontee d'erreur 409/422 lors d'une suppression immediate de
|
||||
// sous-ressource (message back affiche en toast dedie — pas de mapping inline,
|
||||
// le bloc est en cours de retrait). Ex. dernier RIB d'une LCR -> 409.
|
||||
function notifyRemovalError(error: unknown): void {
|
||||
toast.error({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: extractApiErrorMessage((error as { data?: unknown })?.data) || t('technique.providers.toast.error'),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Etat du prestataire cree ────────────────────────────────────────────
|
||||
const providerId = ref<number | null>(null)
|
||||
const mainLocked = ref(false)
|
||||
const mainSubmitting = ref(false)
|
||||
const tabSubmitting = ref(false)
|
||||
|
||||
// ── Formulaire principal ──────────────────────────────────────────────────
|
||||
const main = reactive<ProviderMainDraft>(emptyProviderMain())
|
||||
|
||||
// ── Onglets : ordre + gating progressif ───────────────────────────────────
|
||||
const canAccountingView = computed(() => can('technique.providers.accounting.view'))
|
||||
const canAccountingManage = computed(() => can('technique.providers.accounting.manage'))
|
||||
const tabKeys = computed(() => buildProviderCreateTabKeys(canAccountingView.value))
|
||||
|
||||
// Index du dernier onglet deverrouille (-1 tant que le prestataire n'est pas cree).
|
||||
const unlockedIndex = ref(-1)
|
||||
const activeTab = ref<string>('contact')
|
||||
// Onglets valides (passent en lecture seule).
|
||||
const validated = reactive<Record<string, boolean>>({})
|
||||
// Mode MODIFICATION (ERP-145) : navigation libre, pas de verrouillage ni de
|
||||
// bascule automatique d'onglet a la validation (cf. completeTab).
|
||||
const editMode = ref(false)
|
||||
|
||||
function isValidated(key: string): boolean {
|
||||
return validated[key] === true
|
||||
}
|
||||
|
||||
function tabIndex(key: string): number {
|
||||
return tabKeys.value.indexOf(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation FRONT du formulaire principal : RG-3.03 (>= 1 site) et RG-3.09
|
||||
* (>= 1 categorie). Pose les erreurs inline et retourne false si invalide.
|
||||
* Le back reste la couche autoritaire (ERP-101) ; ce pre-check evite un
|
||||
* aller-retour inutile et porte la garantie RG-3.03 cote front.
|
||||
*/
|
||||
function validateMainFront(): boolean {
|
||||
let valid = true
|
||||
if (!main.companyName?.trim()) {
|
||||
mainErrors.setError('companyName', t('technique.providers.form.errors.nameRequired'))
|
||||
valid = false
|
||||
}
|
||||
if (main.siteIris.length === 0) {
|
||||
mainErrors.setError('sites', t('technique.providers.form.errors.siteRequired'))
|
||||
valid = false
|
||||
}
|
||||
if (main.categoryIris.length === 0) {
|
||||
mainErrors.setError('categories', t('technique.providers.form.errors.categoryRequired'))
|
||||
valid = false
|
||||
}
|
||||
return valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload du POST principal (groupe `provider:write:main`). `companyName` est
|
||||
* omis s'il est vide afin que la 422 porte la violation NotBlank (RG-3.11) sur
|
||||
* le champ plutot qu'une erreur de type. Les relations M2M partent en IRI.
|
||||
*/
|
||||
function buildMainPayload(): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
categories: [...main.categoryIris],
|
||||
sites: [...main.siteIris],
|
||||
}
|
||||
if (main.companyName?.trim()) {
|
||||
payload.companyName = main.companyName
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /providers (groupe `provider:write:main`). Pre-check front RG-3.03/3.09,
|
||||
* puis creation. Au succes : verrouille le bloc principal, deverrouille le 1er
|
||||
* onglet et bascule sur « Contact ». Retourne true si cree, false sinon.
|
||||
*/
|
||||
async function submitMain(): Promise<boolean> {
|
||||
if (mainSubmitting.value) return false
|
||||
mainErrors.clearErrors()
|
||||
if (!validateMainFront()) return false
|
||||
|
||||
mainSubmitting.value = true
|
||||
try {
|
||||
const created = await api.post<ProviderMainResponse>('/providers', buildMainPayload(), {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
})
|
||||
|
||||
providerId.value = created.id
|
||||
// Reaffiche la valeur normalisee renvoyee par le serveur (UPPERCASE, RG-3.11).
|
||||
main.companyName = created.companyName ?? main.companyName
|
||||
|
||||
mainLocked.value = true
|
||||
unlockedIndex.value = 0
|
||||
activeTab.value = tabKeys.value[0] ?? 'contact'
|
||||
toast.success({ title: t('technique.providers.toast.createSuccess') })
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
// 409 = doublon de nom (RG-3.10) → erreur inline dediee + toast ;
|
||||
// 422 → mapping inline par champ ; autre → toast de fallback (ERP-101).
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
if (status === 409) {
|
||||
const message = t('technique.providers.form.duplicateCompany')
|
||||
mainErrors.setError('companyName', message)
|
||||
toast.error({ title: t('technique.providers.toast.error'), message })
|
||||
}
|
||||
else {
|
||||
mainErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') })
|
||||
}
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
mainSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH partiel du prestataire (mode strict : un seul groupe de serialisation
|
||||
* par appel — spec-back § 2.10). Sert l'onglet Comptabilite a champs scalaires
|
||||
* (ERP-144) ; les onglets Contact/Adresse passent par leurs sous-ressources
|
||||
* (POST/PATCH par ligne, ERP-142/143). No-op tant que le prestataire n'existe pas.
|
||||
*/
|
||||
async function patchProvider(payload: Record<string, unknown>): Promise<void> {
|
||||
if (providerId.value === null) return
|
||||
await api.patch(`/providers/${providerId.value}`, payload, { toast: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* MODIFICATION du bloc principal (ERP-145) : PATCH /providers/{id} sur le groupe
|
||||
* provider:write:main (nom + categories + sites). Pre-check front RG-3.03/3.09,
|
||||
* 409 doublon de nom (RG-3.10) et 422 mappes inline comme a la creation. A la
|
||||
* difference de `submitMain`, ne verrouille rien et ne bascule pas d'onglet (la
|
||||
* navigation est libre en modification). Retourne true si le PATCH a reussi.
|
||||
*/
|
||||
async function updateMain(): Promise<boolean> {
|
||||
if (providerId.value === null || mainSubmitting.value) return false
|
||||
mainErrors.clearErrors()
|
||||
if (!validateMainFront()) return false
|
||||
|
||||
mainSubmitting.value = true
|
||||
try {
|
||||
const updated = await api.patch<ProviderMainResponse>(
|
||||
`/providers/${providerId.value}`,
|
||||
buildMainPayload(),
|
||||
{ toast: false },
|
||||
)
|
||||
main.companyName = updated.companyName ?? main.companyName
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
if (status === 409) {
|
||||
const message = t('technique.providers.form.duplicateCompany')
|
||||
mainErrors.setError('companyName', message)
|
||||
toast.error({ title: t('technique.providers.toast.error'), message })
|
||||
}
|
||||
else {
|
||||
mainErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') })
|
||||
}
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
mainSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque un onglet valide (passe en lecture seule), deverrouille et avance a
|
||||
* l'onglet suivant. Retourne true si c'etait le dernier onglet du flux
|
||||
* (creation terminee), false sinon.
|
||||
*/
|
||||
function completeTab(key: string): boolean {
|
||||
// En modification : navigation libre, l'onglet reste editable apres validation.
|
||||
if (editMode.value) {
|
||||
return false
|
||||
}
|
||||
validated[key] = true
|
||||
const index = tabIndex(key)
|
||||
const next = tabKeys.value[index + 1]
|
||||
if (next === undefined) {
|
||||
return true
|
||||
}
|
||||
unlockedIndex.value = Math.max(unlockedIndex.value, index + 1)
|
||||
activeTab.value = next
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Soumet TOUS les blocs d'une collection en collectant les erreurs PAR INDEX :
|
||||
* on n'arrete pas au premier bloc en echec (decision ERP-101). Reinitialise la
|
||||
* cible, tente chaque ligne via `saveRow`, mappe les 422 inline ou delegue le
|
||||
* fallback a `onUnmappedError`. `shouldSkip` ignore les amorces vides. Retourne
|
||||
* true si au moins un bloc a echoue. Miroir de `useSupplierFormErrors.submitRows`.
|
||||
*/
|
||||
async function submitRows<T>(
|
||||
rows: T[],
|
||||
target: Ref<Record<string, string>[]>,
|
||||
saveRow: (row: T, index: number) => Promise<void>,
|
||||
onUnmappedError: (error: unknown, index: number) => void,
|
||||
shouldSkip?: (row: T, index: number) => boolean,
|
||||
): Promise<boolean> {
|
||||
target.value = []
|
||||
let hasError = false
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
const row = rows[index] as T
|
||||
if (shouldSkip?.(row, index)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await saveRow(row, index)
|
||||
}
|
||||
catch (error) {
|
||||
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
|
||||
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
|
||||
if (Object.keys(mapped).length > 0) {
|
||||
target.value[index] = mapped
|
||||
}
|
||||
else {
|
||||
onUnmappedError(error, index)
|
||||
}
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
return hasError
|
||||
}
|
||||
|
||||
// ── Onglet Contact (ERP-142) ──────────────────────────────────────────────
|
||||
const contacts = ref<ProviderContactFormDraft[]>([emptyProviderContact()])
|
||||
// Erreurs 422 par ligne (alignees sur l'index du v-for), peuplees par submitRows.
|
||||
const contactErrors = ref<Record<string, string>[]>([])
|
||||
|
||||
// « + Nouveau contact » desactive tant que le dernier bloc n'a pas de nom OU
|
||||
// prenom (RG-3.04, aligne M1/M2 — fonction/tel/email seuls ne suffisent pas).
|
||||
const canAddContact = computed(() => {
|
||||
const last = contacts.value[contacts.value.length - 1]
|
||||
return last !== undefined && isProviderContactNamed(last)
|
||||
})
|
||||
|
||||
function addContact(): void {
|
||||
if (canAddContact.value) {
|
||||
contacts.value.push(emptyProviderContact())
|
||||
}
|
||||
}
|
||||
|
||||
// ERP-172 : DELETE immediat du contact existant (sous-ressource) a la
|
||||
// confirmation de la modale. Bloc jamais persiste (id null) : retrait local.
|
||||
async function removeContact(index: number): Promise<void> {
|
||||
await removeCollectionRow({
|
||||
rows: contacts.value,
|
||||
errors: contactErrors.value,
|
||||
index,
|
||||
endpoint: '/provider_contacts',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyProviderContact,
|
||||
onError: notifyRemovalError,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Contact : POST des nouveaux contacts sur
|
||||
* /providers/{id}/contacts, PATCH des existants sur /provider_contacts/{id}
|
||||
* (sous-ressource, groupe provider:write:contacts). RG-3.12 : au moins un bloc
|
||||
* valide. Si l'onglet ne contient QUE des amorces vides, on les soumet pour
|
||||
* declencher la 422 RG-3.04 inline (sur `firstName`) plutot que de finaliser un
|
||||
* onglet vide. Retourne true si l'onglet a ete valide (avance/termine).
|
||||
*/
|
||||
async function submitContacts(onError: (error: unknown) => void): Promise<boolean> {
|
||||
if (providerId.value === null || tabSubmitting.value) {
|
||||
return false
|
||||
}
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
const hasSubmittable = contacts.value.some(c => c.id !== null || !isProviderContactBlank(c))
|
||||
const hasError = await submitRows(
|
||||
contacts.value,
|
||||
contactErrors,
|
||||
async (contact) => {
|
||||
const body = buildProviderContactPayload(contact)
|
||||
if (contact.id === null) {
|
||||
const created = await api.post<ProviderContactResponse>(
|
||||
`/providers/${providerId.value}/contacts`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
contact.id = created.id
|
||||
contact.iri = created['@id'] ?? null
|
||||
}
|
||||
else {
|
||||
await api.patch(`/provider_contacts/${contact.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
onError,
|
||||
contact => hasSubmittable && contact.id === null && isProviderContactBlank(contact),
|
||||
)
|
||||
if (hasError) {
|
||||
return false
|
||||
}
|
||||
completeTab('contact')
|
||||
return true
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Onglet Adresse (ERP-143) ──────────────────────────────────────────────
|
||||
const addresses = ref<ProviderAddressFormDraft[]>([emptyProviderAddress()])
|
||||
// Erreurs 422 par ligne (alignees sur l'index du v-for).
|
||||
const addressErrors = ref<Record<string, string>[]>([])
|
||||
|
||||
// « + Nouvelle adresse » desactive tant que la derniere adresse n'a pas
|
||||
// au moins un site ET une categorie (RG-3.05 / RG-3.09).
|
||||
const canAddAddress = computed(() => {
|
||||
const last = addresses.value[addresses.value.length - 1]
|
||||
return last !== undefined && isProviderAddressValid(last)
|
||||
})
|
||||
|
||||
function addAddress(): void {
|
||||
if (canAddAddress.value) {
|
||||
addresses.value.push(emptyProviderAddress())
|
||||
}
|
||||
}
|
||||
|
||||
// ERP-172 : DELETE immediat de l'adresse existante (sous-ressource).
|
||||
async function removeAddress(index: number): Promise<void> {
|
||||
await removeCollectionRow({
|
||||
rows: addresses.value,
|
||||
errors: addressErrors.value,
|
||||
index,
|
||||
endpoint: '/provider_addresses',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyProviderAddress,
|
||||
onError: notifyRemovalError,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Adresse : POST des nouvelles adresses sur
|
||||
* /providers/{id}/addresses, PATCH des existantes sur /provider_addresses/{id}
|
||||
* (sous-ressource, groupe provider:write:addresses). Erreurs 422 collectees par
|
||||
* ligne. Retourne true si l'onglet a ete valide (avance/termine).
|
||||
*/
|
||||
async function submitAddresses(onError: (error: unknown) => void): Promise<boolean> {
|
||||
if (providerId.value === null || tabSubmitting.value) {
|
||||
return false
|
||||
}
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
const hasError = await submitRows(
|
||||
addresses.value,
|
||||
addressErrors,
|
||||
async (address) => {
|
||||
const body = buildProviderAddressPayload(address)
|
||||
if (address.id === null) {
|
||||
const created = await api.post<ProviderAddressResponse>(
|
||||
`/providers/${providerId.value}/addresses`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
address.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/provider_addresses/${address.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
onError,
|
||||
)
|
||||
if (hasError) {
|
||||
return false
|
||||
}
|
||||
completeTab('address')
|
||||
return true
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Onglet Comptabilite (ERP-144) ─────────────────────────────────────────
|
||||
const accounting = reactive<ProviderAccountingDraft>(emptyProviderAccounting())
|
||||
const ribs = ref<ProviderRibFormDraft[]>([])
|
||||
const accountingErrors = useFormErrors()
|
||||
// Erreurs 422 par ligne de RIB (alignees sur l'index du v-for).
|
||||
const ribErrors = ref<Record<string, string>[]>([])
|
||||
|
||||
// L'onglet est editable seulement avec accounting.manage (sinon lecture seule).
|
||||
const accountingReadonly = computed(() => isValidated('accounting') || !canAccountingManage.value)
|
||||
|
||||
/**
|
||||
* Met a jour le type de reglement (IRI) en propageant ses RG inter-champs :
|
||||
* - hors VIREMENT (RG-3.07) : on vide la banque (sans objet) ;
|
||||
* - LCR (RG-3.08) : on garantit au moins un bloc RIB visible ; hors LCR, on
|
||||
* purge les erreurs de RIB (les blocs sont conserves mais non persistes).
|
||||
* `isBankRequired` / `isRibRequired` sont calcules par l'appelant (page) a
|
||||
* partir du code resolu via les referentiels.
|
||||
*/
|
||||
function setPaymentType(iri: string | null, isBankRequired: boolean, isRibRequired: boolean): void {
|
||||
accounting.paymentTypeIri = iri
|
||||
if (!isBankRequired) {
|
||||
accounting.bankIri = null
|
||||
}
|
||||
if (isRibRequired) {
|
||||
if (ribs.value.length === 0) {
|
||||
ribs.value.push(emptyProviderRib())
|
||||
}
|
||||
}
|
||||
else {
|
||||
ribErrors.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet (RG-3.08).
|
||||
const canAddRib = computed(() => {
|
||||
const last = ribs.value[ribs.value.length - 1]
|
||||
return last !== undefined && isRibComplete(last)
|
||||
})
|
||||
|
||||
function addRib(): void {
|
||||
if (canAddRib.value) {
|
||||
ribs.value.push(emptyProviderRib())
|
||||
}
|
||||
}
|
||||
|
||||
// ERP-172 : DELETE immediat du RIB existant. Le back peut refuser la suppression
|
||||
// du dernier RIB d'une LCR -> 409 remonte via notifyRemovalError, bloc conserve.
|
||||
async function removeRib(index: number): Promise<void> {
|
||||
await removeCollectionRow({
|
||||
rows: ribs.value,
|
||||
errors: ribErrors.value,
|
||||
index,
|
||||
endpoint: '/provider_ribs',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyProviderRib,
|
||||
onError: notifyRemovalError,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Comptabilite : (1) sous LCR, POST/PATCH des RIB d'abord
|
||||
* (le back valide RG-3.08 sur le PATCH scalaires, les RIB doivent donc exister
|
||||
* AVANT) ; (2) PATCH des scalaires comptables (groupe provider:write:accounting,
|
||||
* banque envoyee seulement si VIREMENT — RG-3.07). Erreurs RIB par ligne ;
|
||||
* erreurs scalaires inline (bank/paymentType). Retourne true si l'onglet a ete
|
||||
* valide.
|
||||
*/
|
||||
async function submitAccounting(
|
||||
isBankRequired: boolean,
|
||||
isRibRequired: boolean,
|
||||
onRibError: (error: unknown) => void,
|
||||
): Promise<boolean> {
|
||||
if (providerId.value === null || tabSubmitting.value) {
|
||||
return false
|
||||
}
|
||||
tabSubmitting.value = true
|
||||
accountingErrors.clearErrors()
|
||||
try {
|
||||
// 1) RIB d'abord, uniquement sous LCR. Une amorce vide neuve est sautee
|
||||
// s'il reste un autre RIB soumettable ; sinon (LCR sans aucun RIB rempli)
|
||||
// on la soumet pour declencher la 422 NotBlank inline.
|
||||
if (isRibRequired) {
|
||||
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
||||
const ribHasError = await submitRows(
|
||||
ribs.value,
|
||||
ribErrors,
|
||||
async (rib) => {
|
||||
const body = buildProviderRibPayload(rib)
|
||||
if (rib.id === null) {
|
||||
const created = await api.post<ProviderRibResponse>(
|
||||
`/providers/${providerId.value}/ribs`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
rib.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/provider_ribs/${rib.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
onRibError,
|
||||
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
||||
)
|
||||
if (ribHasError) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||
try {
|
||||
await api.patch(
|
||||
`/providers/${providerId.value}`,
|
||||
buildProviderAccountingPayload(accounting, isBankRequired),
|
||||
{ toast: false },
|
||||
)
|
||||
}
|
||||
catch (error) {
|
||||
accountingErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') })
|
||||
return false
|
||||
}
|
||||
|
||||
completeTab('accounting')
|
||||
return true
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// etat
|
||||
main,
|
||||
providerId,
|
||||
mainLocked,
|
||||
mainSubmitting,
|
||||
tabSubmitting,
|
||||
mainErrors,
|
||||
// onglets
|
||||
canAccountingView,
|
||||
canAccountingManage,
|
||||
tabKeys,
|
||||
activeTab,
|
||||
unlockedIndex,
|
||||
validated,
|
||||
editMode,
|
||||
isValidated,
|
||||
// contacts
|
||||
contacts,
|
||||
contactErrors,
|
||||
canAddContact,
|
||||
addContact,
|
||||
removeContact,
|
||||
submitContacts,
|
||||
// adresses
|
||||
addresses,
|
||||
addressErrors,
|
||||
canAddAddress,
|
||||
addAddress,
|
||||
removeAddress,
|
||||
submitAddresses,
|
||||
// comptabilite
|
||||
accounting,
|
||||
ribs,
|
||||
accountingErrors,
|
||||
ribErrors,
|
||||
accountingReadonly,
|
||||
setPaymentType,
|
||||
canAddRib,
|
||||
addRib,
|
||||
removeRib,
|
||||
submitAccounting,
|
||||
// actions
|
||||
validateMainFront,
|
||||
buildMainPayload,
|
||||
submitMain,
|
||||
updateMain,
|
||||
patchProvider,
|
||||
completeTab,
|
||||
submitRows,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Charge les referentiels (listes courtes) alimentant les selects du formulaire
|
||||
* principal de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) :
|
||||
* categories (type PRESTATAIRE) et sites (86 / 17 / 82).
|
||||
*
|
||||
* Miroir reduit de `useSupplierReferentials` (M2) : a ce stade (formulaire
|
||||
* principal) seuls categories + sites sont necessaires. Les referentiels
|
||||
* comptables (modes de TVA, delais/types de reglement, banques) seront charges
|
||||
* par l'onglet Comptabilite (ERP-144).
|
||||
*
|
||||
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
|
||||
* `?pagination=false` (referentiels de quelques entrees), avec l'en-tete
|
||||
* `Accept: application/ld+json` impose par API Platform 4 pour obtenir l'enveloppe
|
||||
* Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`), renvoyee telle
|
||||
* quelle dans le payload POST (relations M2M).
|
||||
*
|
||||
* Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole ; un
|
||||
* echec (permission manquante, reseau) laisse simplement la liste vide.
|
||||
*
|
||||
* Etat 100 % local a l'instance (refs) — aucune persistance URL.
|
||||
*/
|
||||
|
||||
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
|
||||
export interface RefOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Option de type de reglement enrichie de son code stable (RG-3.07 / RG-3.08). */
|
||||
export interface PaymentTypeOption extends RefOption {
|
||||
code: string
|
||||
}
|
||||
|
||||
interface HydraMember {
|
||||
'@id': string
|
||||
}
|
||||
|
||||
interface ReferentialMember extends HydraMember {
|
||||
code: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface CategoryMember extends HydraMember {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface SiteMember extends HydraMember {
|
||||
name: string
|
||||
postalCode: string
|
||||
}
|
||||
|
||||
interface CountryMember extends HydraMember {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||
|
||||
export function useProviderReferentials() {
|
||||
const api = useApi()
|
||||
|
||||
const categories = ref<RefOption[]>([])
|
||||
const sites = ref<RefOption[]>([])
|
||||
const countries = ref<RefOption[]>([])
|
||||
// Referentiels comptables (charges a la demande via loadAccounting).
|
||||
const tvaModes = ref<RefOption[]>([])
|
||||
const paymentDelays = ref<RefOption[]>([])
|
||||
const paymentTypes = ref<PaymentTypeOption[]>([])
|
||||
const banks = ref<RefOption[]>([])
|
||||
|
||||
/** Recupere une collection complete (pagination desactivee) en Hydra. */
|
||||
async function fetchAll<T extends HydraMember>(
|
||||
url: string,
|
||||
query: Record<string, string | string[]> = {},
|
||||
): Promise<T[]> {
|
||||
const res = await api.get<{ member?: T[] }>(
|
||||
url,
|
||||
{ pagination: 'false', ...query },
|
||||
{ headers: LD_JSON_HEADERS, toast: false },
|
||||
)
|
||||
return res.member ?? []
|
||||
}
|
||||
|
||||
/** Charge en parallele les referentiels du formulaire principal (categories + sites). */
|
||||
async function loadMain(): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
// RG-3.09 : un prestataire ne porte que des categories de type
|
||||
// PRESTATAIRE -> filtre cote API. Libelle affiche = `name`.
|
||||
fetchAll<CategoryMember>('/categories', { typeCode: 'PRESTATAIRE' })
|
||||
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name })) }),
|
||||
// Sites (RG-3.03) : libelle = numero de departement (2 premiers chiffres
|
||||
// du code postal du site), ex: 86100 -> « 86 », 17400 -> « 17 ».
|
||||
fetchAll<SiteMember>('/sites')
|
||||
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
|
||||
// Pays (ERP-116) : la valeur d'option est le NOM du pays (l'adresse stocke
|
||||
// `country` en chaine libre, « France »...). value === label. Aligne sur
|
||||
// les ecrans client/fournisseur. Sert le select Pays de l'onglet Adresse.
|
||||
fetchAll<CountryMember>('/countries')
|
||||
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge les referentiels comptables (onglet Comptabilite, ERP-144). Appele
|
||||
* uniquement quand l'utilisateur peut voir l'onglet (accounting.view). Resilient
|
||||
* (allSettled) : un referentiel en echec reste vide.
|
||||
*/
|
||||
async function loadAccounting(): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
fetchAll<ReferentialMember>('/tva_modes')
|
||||
.then((list) => { tvaModes.value = list.map(t => ({ value: t['@id'], label: t.label })) }),
|
||||
fetchAll<ReferentialMember>('/payment_delays')
|
||||
.then((list) => { paymentDelays.value = list.map(d => ({ value: d['@id'], label: d.label })) }),
|
||||
// Le code stable du type sert les RG-3.07 (VIREMENT) / RG-3.08 (LCR).
|
||||
fetchAll<ReferentialMember>('/payment_types')
|
||||
.then((list) => { paymentTypes.value = list.map(p => ({ value: p['@id'], label: p.label, code: p.code })) }),
|
||||
fetchAll<ReferentialMember>('/banks')
|
||||
.then((list) => { banks.value = list.map(b => ({ value: b['@id'], label: b.label })) }),
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
sites,
|
||||
countries,
|
||||
tvaModes,
|
||||
paymentDelays,
|
||||
paymentTypes,
|
||||
banks,
|
||||
loadMain,
|
||||
loadAccounting,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||
|
||||
/**
|
||||
* Site Starseed rattache DIRECTEMENT au prestataire (M2M `provider_site`,
|
||||
* RG-3.03), tel qu'embarque en LISTE (groupe site:read) pour la colonne « Site »
|
||||
* du Repertoire (badges colores).
|
||||
*
|
||||
* Difference M3 vs M2 : au M2 les sites venaient de l'agregat dedoublonne des
|
||||
* adresses (`Supplier::getSites()`) ; ici c'est une relation directe portee par
|
||||
* le formulaire principal (cf. spec-back M3 § 2.12).
|
||||
*/
|
||||
export interface ProviderSite {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorie (type PRESTATAIRE) rattachee au prestataire, embarquee en LISTE
|
||||
* (groupe category:read). La colonne « Catégories » affiche le `name` (cohérence
|
||||
* M1/M2 — libellé = `name`, pas `code`).
|
||||
*/
|
||||
export interface ProviderCategory {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue MINIMALE d'un prestataire pour le Repertoire (datatable). Volontairement
|
||||
* partielle : seuls les champs des colonnes + l'id (navigation) sont types ici.
|
||||
* Le detail complet (onglets) est hors perimetre de cet ecran (ERP-140).
|
||||
*/
|
||||
export interface Provider {
|
||||
id: number
|
||||
companyName: string
|
||||
categories: ProviderCategory[]
|
||||
sites: ProviderSite[]
|
||||
/** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */
|
||||
updatedAt: string | null
|
||||
isArchived: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Repertoire prestataires (ERP-140) — simple enveloppe de `usePaginatedList<Provider>`
|
||||
* sur la ressource `/providers` (pagination serveur obligatoire ; jamais de
|
||||
* chargement integral en memoire). Miroir de `useSuppliersRepository` (M2).
|
||||
*
|
||||
* Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes
|
||||
* par la page via `setFilters` du composable partage — la remise en page 1 est
|
||||
* garantie. Par defaut, aucun `includeArchived` n'est envoye : le back masque
|
||||
* donc les prestataires archives (exclusion par defaut, spec-back § 2.11).
|
||||
*
|
||||
* Le cloisonnement par site est applique AUTOMATIQUEMENT cote back (§ 2.13) en
|
||||
* fonction de l'utilisateur — rien a filtrer cote front.
|
||||
*
|
||||
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
|
||||
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
|
||||
* `usePaginatedList`. Aucun reset au logout a gerer.
|
||||
*/
|
||||
export function useProvidersRepository() {
|
||||
return usePaginatedList<Provider>({ url: '/providers' })
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
@@ -0,0 +1,543 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete : retour consultation + nom du prestataire. -->
|
||||
<div class="flex items-center gap-3 pt-11">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
v-bind="{ ariaLabel: t('technique.providers.edit.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Etats de chargement / introuvable. -->
|
||||
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('technique.providers.edit.loading') }}</p>
|
||||
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('technique.providers.edit.notFound') }}</p>
|
||||
|
||||
<template v-else-if="provider">
|
||||
<!-- ── Bloc principal (pre-rempli, editable si `manage`) ──────────── -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="main.companyName"
|
||||
:label="t('technique.providers.form.main.companyName')"
|
||||
:required="true"
|
||||
:readonly="businessReadonly"
|
||||
:error="mainErrors.errors.companyName"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="main.categoryIris"
|
||||
:options="referentials.categories.value"
|
||||
:label="t('technique.providers.form.main.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="businessReadonly"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.categories"
|
||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="main.siteIris"
|
||||
:options="referentials.sites.value"
|
||||
:label="t('technique.providers.form.main.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="businessReadonly"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.sites"
|
||||
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!businessReadonly" class="mt-12 flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.edit.save')"
|
||||
:disabled="mainSubmitting"
|
||||
@click="onUpdateMain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Onglets : navigation LIBRE, edition independante par onglet ──── -->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||
<!-- Onglet Contact -->
|
||||
<template #contact>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||
bloc enregistre. -->
|
||||
<ProviderContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="businessReadonly"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
/>
|
||||
<div v-if="!businessReadonly" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.form.contact.add')"
|
||||
:disabled="!canAddContact"
|
||||
@click="addContact"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.edit.save')"
|
||||
:disabled="tabSubmitting"
|
||||
@click="onSubmitContacts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Adresse -->
|
||||
<template #address>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<ProviderAddressBlock
|
||||
v-for="(address, index) in addresses"
|
||||
:key="index"
|
||||
:model-value="address"
|
||||
:category-options="referentials.categories.value"
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="businessReadonly"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
@degraded="onAddressDegraded"
|
||||
/>
|
||||
<div v-if="!businessReadonly" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.form.address.add')"
|
||||
:disabled="!canAddAddress"
|
||||
@click="addAddress"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.edit.save')"
|
||||
:disabled="tabSubmitting"
|
||||
@click="onSubmitAddresses"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Comptabilite (present si accounting.view ; editable si manage). -->
|
||||
<template v-if="canAccountingView" #accounting>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="accounting.siren"
|
||||
:label="t('technique.providers.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.siren"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.accountNumber"
|
||||
:label="t('technique.providers.form.accounting.accountNumber')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.accountNumber"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="referentials.tvaModes.value"
|
||||
:label="t('technique.providers.form.accounting.tvaMode')"
|
||||
:readonly="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.tvaMode"
|
||||
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.nTva"
|
||||
:label="t('technique.providers.form.accounting.nTva')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.nTva"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="referentials.paymentDelays.value"
|
||||
:label="t('technique.providers.form.accounting.paymentDelay')"
|
||||
:readonly="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentDelay"
|
||||
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="referentials.paymentTypes.value"
|
||||
:label="t('technique.providers.form.accounting.paymentType')"
|
||||
:readonly="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentType"
|
||||
@update:model-value="onPaymentTypeChange"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="isBankRequired"
|
||||
:model-value="accounting.bankIri"
|
||||
:options="referentials.banks.value"
|
||||
:label="t('technique.providers.form.accounting.bank')"
|
||||
:readonly="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.bank"
|
||||
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08). -->
|
||||
<div
|
||||
v-for="(rib, index) in visibleRibs"
|
||||
:key="index"
|
||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
|
||||
@click="askRemoveRib(index)"
|
||||
/>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('technique.providers.form.accounting.ribLabel')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.label"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.bic"
|
||||
:label="t('technique.providers.form.accounting.ribBic')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.bic"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.iban"
|
||||
:label="t('technique.providers.form.accounting.ribIban')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.iban"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
v-if="isRibRequired"
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.form.accounting.addRib')"
|
||||
:disabled="!canAddRib"
|
||||
@click="addRib"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.edit.save')"
|
||||
:disabled="tabSubmitting"
|
||||
@click="onSubmitAccounting"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MalioTabList>
|
||||
</template>
|
||||
|
||||
<!-- Modal de confirmation generique (suppression contact / adresse / RIB). -->
|
||||
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ t('technique.providers.form.confirmDelete.title') }}</h2>
|
||||
</template>
|
||||
<p>{{ confirmModal.message }}</p>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('technique.providers.form.confirmDelete.cancel')"
|
||||
@click="confirmModal.open = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
button-class="flex-1"
|
||||
:label="t('technique.providers.form.confirmDelete.confirm')"
|
||||
@click="runConfirm"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useProvider } from '~/modules/technique/composables/useProvider'
|
||||
import { useProviderReferentials, type RefOption } from '~/modules/technique/composables/useProviderReferentials'
|
||||
import { useProviderForm } from '~/modules/technique/composables/useProviderForm'
|
||||
import {
|
||||
canEditProvider,
|
||||
irisOf,
|
||||
mapAccountingDraft,
|
||||
mapAddressToDraft,
|
||||
mapContactToDraft,
|
||||
mapRibToDraft,
|
||||
paymentTypeCodeOf,
|
||||
} from '~/modules/technique/utils/forms/providerDetail'
|
||||
import {
|
||||
isBankRequiredForPaymentType,
|
||||
isRibRequiredForPaymentType,
|
||||
} from '~/modules/technique/utils/forms/providerAccounting'
|
||||
import {
|
||||
emptyProviderAddress,
|
||||
emptyProviderContact,
|
||||
emptyProviderRib,
|
||||
} from '~/modules/technique/types/providerForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||
|
||||
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can, canAny } = usePermissions()
|
||||
|
||||
const providerId = route.params.id as string
|
||||
|
||||
// Acces : l'edition exige `manage` OU `accounting.manage` (le role Compta edite
|
||||
// son onglet). Sinon retour consultation.
|
||||
if (!canEditProvider(canAny)) {
|
||||
await navigateTo(`/providers/${providerId}`)
|
||||
}
|
||||
|
||||
const businessReadonly = computed(() => !can('technique.providers.manage'))
|
||||
|
||||
const referentials = useProviderReferentials()
|
||||
const { provider, loading, error, load } = useProvider(providerId)
|
||||
|
||||
const {
|
||||
main,
|
||||
providerId: formProviderId,
|
||||
mainErrors,
|
||||
mainSubmitting,
|
||||
tabSubmitting,
|
||||
editMode,
|
||||
canAccountingView,
|
||||
tabKeys,
|
||||
activeTab,
|
||||
contacts,
|
||||
contactErrors,
|
||||
canAddContact,
|
||||
addContact,
|
||||
removeContact,
|
||||
submitContacts,
|
||||
addresses,
|
||||
addressErrors,
|
||||
canAddAddress,
|
||||
addAddress,
|
||||
removeAddress,
|
||||
submitAddresses,
|
||||
accounting,
|
||||
ribs,
|
||||
accountingErrors,
|
||||
ribErrors,
|
||||
accountingReadonly,
|
||||
setPaymentType,
|
||||
canAddRib,
|
||||
addRib,
|
||||
removeRib,
|
||||
submitAccounting,
|
||||
updateMain,
|
||||
} = useProviderForm()
|
||||
|
||||
// Modification : navigation libre + pas de verrouillage a la validation.
|
||||
editMode.value = true
|
||||
activeTab.value = 'contact'
|
||||
|
||||
const headerTitle = computed(() => provider.value?.companyName || t('technique.providers.edit.title'))
|
||||
useHead({ title: t('technique.providers.edit.title') })
|
||||
|
||||
// ── Onglets (navigation libre ; Comptabilite si accounting.view) ───────────────
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
contact: 'mdi:account-box-plus-outline',
|
||||
address: 'mdi:map-marker-outline',
|
||||
accounting: 'mdi:bank-circle-outline',
|
||||
}
|
||||
const tabs = computed(() => tabKeys.value.map(key => ({
|
||||
key,
|
||||
label: t(`technique.providers.tab.${key}`),
|
||||
icon: TAB_ICONS[key],
|
||||
})))
|
||||
|
||||
/** Pre-remplit les brouillons depuis la SEULE reponse detail. */
|
||||
function prefill(): void {
|
||||
const d = provider.value
|
||||
if (!d) return
|
||||
|
||||
// Indispensable : pilote les URLs des PATCH/POST par onglet (sinon les submits no-op).
|
||||
formProviderId.value = d.id
|
||||
|
||||
main.companyName = d.companyName ?? null
|
||||
main.categoryIris = irisOf(d.categories)
|
||||
main.siteIris = irisOf(d.sites)
|
||||
|
||||
const mappedContacts = (d.contacts ?? []).map(mapContactToDraft)
|
||||
contacts.value = mappedContacts.length > 0 ? mappedContacts : [emptyProviderContact()]
|
||||
|
||||
const mappedAddresses = (d.addresses ?? []).map(mapAddressToDraft)
|
||||
addresses.value = mappedAddresses.length > 0 ? mappedAddresses : [emptyProviderAddress()]
|
||||
|
||||
if (canAccountingView.value) {
|
||||
Object.assign(accounting, mapAccountingDraft(d))
|
||||
ribs.value = (d.ribs ?? []).map(mapRibToDraft)
|
||||
// Garantit un bloc RIB visible si le type de reglement est LCR.
|
||||
if (isRibRequiredForPaymentType(paymentTypeCodeOf(d.paymentType)) && ribs.value.length === 0) {
|
||||
ribs.value.push(emptyProviderRib())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Comptabilite : RG-3.07 / RG-3.08 pilotees par le code du type de reglement ──
|
||||
const selectedPaymentTypeCode = computed(() =>
|
||||
referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null,
|
||||
)
|
||||
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
|
||||
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
|
||||
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
|
||||
|
||||
function onPaymentTypeChange(value: string | number | null): void {
|
||||
const iri = value === null ? null : String(value)
|
||||
const code = referentials.paymentTypes.value.find(p => p.value === iri)?.code ?? null
|
||||
setPaymentType(iri, isBankRequiredForPaymentType(code), isRibRequiredForPaymentType(code))
|
||||
}
|
||||
|
||||
// ── Options adresses ──────────────────────────────────────────────────────────
|
||||
const contactOptions = computed<RefOption[]>(() =>
|
||||
contacts.value
|
||||
.filter(c => c.iri !== null)
|
||||
.map(c => ({
|
||||
value: c.iri as string,
|
||||
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? ''),
|
||||
})),
|
||||
)
|
||||
|
||||
const countryOptions = computed<RefOption[]>(() => {
|
||||
const list = referentials.countries.value
|
||||
return list.some(c => c.value === 'France')
|
||||
? list
|
||||
: [{ value: 'France', label: 'France' }, ...list]
|
||||
})
|
||||
|
||||
const addressDegradedNotified = ref(false)
|
||||
function onAddressDegraded(): void {
|
||||
if (addressDegradedNotified.value) return
|
||||
addressDegradedNotified.value = true
|
||||
toast.warning({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: t('technique.providers.form.address.degraded'),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Navigation + helpers ──────────────────────────────────────────────────────
|
||||
function goBack(): void {
|
||||
router.push(`/providers/${providerId}`)
|
||||
}
|
||||
|
||||
function apiErrorMessage(err: unknown): string {
|
||||
const data = (err as { response?: { _data?: unknown } })?.response?._data
|
||||
return extractApiErrorMessage(data) || t('technique.providers.toast.error')
|
||||
}
|
||||
|
||||
/** PATCH du bloc principal (groupe provider:write:main). */
|
||||
async function onUpdateMain(): Promise<void> {
|
||||
if (await updateMain()) {
|
||||
toast.success({ title: t('technique.providers.toast.updateSuccess') })
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmitContacts(): Promise<void> {
|
||||
const ok = await submitContacts(err => toast.error({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: apiErrorMessage(err),
|
||||
}))
|
||||
if (ok) toast.success({ title: t('technique.providers.toast.updateSuccess') })
|
||||
}
|
||||
|
||||
async function onSubmitAddresses(): Promise<void> {
|
||||
const ok = await submitAddresses(err => toast.error({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: apiErrorMessage(err),
|
||||
}))
|
||||
if (ok) toast.success({ title: t('technique.providers.toast.updateSuccess') })
|
||||
}
|
||||
|
||||
async function onSubmitAccounting(): Promise<void> {
|
||||
const ok = await submitAccounting(
|
||||
isBankRequired.value,
|
||||
isRibRequired.value,
|
||||
err => toast.error({ title: t('technique.providers.toast.error'), message: apiErrorMessage(err) }),
|
||||
)
|
||||
if (ok) toast.success({ title: t('technique.providers.toast.updateSuccess') })
|
||||
}
|
||||
|
||||
// ── Modal de confirmation generique ───────────────────────────────────────────
|
||||
const confirmModal = reactive({
|
||||
open: false,
|
||||
message: '',
|
||||
action: null as null | (() => void),
|
||||
})
|
||||
|
||||
function askConfirm(message: string, action: () => void): void {
|
||||
confirmModal.message = message
|
||||
confirmModal.action = action
|
||||
confirmModal.open = true
|
||||
}
|
||||
|
||||
function runConfirm(): void {
|
||||
confirmModal.action?.()
|
||||
confirmModal.action = null
|
||||
confirmModal.open = false
|
||||
}
|
||||
|
||||
function askRemoveContact(index: number): void {
|
||||
askConfirm(t('technique.providers.form.confirmDelete.contact'), () => removeContact(index))
|
||||
}
|
||||
|
||||
function askRemoveAddress(index: number): void {
|
||||
askConfirm(t('technique.providers.form.confirmDelete.address'), () => removeAddress(index))
|
||||
}
|
||||
|
||||
function askRemoveRib(index: number): void {
|
||||
askConfirm(t('technique.providers.form.confirmDelete.rib'), () => removeRib(index))
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
referentials.loadMain().catch(() => {})
|
||||
if (canAccountingView.value) {
|
||||
referentials.loadAccounting().catch(() => {})
|
||||
}
|
||||
await load()
|
||||
prefill()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete : retour repertoire + nom du prestataire + actions. -->
|
||||
<div class="flex items-center gap-3 pt-11">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
v-bind="{ ariaLabel: t('technique.providers.consultation.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
|
||||
|
||||
<div class="ml-auto flex items-center gap-12">
|
||||
<MalioButton
|
||||
v-if="canEdit"
|
||||
variant="secondary"
|
||||
icon-name="mdi:pencil-outline"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.action.edit')"
|
||||
@click="goEdit"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showArchive"
|
||||
variant="secondary"
|
||||
icon-name="mdi:archive-arrow-down-outline"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.action.archive')"
|
||||
@click="askToggleArchive"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showRestore"
|
||||
variant="secondary"
|
||||
icon-name="mdi:archive-arrow-up-outline"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.action.restore')"
|
||||
@click="askToggleArchive"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Etats de chargement / introuvable. -->
|
||||
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('technique.providers.consultation.loading') }}</p>
|
||||
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('technique.providers.consultation.notFound') }}</p>
|
||||
|
||||
<template v-else-if="provider">
|
||||
<!-- ── Bloc principal (lecture seule) ─────────────────────────────── -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
:model-value="provider.companyName"
|
||||
:label="t('technique.providers.form.main.companyName')"
|
||||
readonly
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="mainCategoryIris"
|
||||
:options="mainCategoryOptions"
|
||||
:label="t('technique.providers.form.main.categories')"
|
||||
:display-tag="true"
|
||||
readonly
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="mainSiteIris"
|
||||
:options="mainSiteOptions"
|
||||
:label="t('technique.providers.form.main.sites')"
|
||||
:display-tag="true"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Onglets (navigation libre, tout en lecture seule) ──────────── -->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||
<!-- Onglet Contacts -->
|
||||
<template #contacts>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<ProviderContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Adresse -->
|
||||
<template #address>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<ProviderAddressBlock
|
||||
v-for="(view, index) in addressViews"
|
||||
:key="index"
|
||||
:model-value="view.draft"
|
||||
:category-options="view.categoryOptions"
|
||||
:site-options="view.siteOptions"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptionsFor(view.draft.country)"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglets placeholder « A venir » (comme les autres modules). -->
|
||||
<template #reports><ComingSoonPlaceholder /></template>
|
||||
<template #exchanges><ComingSoonPlaceholder /></template>
|
||||
|
||||
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
|
||||
<template v-if="canAccountingView" #accounting>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" readonly />
|
||||
<MalioInputText :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" readonly />
|
||||
<MalioSelect :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" readonly empty-option-label="" />
|
||||
<MalioInputText :model-value="accounting.nTva" :label="t('technique.providers.form.accounting.nTva')" readonly />
|
||||
<MalioSelect :model-value="accounting.paymentDelayIri" :options="paymentDelayOptions" :label="t('technique.providers.form.accounting.paymentDelay')" readonly empty-option-label="" />
|
||||
<MalioSelect :model-value="accounting.paymentTypeIri" :options="paymentTypeOptions" :label="t('technique.providers.form.accounting.paymentType')" readonly empty-option-label="" />
|
||||
<MalioSelect v-if="isBankRequired" :model-value="accounting.bankIri" :options="bankOptions" :label="t('technique.providers.form.accounting.bank')" readonly empty-option-label="" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocs RIB (uniquement si type de reglement = LCR). -->
|
||||
<div
|
||||
v-for="(rib, index) in visibleRibs"
|
||||
:key="index"
|
||||
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" readonly />
|
||||
<MalioInputText :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" readonly />
|
||||
<MalioInputText :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" readonly />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MalioTabList>
|
||||
</template>
|
||||
|
||||
<!-- Modal de confirmation archivage / restauration. -->
|
||||
<MalioModal v-model="confirmArchive.open" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ confirmArchive.title }}</h2>
|
||||
</template>
|
||||
<p>{{ confirmArchive.message }}</p>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('technique.providers.form.confirmDelete.cancel')"
|
||||
@click="confirmArchive.open = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
button-class="flex-1"
|
||||
:label="confirmArchive.confirmLabel"
|
||||
@click="runToggleArchive"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useProvider } from '~/modules/technique/composables/useProvider'
|
||||
import {
|
||||
canEditProvider,
|
||||
categoryOptionsOf,
|
||||
contactOptionsOf,
|
||||
irisOf,
|
||||
mapAccountingDraft,
|
||||
mapAddressToDraft,
|
||||
mapContactToDraft,
|
||||
mapRibToDraft,
|
||||
paymentTypeCodeOf,
|
||||
referentialOptionOf,
|
||||
showArchiveAction,
|
||||
showRestoreAction,
|
||||
siteOptionsOf,
|
||||
} from '~/modules/technique/utils/forms/providerDetail'
|
||||
import { isBankRequiredForPaymentType, isRibRequiredForPaymentType } from '~/modules/technique/utils/forms/providerAccounting'
|
||||
import { emptyProviderAddress, emptyProviderContact } from '~/modules/technique/types/providerForm'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can, canAny } = usePermissions()
|
||||
|
||||
const providerId = route.params.id as string
|
||||
const { provider, loading, error, load, archive, restore } = useProvider(providerId)
|
||||
|
||||
const canAccountingView = computed(() => can('technique.providers.accounting.view'))
|
||||
const canEdit = computed(() => canEditProvider(canAny))
|
||||
const isArchived = computed(() => provider.value?.isArchived ?? false)
|
||||
const showArchive = computed(() => showArchiveAction(can, isArchived.value))
|
||||
const showRestore = computed(() => showRestoreAction(can, isArchived.value))
|
||||
|
||||
const headerTitle = computed(() => provider.value?.companyName || t('technique.providers.consultation.title'))
|
||||
useHead({ title: t('technique.providers.consultation.title') })
|
||||
|
||||
// ── Onglets (ordre spec : Contacts · Adresse · Rapports · Échanges · Comptabilité) ──
|
||||
const activeTab = ref('contacts')
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
contacts: 'mdi:account-box-plus-outline',
|
||||
address: 'mdi:map-marker-outline',
|
||||
reports: 'mdi:file-chart-outline',
|
||||
exchanges: 'mdi:swap-horizontal',
|
||||
accounting: 'mdi:bank-circle-outline',
|
||||
}
|
||||
const tabs = computed(() => {
|
||||
const keys = ['contacts', 'address', 'reports', 'exchanges']
|
||||
if (canAccountingView.value) keys.push('accounting')
|
||||
return keys.map(key => ({ key, label: t(`technique.providers.tab.${key}`), icon: TAB_ICONS[key] }))
|
||||
})
|
||||
|
||||
// ── Donnees mappees depuis la SEULE reponse detail ─────────────────────────────
|
||||
const mainCategoryIris = computed(() => irisOf(provider.value?.categories))
|
||||
const mainSiteIris = computed(() => irisOf(provider.value?.sites))
|
||||
const mainCategoryOptions = computed(() => categoryOptionsOf(provider.value?.categories))
|
||||
const mainSiteOptions = computed(() => siteOptionsOf(provider.value?.sites))
|
||||
|
||||
// Au moins un bloc affiche meme sans donnee (bloc vide en lecture seule, comme
|
||||
// l'onglet Comptabilite et les autres modules — pas de message « Aucun … »).
|
||||
const contacts = computed(() => {
|
||||
const list = (provider.value?.contacts ?? []).map(mapContactToDraft)
|
||||
return list.length > 0 ? list : [emptyProviderContact()]
|
||||
})
|
||||
// Contacts rattachables (pour resoudre les libelles des contacts lies aux adresses).
|
||||
const contactOptions = computed(() => contactOptionsOf(provider.value?.contacts))
|
||||
|
||||
// Vue par adresse : brouillon + options propres a l'adresse (sites/categories embarques).
|
||||
const addressViews = computed(() => {
|
||||
const views = (provider.value?.addresses ?? []).map(address => ({
|
||||
draft: mapAddressToDraft(address),
|
||||
siteOptions: siteOptionsOf(address.sites),
|
||||
categoryOptions: categoryOptionsOf(address.categories),
|
||||
}))
|
||||
return views.length > 0
|
||||
? views
|
||||
: [{ draft: emptyProviderAddress(), siteOptions: [], categoryOptions: [] }]
|
||||
})
|
||||
|
||||
/** Pays : une seule option (la valeur courante), suffisant pour l'affichage readonly. */
|
||||
function countryOptionsFor(country: string): { value: string, label: string }[] {
|
||||
return country ? [{ value: country, label: country }] : []
|
||||
}
|
||||
|
||||
// ── Comptabilite (presente uniquement si accounting.view) ──────────────────────
|
||||
const accounting = computed(() => mapAccountingDraft(provider.value ?? { id: 0, '@id': '' }))
|
||||
const paymentTypeCode = computed(() => paymentTypeCodeOf(provider.value?.paymentType))
|
||||
const isBankRequired = computed(() => isBankRequiredForPaymentType(paymentTypeCode.value))
|
||||
const isRibRequired = computed(() => isRibRequiredForPaymentType(paymentTypeCode.value))
|
||||
const visibleRibs = computed(() => isRibRequired.value ? (provider.value?.ribs ?? []).map(mapRibToDraft) : [])
|
||||
|
||||
// Options « une entree » construites depuis l'embed (libelles role-independants).
|
||||
const tvaModeOptions = computed(() => referentialOptionOf(provider.value?.tvaMode))
|
||||
const paymentDelayOptions = computed(() => referentialOptionOf(provider.value?.paymentDelay))
|
||||
const paymentTypeOptions = computed(() => referentialOptionOf(provider.value?.paymentType))
|
||||
const bankOptions = computed(() => referentialOptionOf(provider.value?.bank))
|
||||
|
||||
// ── Navigation / actions ───────────────────────────────────────────────────────
|
||||
function goBack(): void {
|
||||
router.push('/providers')
|
||||
}
|
||||
|
||||
function goEdit(): void {
|
||||
router.push(`/providers/${providerId}/edit`)
|
||||
}
|
||||
|
||||
// ── Archivage / restauration ───────────────────────────────────────────────────
|
||||
const confirmArchive = reactive({
|
||||
open: false,
|
||||
title: '',
|
||||
message: '',
|
||||
confirmLabel: '',
|
||||
})
|
||||
|
||||
function askToggleArchive(): void {
|
||||
const archiving = !isArchived.value
|
||||
confirmArchive.title = archiving
|
||||
? t('technique.providers.action.archive')
|
||||
: t('technique.providers.action.restore')
|
||||
confirmArchive.message = archiving
|
||||
? t('technique.providers.consultation.confirmArchive')
|
||||
: t('technique.providers.consultation.confirmRestore')
|
||||
confirmArchive.confirmLabel = archiving
|
||||
? t('technique.providers.action.archive')
|
||||
: t('technique.providers.action.restore')
|
||||
confirmArchive.open = true
|
||||
}
|
||||
|
||||
async function runToggleArchive(): Promise<void> {
|
||||
const archiving = !isArchived.value
|
||||
confirmArchive.open = false
|
||||
try {
|
||||
await (archiving ? archive() : restore())
|
||||
toast.success({
|
||||
title: archiving
|
||||
? t('technique.providers.toast.archiveSuccess')
|
||||
: t('technique.providers.toast.restoreSuccess'),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
// 409 a la restauration (homonyme actif) ou autre : toast generique.
|
||||
toast.error({ title: t('technique.providers.toast.error') })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,438 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ t('technique.providers.title') }}
|
||||
<template #actions>
|
||||
<!-- gap-8 = 32px d'espacement entre Filtrer et Ajouter. -->
|
||||
<div class="flex items-center gap-8">
|
||||
<!-- Bouton Filtrer a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
|
||||
<MalioButton
|
||||
v-if="canView"
|
||||
variant="tertiary"
|
||||
:label="filterButtonLabel"
|
||||
icon-name="mdi:tune"
|
||||
icon-position="left"
|
||||
icon-size="24"
|
||||
@click="openFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
variant="secondary"
|
||||
:label="t('technique.providers.add')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="goToCreate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Datatable branchee sur usePaginatedList via useProvidersRepository :
|
||||
pagination serveur, tri companyName ASC par defaut (cote back),
|
||||
archives masques par defaut. Cloisonnement par site cote back. -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
row-clickable
|
||||
table-class="table-fixed providers-table"
|
||||
:empty-message="t('technique.providers.empty')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<!-- Categories : libelles (name) separes par une virgule. -->
|
||||
<template #cell-categories="{ item }">
|
||||
{{ formatCategories(item) }}
|
||||
</template>
|
||||
|
||||
<!-- Sites : badges colores (name + color), relation directe du prestataire. -->
|
||||
<template #cell-sites="{ item }">
|
||||
<span class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="site in (item.sites as ProviderSite[])"
|
||||
:key="site.id"
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 font-medium text-white"
|
||||
:style="{ backgroundColor: site.color }"
|
||||
>
|
||||
{{ site.name }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Derniere activite : date de derniere modification (updatedAt), format JJ-MM-AAAA. -->
|
||||
<template #cell-lastActivity="{ item }">
|
||||
{{ formatLastActivity(item) }}
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<div class="flex justify-center mt-4">
|
||||
<MalioButton
|
||||
v-if="canView"
|
||||
variant="primary"
|
||||
:label="t('technique.providers.export')"
|
||||
:disabled="exporting"
|
||||
@click="exportXlsx"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
|
||||
« Voir les résultats ». Meme pattern que le repertoire fournisseurs.
|
||||
Etat 100 % local, jamais dans l'URL (regle ABSOLUE n°6). -->
|
||||
<MalioDrawer
|
||||
v-model="filterDrawerOpen"
|
||||
drawer-class="max-w-[450px]"
|
||||
body-class="p-0"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold uppercase">{{ t('technique.providers.filters.title') }}</h2>
|
||||
</template>
|
||||
|
||||
<MalioAccordion>
|
||||
<!-- Recherche : nom entreprise + contact + email (param `search`). -->
|
||||
<MalioAccordionItem :title="t('technique.providers.filters.search')" value="search">
|
||||
<MalioInputText
|
||||
v-model="draftSearch"
|
||||
icon-name="mdi:magnify"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Categories (type PRESTATAIRE) : cases a cocher (multi). Valeur = code stable. -->
|
||||
<MalioAccordionItem :title="t('technique.providers.filters.categories')" value="categories">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="opt in categoryOptions"
|
||||
:id="`filter-category-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="draftCategoryCodes.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => toggleCategory(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Sites : cases a cocher (multi). Valeur = id du site. -->
|
||||
<MalioAccordionItem :title="t('technique.providers.filters.sites')" value="sites">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="opt in siteOptions"
|
||||
:id="`filter-site-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="draftSiteIds.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => toggleSite(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). -->
|
||||
<MalioAccordionItem :title="t('technique.providers.filters.status')" value="status">
|
||||
<MalioCheckbox
|
||||
id="filter-include-archived"
|
||||
:label="t('technique.providers.filters.includeArchived')"
|
||||
:model-value="draftIncludeArchived"
|
||||
@update:model-value="(val: boolean) => draftIncludeArchived = val"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('technique.providers.filters.reset')"
|
||||
button-class="w-m-btn-action"
|
||||
@click="resetFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.filters.apply')"
|
||||
button-class="w-[170px]"
|
||||
@click="applyFilters"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { Provider, ProviderSite } from '~/modules/technique/composables/useProvidersRepository'
|
||||
|
||||
interface FilterOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('technique.providers.title') })
|
||||
|
||||
// Bouton « Ajouter » reserve a `manage` (POST /providers garde manage seul —
|
||||
// Compta cree pas). « Exporter » et « Filtrer » suivent `view`.
|
||||
const canManage = computed(() => can('technique.providers.manage'))
|
||||
const canView = computed(() => can('technique.providers.view'))
|
||||
|
||||
const {
|
||||
items: providers,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadProviders,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
setFilters,
|
||||
} = useProvidersRepository()
|
||||
|
||||
// Mappe les prestataires en objets « plats » pour MalioDataTable (items typees
|
||||
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
|
||||
// implicite, contrairement a l'interface Provider. Meme pattern que fournisseurs.
|
||||
const rows = computed(() => providers.value.map(provider => ({
|
||||
id: provider.id,
|
||||
companyName: provider.companyName,
|
||||
categories: provider.categories,
|
||||
sites: provider.sites,
|
||||
updatedAt: provider.updatedAt,
|
||||
})))
|
||||
|
||||
const columns = [
|
||||
{ key: 'companyName', label: t('technique.providers.column.companyName') },
|
||||
{ key: 'categories', label: t('technique.providers.column.categories') },
|
||||
{ key: 'sites', label: t('technique.providers.column.sites') },
|
||||
{ key: 'lastActivity', label: t('technique.providers.column.lastActivity') },
|
||||
]
|
||||
|
||||
/** Libelles des categories du prestataire, separes par une virgule (name). */
|
||||
function formatCategories(item: Record<string, unknown>): string {
|
||||
const categories = (item.categories as Provider['categories']) ?? []
|
||||
return categories.map(c => c.name).join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Derniere activite : date de derniere modification de la fiche (updatedAt,
|
||||
* expose en liste via default:read). Format court francais JJ-MM-AAAA (tirets,
|
||||
* cf. spec-front M3 § Datatable).
|
||||
*/
|
||||
function formatLastActivity(item: Record<string, unknown>): string {
|
||||
const value = item.updatedAt as string | null | undefined
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Garde-fou date invalide : un updatedAt mal forme donnerait « Invalid Date ».
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const year = date.getFullYear()
|
||||
return `${day}-${month}-${year}`
|
||||
}
|
||||
|
||||
/** Clic sur une ligne → ecran Consultation (route a plat /providers/{id}). */
|
||||
function onRowClick(item: Record<string, unknown>): void {
|
||||
router.push(`/providers/${item.id}`)
|
||||
}
|
||||
|
||||
function goToCreate(): void {
|
||||
router.push('/providers/new')
|
||||
}
|
||||
|
||||
// ── Filtres (drawer) ────────────────────────────────────────────────────────
|
||||
// Deux niveaux d'etat (pattern repertoire fournisseurs) :
|
||||
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
|
||||
// uniquement au clic « Voir les résultats » / « Réinitialiser ».
|
||||
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
|
||||
const filterDrawerOpen = ref(false)
|
||||
|
||||
const draftSearch = ref('')
|
||||
const draftCategoryCodes = ref<string[]>([])
|
||||
const draftSiteIds = ref<string[]>([])
|
||||
const draftIncludeArchived = ref(false)
|
||||
|
||||
const appliedSearch = ref('')
|
||||
const appliedCategoryCodes = ref<string[]>([])
|
||||
const appliedSiteIds = ref<string[]>([])
|
||||
const appliedIncludeArchived = ref(false)
|
||||
|
||||
// Options des selects multi, chargees une fois (referentiels courts).
|
||||
const categoryOptions = ref<FilterOption[]>([])
|
||||
const siteOptions = ref<FilterOption[]>([])
|
||||
|
||||
const activeFilterCount = computed(() => {
|
||||
let count = 0
|
||||
if (appliedSearch.value.trim() !== '') count++
|
||||
if (appliedCategoryCodes.value.length > 0) count++
|
||||
if (appliedSiteIds.value.length > 0) count++
|
||||
if (appliedIncludeArchived.value) count++
|
||||
return count
|
||||
})
|
||||
|
||||
const filterButtonLabel = computed(() => {
|
||||
const base = t('technique.providers.filters.title')
|
||||
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
|
||||
})
|
||||
|
||||
// Recopie l'etat applique vers le brouillon puis ouvre le drawer : la
|
||||
// reouverture reflete les filtres actifs.
|
||||
function openFilters(): void {
|
||||
draftSearch.value = appliedSearch.value
|
||||
draftCategoryCodes.value = [...appliedCategoryCodes.value]
|
||||
draftSiteIds.value = [...appliedSiteIds.value]
|
||||
draftIncludeArchived.value = appliedIncludeArchived.value
|
||||
filterDrawerOpen.value = true
|
||||
}
|
||||
|
||||
function toggleCategory(code: string, selected: boolean): void {
|
||||
draftCategoryCodes.value = selected
|
||||
? [...draftCategoryCodes.value, code]
|
||||
: draftCategoryCodes.value.filter(c => c !== code)
|
||||
}
|
||||
|
||||
function toggleSite(id: string, selected: boolean): void {
|
||||
draftSiteIds.value = selected
|
||||
? [...draftSiteIds.value, id]
|
||||
: draftSiteIds.value.filter(s => s !== id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le payload de filtres serveur a partir de l'etat applique. Cles
|
||||
* `categoryCode[]` / `siteId[]` pour que PHP les parse en tableaux (OR cote back).
|
||||
* Les filtres vides sont omis pour une query propre.
|
||||
*/
|
||||
function buildFilterPayload(): Record<string, string | string[] | boolean> {
|
||||
const payload: Record<string, string | string[] | boolean> = {}
|
||||
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
||||
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
|
||||
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
|
||||
if (appliedIncludeArchived.value) payload.includeArchived = true
|
||||
return payload
|
||||
}
|
||||
|
||||
// « Voir les résultats » : recopie brouillon → applied, pousse les filtres
|
||||
// (retombe en page 1 via usePaginatedList) et ferme le drawer.
|
||||
function applyFilters(): void {
|
||||
appliedSearch.value = draftSearch.value.trim()
|
||||
appliedCategoryCodes.value = [...draftCategoryCodes.value]
|
||||
appliedSiteIds.value = [...draftSiteIds.value]
|
||||
appliedIncludeArchived.value = draftIncludeArchived.value
|
||||
|
||||
setFilters(buildFilterPayload(), { replace: true })
|
||||
filterDrawerOpen.value = false
|
||||
}
|
||||
|
||||
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
|
||||
// Le drawer reste ouvert pour montrer le formulaire vide.
|
||||
function resetFilters(): void {
|
||||
draftSearch.value = ''
|
||||
draftCategoryCodes.value = []
|
||||
draftSiteIds.value = []
|
||||
draftIncludeArchived.value = false
|
||||
|
||||
appliedSearch.value = ''
|
||||
appliedCategoryCodes.value = []
|
||||
appliedSiteIds.value = []
|
||||
appliedIncludeArchived.value = false
|
||||
|
||||
setFilters({}, { replace: true })
|
||||
}
|
||||
|
||||
/** Charge les referentiels du drawer (categories PRESTATAIRE + sites) via ?pagination=false. */
|
||||
async function loadFilterOptions(): Promise<void> {
|
||||
const [cats, sites] = await Promise.all([
|
||||
api.get<{ member?: Array<{ code: string, name: string }> }>(
|
||||
'/categories',
|
||||
// Taxonomie multi-types : le filtre du repertoire prestataires ne
|
||||
// propose que les categories de type PRESTATAIRE.
|
||||
{ pagination: 'false', typeCode: 'PRESTATAIRE' },
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
),
|
||||
api.get<{ member?: Array<{ id: number, name: string }> }>(
|
||||
'/sites',
|
||||
{ pagination: 'false' },
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
),
|
||||
])
|
||||
|
||||
categoryOptions.value = (cats.member ?? []).map(c => ({ value: c.code, label: c.name }))
|
||||
siteOptions.value = (sites.member ?? []).map(s => ({ value: String(s.id), label: s.name }))
|
||||
}
|
||||
|
||||
// ── Export XLSX ─────────────────────────────────────────────────────────────
|
||||
// Memes filtres que la vue. La colonne SIREN n'est dans le fichier que si
|
||||
// l'utilisateur a accounting.view (gere cote back).
|
||||
const exporting = ref(false)
|
||||
|
||||
async function exportXlsx(): Promise<void> {
|
||||
if (exporting.value) {
|
||||
return
|
||||
}
|
||||
exporting.value = true
|
||||
try {
|
||||
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
|
||||
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
|
||||
// contenu faute d'overload blob sur le client partage — meme approche que
|
||||
// l'export fournisseurs.
|
||||
const blob = await api.get<Blob>('/providers/export.xlsx', buildFilterPayload(), {
|
||||
responseType: 'blob',
|
||||
toast: false,
|
||||
} as unknown as Parameters<typeof api.get>[2])
|
||||
|
||||
triggerDownload(blob, 'repertoire-prestataires.xlsx')
|
||||
}
|
||||
catch {
|
||||
toast.error({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: t('technique.providers.toast.exportError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Declenche le telechargement d'un blob via un lien temporaire. */
|
||||
function triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProviders()
|
||||
// Echec du chargement des referentiels non bloquant : la liste s'affiche,
|
||||
// l'utilisateur perd juste les options de filtre.
|
||||
loadFilterOptions().catch(() => {
|
||||
categoryOptions.value = []
|
||||
siteOptions.value = []
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* Colonne Sites uniquement (3e colonne : companyName, categories, SITES,
|
||||
* lastActivity) : ses badges rendent la cellule trop haute. On reduit le padding
|
||||
* vertical de SON td (16px Malio -> 8px) sans toucher les autres colonnes ni les
|
||||
* couleurs/tailles (qui restent sur les defauts Malio).
|
||||
*/
|
||||
:deep(.providers-table tbody td:nth-child(3)) {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,535 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete : retour vers le repertoire + titre. -->
|
||||
<div class="flex items-center gap-3 pt-11">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('technique.providers.form.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- ── Formulaire principal (pre-onglets) ─────────────────────────────
|
||||
Sans validation de ce bloc, les onglets restent inaccessibles. Au
|
||||
succes du POST, les champs passent en lecture seule et on bascule
|
||||
automatiquement sur l'onglet Contact (PAS d'onglet Information au M3).
|
||||
Selecteur de site present ici (RG-3.03, relation directe). -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="main.companyName"
|
||||
:label="t('technique.providers.form.main.companyName')"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:error="mainErrors.errors.companyName"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="main.categoryIris"
|
||||
:options="referentials.categories.value"
|
||||
:label="t('technique.providers.form.main.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="mainLocked"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.categories"
|
||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="main.siteIris"
|
||||
:options="referentials.sites.value"
|
||||
:label="t('technique.providers.form.main.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="mainLocked"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.sites"
|
||||
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!mainLocked" class="mt-12 flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.form.submit')"
|
||||
:disabled="mainSubmitting"
|
||||
@click="submitMain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Onglets a validation incrementale ─────────────────────────────
|
||||
Onglet Contact actif (ERP-142) ; Adresse / Comptabilite arrivent aux
|
||||
tickets ERP-143 / 144 : placeholders « A venir » pour l'instant. -->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||
<!-- Onglet Contact : saisie multi-contacts (blocs ajoutables). -->
|
||||
<template #contact>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||
bloc enregistre. -->
|
||||
<ProviderContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="isValidated('contact')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
/>
|
||||
<div v-if="!isValidated('contact')" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.form.contact.add')"
|
||||
:disabled="!canAddContact"
|
||||
@click="addContact"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.form.submit')"
|
||||
:disabled="tabSubmitting || providerId === null"
|
||||
@click="onSubmitContacts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Onglet Adresse : saisie multi-adresses (blocs ajoutables). -->
|
||||
<template #address>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<ProviderAddressBlock
|
||||
v-for="(address, index) in addresses"
|
||||
:key="index"
|
||||
:model-value="address"
|
||||
:category-options="referentials.categories.value"
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="isValidated('address')"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
@degraded="onAddressDegraded"
|
||||
/>
|
||||
<div v-if="!isValidated('address')" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.form.address.add')"
|
||||
:disabled="!canAddAddress"
|
||||
@click="addAddress"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.form.submit')"
|
||||
:disabled="tabSubmitting || providerId === null"
|
||||
@click="onSubmitAddresses"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Onglet Comptabilite (present uniquement si accounting.view ; editable si manage). -->
|
||||
<template v-if="canAccountingView" #accounting>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="accounting.siren"
|
||||
:label="t('technique.providers.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.siren"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.accountNumber"
|
||||
:label="t('technique.providers.form.accounting.accountNumber')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.accountNumber"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="referentials.tvaModes.value"
|
||||
:label="t('technique.providers.form.accounting.tvaMode')"
|
||||
:readonly="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.tvaMode"
|
||||
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.nTva"
|
||||
:label="t('technique.providers.form.accounting.nTva')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.nTva"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="referentials.paymentDelays.value"
|
||||
:label="t('technique.providers.form.accounting.paymentDelay')"
|
||||
:readonly="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentDelay"
|
||||
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="referentials.paymentTypes.value"
|
||||
:label="t('technique.providers.form.accounting.paymentType')"
|
||||
:readonly="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentType"
|
||||
@update:model-value="onPaymentTypeChange"
|
||||
/>
|
||||
<!-- Banque : visible et obligatoire seulement si VIREMENT (RG-3.07). -->
|
||||
<MalioSelect
|
||||
v-if="isBankRequired"
|
||||
:model-value="accounting.bankIri"
|
||||
:options="referentials.banks.value"
|
||||
:label="t('technique.providers.form.accounting.bank')"
|
||||
:readonly="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.bank"
|
||||
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08). -->
|
||||
<div
|
||||
v-for="(rib, index) in visibleRibs"
|
||||
:key="index"
|
||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
|
||||
@click="askRemoveRib(index)"
|
||||
/>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('technique.providers.form.accounting.ribLabel')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.label"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.bic"
|
||||
:label="t('technique.providers.form.accounting.ribBic')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.bic"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.iban"
|
||||
:label="t('technique.providers.form.accounting.ribIban')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.iban"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
v-if="isRibRequired"
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.form.accounting.addRib')"
|
||||
:disabled="!canAddRib"
|
||||
@click="addRib"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.form.submit')"
|
||||
:disabled="tabSubmitting || providerId === null"
|
||||
@click="onSubmitAccounting"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MalioTabList>
|
||||
|
||||
<!-- Modal de confirmation generique (suppression d'un bloc contact). -->
|
||||
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ t('technique.providers.form.confirmDelete.title') }}</h2>
|
||||
</template>
|
||||
<p>{{ confirmModal.message }}</p>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('technique.providers.form.confirmDelete.cancel')"
|
||||
@click="confirmModal.open = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
button-class="flex-1"
|
||||
:label="t('technique.providers.form.confirmDelete.confirm')"
|
||||
@click="runConfirm"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useProviderReferentials, type RefOption } from '~/modules/technique/composables/useProviderReferentials'
|
||||
import { useProviderForm } from '~/modules/technique/composables/useProviderForm'
|
||||
import {
|
||||
isBankRequiredForPaymentType,
|
||||
isRibRequiredForPaymentType,
|
||||
} from '~/modules/technique/utils/forms/providerAccounting'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||
|
||||
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('technique.providers.form.title') })
|
||||
|
||||
// Gating de la route : la creation est reservee a `manage` (POST /providers garde
|
||||
// manage seul — Compta ne cree pas). Compta (accounting seul) et Usine sont
|
||||
// rediriges vers le repertoire.
|
||||
if (!can('technique.providers.manage')) {
|
||||
await navigateTo('/providers')
|
||||
}
|
||||
|
||||
const referentials = useProviderReferentials()
|
||||
|
||||
const {
|
||||
main,
|
||||
providerId,
|
||||
mainLocked,
|
||||
mainSubmitting,
|
||||
mainErrors,
|
||||
canAccountingView,
|
||||
tabKeys,
|
||||
activeTab,
|
||||
unlockedIndex,
|
||||
submitMain,
|
||||
tabSubmitting,
|
||||
isValidated,
|
||||
contacts,
|
||||
contactErrors,
|
||||
canAddContact,
|
||||
addContact,
|
||||
removeContact,
|
||||
submitContacts,
|
||||
addresses,
|
||||
addressErrors,
|
||||
canAddAddress,
|
||||
addAddress,
|
||||
removeAddress,
|
||||
submitAddresses,
|
||||
accounting,
|
||||
ribs,
|
||||
accountingErrors,
|
||||
ribErrors,
|
||||
accountingReadonly,
|
||||
setPaymentType,
|
||||
canAddRib,
|
||||
addRib,
|
||||
removeRib,
|
||||
submitAccounting,
|
||||
} = useProviderForm()
|
||||
|
||||
/** Retour vers le repertoire prestataires (fleche d'en-tete). */
|
||||
function goBack(): void {
|
||||
router.push('/providers')
|
||||
}
|
||||
|
||||
/**
|
||||
* Message d'erreur a afficher dans un toast a partir d'une erreur d'API. Retourne
|
||||
* TOUJOURS une chaine (le composant de toast plante sur `undefined`).
|
||||
*/
|
||||
function apiErrorMessage(error: unknown): string {
|
||||
const data = (error as { response?: { _data?: unknown } })?.response?._data
|
||||
return extractApiErrorMessage(data) || t('technique.providers.toast.error')
|
||||
}
|
||||
|
||||
// Dernier onglet REMPLISSABLE par le role : tabKeys exclut deja la Comptabilite
|
||||
// si l'user n'a pas accounting.view. Sa validation cloture l'ajout (redirection).
|
||||
const lastFillableTab = computed(() => tabKeys.value[tabKeys.value.length - 1])
|
||||
|
||||
/**
|
||||
* Apres validation d'un onglet (creation) : si c'est le dernier onglet du role,
|
||||
* l'ajout est termine -> toast final + retour au repertoire (miroir M1/M2) ; sinon
|
||||
* toast de mise a jour (l'onglet suivant a deja ete deverrouille par completeTab).
|
||||
*/
|
||||
function onTabSaved(key: string): void {
|
||||
if (key === lastFillableTab.value) {
|
||||
toast.success({ title: t('technique.providers.toast.addComplete') })
|
||||
router.push('/providers')
|
||||
return
|
||||
}
|
||||
toast.success({ title: t('technique.providers.toast.updateSuccess') })
|
||||
}
|
||||
|
||||
// ── Onglet Contact ──────────────────────────────────────────────────────────
|
||||
/** Valide l'onglet Contact ; redirige si c'est le dernier onglet du role. */
|
||||
async function onSubmitContacts(): Promise<void> {
|
||||
const ok = await submitContacts(error => toast.error({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: apiErrorMessage(error),
|
||||
}))
|
||||
if (ok) {
|
||||
onTabSaved('contact')
|
||||
}
|
||||
}
|
||||
|
||||
function askRemoveContact(index: number): void {
|
||||
askConfirm(t('technique.providers.form.confirmDelete.contact'), () => removeContact(index))
|
||||
}
|
||||
|
||||
// ── Onglet Adresse ────────────────────────────────────────────────────────────
|
||||
// Contacts deja persistes (IRI non nul), rattachables a une adresse (M2M). Le
|
||||
// libelle reprend le nom complet, a defaut l'email.
|
||||
const contactOptions = computed<RefOption[]>(() =>
|
||||
contacts.value
|
||||
.filter(c => c.iri !== null)
|
||||
.map(c => ({
|
||||
value: c.iri as string,
|
||||
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? ''),
|
||||
})),
|
||||
)
|
||||
|
||||
// Pays : France garantie en tete meme si /countries echoue (resilience ERP-102),
|
||||
// pour rester preselectionnable par defaut sur chaque adresse.
|
||||
const countryOptions = computed<RefOption[]>(() => {
|
||||
const list = referentials.countries.value
|
||||
return list.some(c => c.value === 'France')
|
||||
? list
|
||||
: [{ value: 'France', label: 'France' }, ...list]
|
||||
})
|
||||
|
||||
const addressDegradedNotified = ref(false)
|
||||
|
||||
/** Avertit une seule fois quand l'autocompletion d'adresse bascule en degrade (RG-3.06). */
|
||||
function onAddressDegraded(): void {
|
||||
if (addressDegradedNotified.value) {
|
||||
return
|
||||
}
|
||||
addressDegradedNotified.value = true
|
||||
toast.warning({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: t('technique.providers.form.address.degraded'),
|
||||
})
|
||||
}
|
||||
|
||||
/** Valide l'onglet Adresse ; redirige si c'est le dernier onglet du role. */
|
||||
async function onSubmitAddresses(): Promise<void> {
|
||||
const ok = await submitAddresses(error => toast.error({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: apiErrorMessage(error),
|
||||
}))
|
||||
if (ok) {
|
||||
onTabSaved('address')
|
||||
}
|
||||
}
|
||||
|
||||
function askRemoveAddress(index: number): void {
|
||||
askConfirm(t('technique.providers.form.confirmDelete.address'), () => removeAddress(index))
|
||||
}
|
||||
|
||||
// ── Onglet Comptabilite ───────────────────────────────────────────────────────
|
||||
// Code stable du type de reglement selectionne (pour RG-3.07 / RG-3.08).
|
||||
const selectedPaymentTypeCode = computed(() =>
|
||||
referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null,
|
||||
)
|
||||
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
|
||||
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
|
||||
|
||||
// Les blocs RIB ne sont affiches que pour une LCR (RG-3.08).
|
||||
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
|
||||
|
||||
/** Changement de type de reglement : propage les RG inter-champs (banque / RIB). */
|
||||
function onPaymentTypeChange(value: string | number | null): void {
|
||||
const iri = value === null ? null : String(value)
|
||||
const code = referentials.paymentTypes.value.find(p => p.value === iri)?.code ?? null
|
||||
setPaymentType(iri, isBankRequiredForPaymentType(code), isRibRequiredForPaymentType(code))
|
||||
}
|
||||
|
||||
function askRemoveRib(index: number): void {
|
||||
askConfirm(t('technique.providers.form.confirmDelete.rib'), () => removeRib(index))
|
||||
}
|
||||
|
||||
/** Valide l'onglet Comptabilite ; redirige si c'est le dernier onglet du role. */
|
||||
async function onSubmitAccounting(): Promise<void> {
|
||||
const ok = await submitAccounting(
|
||||
isBankRequired.value,
|
||||
isRibRequired.value,
|
||||
error => toast.error({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: apiErrorMessage(error),
|
||||
}),
|
||||
)
|
||||
if (ok) {
|
||||
onTabSaved('accounting')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modal de confirmation generique ─────────────────────────────────────────
|
||||
const confirmModal = reactive({
|
||||
open: false,
|
||||
message: '',
|
||||
action: null as null | (() => void),
|
||||
})
|
||||
|
||||
function askConfirm(message: string, action: () => void): void {
|
||||
confirmModal.message = message
|
||||
confirmModal.action = action
|
||||
confirmModal.open = true
|
||||
}
|
||||
|
||||
function runConfirm(): void {
|
||||
confirmModal.action?.()
|
||||
confirmModal.action = null
|
||||
confirmModal.open = false
|
||||
}
|
||||
|
||||
// Icone (Iconify) affichee dans l'onglet, par cle.
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
contact: 'mdi:account-box-plus-outline',
|
||||
address: 'mdi:map-marker-outline',
|
||||
accounting: 'mdi:bank-circle-outline',
|
||||
}
|
||||
|
||||
// Onglets desactives tant que le formulaire principal n'est pas valide
|
||||
// (unlockedIndex = -1 au depart) ; deverrouillage progressif ensuite.
|
||||
const tabs = computed(() => tabKeys.value.map((key, index) => ({
|
||||
key,
|
||||
label: t(`technique.providers.tab.${key}`),
|
||||
icon: TAB_ICONS[key],
|
||||
disabled: index > unlockedIndex.value,
|
||||
})))
|
||||
|
||||
onMounted(() => {
|
||||
// Echec du chargement des referentiels non bloquant : les selects restent vides.
|
||||
referentials.loadMain().catch(() => {})
|
||||
// Referentiels comptables charges uniquement si l'onglet est accessible.
|
||||
if (canAccountingView.value) {
|
||||
referentials.loadAccounting().catch(() => {})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Types « brouillon » de l'ecran « Ajouter un prestataire » (M3 Technique).
|
||||
*
|
||||
* Miroir reduit de `types/supplierForm.ts` (M2) : le M3 n'a PAS d'onglet
|
||||
* Information, et porte en plus un selecteur de site SUR le formulaire principal
|
||||
* (RG-3.03 — relation directe `provider.sites`, distincte des sites d'adresse).
|
||||
*
|
||||
* Ces interfaces decrivent l'etat LOCAL du formulaire (refs Vue), distinct des
|
||||
* DTO de l'API : la page de creation (ERP-141) et — a venir — les blocs d'onglet
|
||||
* Contact / Adresse / Comptabilite (ERP-142 → 144) les partagent.
|
||||
*
|
||||
* Les relations M2M (categories, sites) sont portees par leurs IRI Hydra (`@id`),
|
||||
* envoyees telles quelles dans le payload POST (cf. contrat back ERP-139 :
|
||||
* `categories: ['/api/categories/{id}']`, `sites: ['/api/sites/{id}']`).
|
||||
*/
|
||||
|
||||
/** Etat « plat » du formulaire principal (groupe `provider:write:main`). */
|
||||
export interface ProviderMainDraft {
|
||||
/** Nom de l'entreprise prestataire. UPPERCASE serveur (RG-3.11), unicite RG-3.10. */
|
||||
companyName: string | null
|
||||
/** IRI des categories rattachees (M2M, type PRESTATAIRE — RG-3.09 ; >= 1). */
|
||||
categoryIris: string[]
|
||||
/** IRI des sites rattaches DIRECTEMENT au prestataire (M2M `provider_site`, RG-3.03 ; >= 1). */
|
||||
siteIris: string[]
|
||||
}
|
||||
|
||||
/** Fabrique un formulaire principal vierge. */
|
||||
export function emptyProviderMain(): ProviderMainDraft {
|
||||
return {
|
||||
companyName: null,
|
||||
categoryIris: [],
|
||||
siteIris: [],
|
||||
}
|
||||
}
|
||||
|
||||
/** Reponse minimale du POST /providers exploitee par l'ecran de creation. */
|
||||
export interface ProviderMainResponse {
|
||||
id: number
|
||||
/** Nom renvoye normalise (UPPERCASE) par le serveur, reaffiche en lecture seule. */
|
||||
companyName: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Un contact du prestataire (onglet Contact, ERP-142). Miroir de
|
||||
* `SupplierContactFormDraft` (M2). Tous les champs sont nullable cote ORM ; la
|
||||
* validite (RG-3.04) tient a la presence d'AU MOINS un champ rempli parmi
|
||||
* prenom / nom / fonction / telephone principal / email (cf. back).
|
||||
*/
|
||||
export interface ProviderContactFormDraft {
|
||||
/** Id serveur une fois le contact cree (null tant que non persiste). */
|
||||
id: number | null
|
||||
/** IRI Hydra du contact cree — servira au rattachement M2M cote adresse (ERP-143). */
|
||||
iri: string | null
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
jobTitle: string | null
|
||||
phonePrimary: string | null
|
||||
phoneSecondary: string | null
|
||||
email: string | null
|
||||
/** UI : le 2e numero a ete revele via le bouton « + » (max 2 telephones). */
|
||||
hasSecondaryPhone: boolean
|
||||
}
|
||||
|
||||
/** Fabrique un contact vierge. */
|
||||
export function emptyProviderContact(): ProviderContactFormDraft {
|
||||
return {
|
||||
id: null,
|
||||
iri: null,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
jobTitle: null,
|
||||
phonePrimary: null,
|
||||
phoneSecondary: null,
|
||||
email: null,
|
||||
hasSecondaryPhone: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Reponse du POST /providers/{id}/contacts (groupe provider:item:read + IRI Hydra). */
|
||||
export interface ProviderContactResponse {
|
||||
'@id'?: string
|
||||
id: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Une adresse du prestataire (onglet Adresse, ERP-143). Version SIMPLIFIEE de
|
||||
* `SupplierAddressFormDraft` (M2) : PAS de type d'adresse (Prospect/Depart/Rendu),
|
||||
* PAS de bennes, PAS de prestation de triage. Champs postaux + M2M sites /
|
||||
* categories / contacts (par IRI).
|
||||
*/
|
||||
export interface ProviderAddressFormDraft {
|
||||
/** Id serveur une fois l'adresse creee (null tant que non persistee). */
|
||||
id: number | null
|
||||
/** Pays (chaine libre, defaut « France »). */
|
||||
country: string
|
||||
postalCode: string | null
|
||||
city: string | null
|
||||
street: string | null
|
||||
streetComplement: string | null
|
||||
/** IRI des categories rattachees (type PRESTATAIRE, RG-3.09 ; >= 1). */
|
||||
categoryIris: string[]
|
||||
/** IRI des sites rattaches a l'adresse (M2M `provider_address_site`, RG-3.05 ; >= 1). */
|
||||
siteIris: string[]
|
||||
/** IRI des contacts rattaches (= blocs Contact deja persistes de l'onglet Contact). */
|
||||
contactIris: string[]
|
||||
}
|
||||
|
||||
/** Fabrique une adresse vierge (France presaisi). */
|
||||
export function emptyProviderAddress(): ProviderAddressFormDraft {
|
||||
return {
|
||||
id: null,
|
||||
country: 'France',
|
||||
postalCode: null,
|
||||
city: null,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
categoryIris: [],
|
||||
siteIris: [],
|
||||
contactIris: [],
|
||||
}
|
||||
}
|
||||
|
||||
/** Reponse du POST /providers/{id}/addresses (id suffisant pour le suivi cote front). */
|
||||
export interface ProviderAddressResponse {
|
||||
id: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Etat « plat » de l'onglet Comptabilite (groupe `provider:write:accounting`).
|
||||
* Relations (TVA / delai / type de reglement / banque) portees par leur IRI.
|
||||
*/
|
||||
export interface ProviderAccountingDraft {
|
||||
siren: string | null
|
||||
accountNumber: string | null
|
||||
tvaModeIri: string | null
|
||||
nTva: string | null
|
||||
paymentDelayIri: string | null
|
||||
paymentTypeIri: string | null
|
||||
/** Banque : requise et envoyee uniquement si Type de reglement = VIREMENT (RG-3.07). */
|
||||
bankIri: string | null
|
||||
}
|
||||
|
||||
/** Fabrique un onglet Comptabilite vierge. */
|
||||
export function emptyProviderAccounting(): ProviderAccountingDraft {
|
||||
return {
|
||||
siren: null,
|
||||
accountNumber: null,
|
||||
tvaModeIri: null,
|
||||
nTva: null,
|
||||
paymentDelayIri: null,
|
||||
paymentTypeIri: null,
|
||||
bankIri: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Un RIB du prestataire (sous-collection comptable, obligatoire si Type = LCR — RG-3.08). */
|
||||
export interface ProviderRibFormDraft {
|
||||
id: number | null
|
||||
label: string | null
|
||||
bic: string | null
|
||||
iban: string | null
|
||||
}
|
||||
|
||||
/** Fabrique un RIB vierge. */
|
||||
export function emptyProviderRib(): ProviderRibFormDraft {
|
||||
return {
|
||||
id: null,
|
||||
label: null,
|
||||
bic: null,
|
||||
iban: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Reponse du POST /providers/{id}/ribs (id suffisant pour le suivi cote front). */
|
||||
export interface ProviderRibResponse {
|
||||
id: number
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
buildProviderAccountingPayload,
|
||||
buildProviderRibPayload,
|
||||
isBankRequiredForPaymentType,
|
||||
isRibBlank,
|
||||
isRibComplete,
|
||||
isRibRequiredForPaymentType,
|
||||
} from '../providerAccounting'
|
||||
import { emptyProviderAccounting, emptyProviderRib } from '~/modules/technique/types/providerForm'
|
||||
|
||||
/**
|
||||
* Helpers purs de l'onglet Comptabilite prestataire (ERP-144) : RG inter-champs
|
||||
* RG-3.07 (banque si VIREMENT) / RG-3.08 (RIB si LCR) + construction des payloads.
|
||||
*/
|
||||
describe('providerAccounting helpers', () => {
|
||||
describe('RG-3.07 / RG-3.08 — type de reglement', () => {
|
||||
it('banque requise uniquement pour VIREMENT', () => {
|
||||
expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true)
|
||||
expect(isBankRequiredForPaymentType('LCR')).toBe(false)
|
||||
expect(isBankRequiredForPaymentType('CHEQUE')).toBe(false)
|
||||
expect(isBankRequiredForPaymentType(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('RIB requis uniquement pour LCR', () => {
|
||||
expect(isRibRequiredForPaymentType('LCR')).toBe(true)
|
||||
expect(isRibRequiredForPaymentType('VIREMENT')).toBe(false)
|
||||
expect(isRibRequiredForPaymentType(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRibBlank / isRibComplete', () => {
|
||||
it('un RIB vierge est vide et incomplet', () => {
|
||||
expect(isRibBlank(emptyProviderRib())).toBe(true)
|
||||
expect(isRibComplete(emptyProviderRib())).toBe(false)
|
||||
})
|
||||
|
||||
it('un RIB partiel n\'est ni vide ni complet', () => {
|
||||
const rib = { ...emptyProviderRib(), iban: 'FR76...' }
|
||||
expect(isRibBlank(rib)).toBe(false)
|
||||
expect(isRibComplete(rib)).toBe(false)
|
||||
})
|
||||
|
||||
it('un RIB avec libelle + BIC + IBAN est complet', () => {
|
||||
const rib = { ...emptyProviderRib(), label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }
|
||||
expect(isRibComplete(rib)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildProviderAccountingPayload (RG-3.07)', () => {
|
||||
it('envoie la banque si requise (VIREMENT)', () => {
|
||||
const payload = buildProviderAccountingPayload({
|
||||
...emptyProviderAccounting(),
|
||||
paymentTypeIri: '/api/payment_types/3',
|
||||
bankIri: '/api/banks/2',
|
||||
}, true)
|
||||
expect(payload.bank).toBe('/api/banks/2')
|
||||
expect(payload.paymentType).toBe('/api/payment_types/3')
|
||||
})
|
||||
|
||||
it('force la banque a null si non requise (hors VIREMENT)', () => {
|
||||
const payload = buildProviderAccountingPayload({
|
||||
...emptyProviderAccounting(),
|
||||
bankIri: '/api/banks/2',
|
||||
}, false)
|
||||
expect(payload.bank).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildProviderRibPayload', () => {
|
||||
it('omet les champs requis vides (NotBlank back joue sur le champ)', () => {
|
||||
const payload = buildProviderRibPayload(emptyProviderRib())
|
||||
expect(payload).not.toHaveProperty('label')
|
||||
expect(payload).not.toHaveProperty('bic')
|
||||
expect(payload).not.toHaveProperty('iban')
|
||||
})
|
||||
|
||||
it('conserve les champs remplis', () => {
|
||||
const payload = buildProviderRibPayload({ ...emptyProviderRib(), label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' })
|
||||
expect(payload).toEqual({ label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
buildProviderAddressPayload,
|
||||
isProviderAddressValid,
|
||||
} from '../providerAddress'
|
||||
import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
|
||||
|
||||
/**
|
||||
* Helpers purs de l'onglet Adresse prestataire (ERP-143). RG-3.05 (>= 1 site) et
|
||||
* construction du payload de sous-ressource (relations en IRI, requis vides omis,
|
||||
* pas de type d'adresse / bennes / triage — difference M2).
|
||||
*/
|
||||
describe('providerAddress helpers', () => {
|
||||
const SITE = '/api/sites/1'
|
||||
const CAT = '/api/categories/7'
|
||||
|
||||
describe('isProviderAddressValid (RG-3.05 / RG-3.09)', () => {
|
||||
it('false sans site', () => {
|
||||
const address = { ...emptyProviderAddress(), categoryIris: [CAT] }
|
||||
expect(isProviderAddressValid(address)).toBe(false)
|
||||
})
|
||||
|
||||
it('false sans categorie', () => {
|
||||
const address = { ...emptyProviderAddress(), siteIris: [SITE] }
|
||||
expect(isProviderAddressValid(address)).toBe(false)
|
||||
})
|
||||
|
||||
it('true avec au moins un site ET une categorie', () => {
|
||||
const address = { ...emptyProviderAddress(), siteIris: [SITE], categoryIris: [CAT] }
|
||||
expect(isProviderAddressValid(address)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildProviderAddressPayload', () => {
|
||||
it('mappe les relations en IRI et n\'embarque PAS type/bennes/triage (difference M2)', () => {
|
||||
const payload = buildProviderAddressPayload({
|
||||
...emptyProviderAddress(),
|
||||
postalCode: '86100',
|
||||
city: 'Châtellerault',
|
||||
street: '1 rue du Test',
|
||||
siteIris: [SITE],
|
||||
categoryIris: [CAT],
|
||||
contactIris: ['/api/provider_contacts/9'],
|
||||
})
|
||||
expect(payload).toEqual({
|
||||
country: 'France',
|
||||
postalCode: '86100',
|
||||
city: 'Châtellerault',
|
||||
street: '1 rue du Test',
|
||||
streetComplement: null,
|
||||
categories: [CAT],
|
||||
sites: [SITE],
|
||||
contacts: ['/api/provider_contacts/9'],
|
||||
})
|
||||
expect(payload).not.toHaveProperty('addressType')
|
||||
expect(payload).not.toHaveProperty('bennes')
|
||||
expect(payload).not.toHaveProperty('triageProvider')
|
||||
})
|
||||
|
||||
it('omet les scalaires requis vides (NotBlank back joue sur le champ)', () => {
|
||||
const payload = buildProviderAddressPayload({
|
||||
...emptyProviderAddress(),
|
||||
siteIris: [SITE],
|
||||
categoryIris: [CAT],
|
||||
})
|
||||
expect(payload).not.toHaveProperty('postalCode')
|
||||
expect(payload).not.toHaveProperty('city')
|
||||
expect(payload).not.toHaveProperty('street')
|
||||
// streetComplement n'est PAS requis -> reste present a null.
|
||||
expect(payload).toHaveProperty('streetComplement', null)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
buildProviderContactPayload,
|
||||
hasAtLeastOneFilledContact,
|
||||
isProviderContactBlank,
|
||||
isProviderContactNamed,
|
||||
} from '../providerContact'
|
||||
import { emptyProviderContact } from '~/modules/technique/types/providerForm'
|
||||
|
||||
/**
|
||||
* Helpers purs de l'onglet Contact prestataire (ERP-142). On verifie la
|
||||
* definition de « bloc vide » (RG-3.04, alignee sur le back) et la construction
|
||||
* du payload de sous-ressource.
|
||||
*/
|
||||
describe('providerContact helpers', () => {
|
||||
describe('isProviderContactBlank (RG-3.04)', () => {
|
||||
it('un bloc vierge est vide', () => {
|
||||
expect(isProviderContactBlank(emptyProviderContact())).toBe(true)
|
||||
})
|
||||
|
||||
it('un seul champ rempli parmi nom/prenom/fonction/tel/email suffit a le rendre non vide', () => {
|
||||
for (const field of ['firstName', 'lastName', 'jobTitle', 'phonePrimary', 'email'] as const) {
|
||||
const contact = { ...emptyProviderContact(), [field]: 'x' }
|
||||
expect(isProviderContactBlank(contact)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('ignore les espaces (trim) — un champ blanc ne compte pas', () => {
|
||||
expect(isProviderContactBlank({ ...emptyProviderContact(), lastName: ' ' })).toBe(true)
|
||||
})
|
||||
|
||||
it('un 2e telephone seul NE suffit PAS (exclu, comme le back)', () => {
|
||||
const contact = { ...emptyProviderContact(), hasSecondaryPhone: true, phoneSecondary: '0102030405' }
|
||||
expect(isProviderContactBlank(contact)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isProviderContactNamed (RG-3.04 — prenom OU nom)', () => {
|
||||
it('vrai avec un prenom seul ou un nom seul', () => {
|
||||
expect(isProviderContactNamed({ ...emptyProviderContact(), firstName: 'Jean' })).toBe(true)
|
||||
expect(isProviderContactNamed({ ...emptyProviderContact(), lastName: 'Dupont' })).toBe(true)
|
||||
})
|
||||
|
||||
it('faux si seuls fonction / telephone / email sont remplis (ne suffit pas)', () => {
|
||||
expect(isProviderContactNamed({ ...emptyProviderContact(), jobTitle: 'Directeur' })).toBe(false)
|
||||
expect(isProviderContactNamed({ ...emptyProviderContact(), email: 'a@b.fr' })).toBe(false)
|
||||
expect(isProviderContactNamed({ ...emptyProviderContact(), phonePrimary: '0102030405' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAtLeastOneFilledContact (RG-3.12 — au moins un contact nomme)', () => {
|
||||
it('false si aucun bloc n\'est nomme', () => {
|
||||
expect(hasAtLeastOneFilledContact([emptyProviderContact(), { ...emptyProviderContact(), email: 'a@b.fr' }])).toBe(false)
|
||||
})
|
||||
|
||||
it('true des qu\'un bloc porte un nom ou prenom', () => {
|
||||
expect(hasAtLeastOneFilledContact([
|
||||
emptyProviderContact(),
|
||||
{ ...emptyProviderContact(), lastName: 'Dupont' },
|
||||
])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildProviderContactPayload', () => {
|
||||
it('mappe les champs et envoie null pour les vides', () => {
|
||||
const payload = buildProviderContactPayload({ ...emptyProviderContact(), lastName: 'Doe' })
|
||||
expect(payload).toEqual({
|
||||
firstName: null,
|
||||
lastName: 'Doe',
|
||||
jobTitle: null,
|
||||
phonePrimary: null,
|
||||
phoneSecondary: null,
|
||||
email: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('n\'envoie le 2e telephone que si revele (max 2)', () => {
|
||||
const masque = buildProviderContactPayload({
|
||||
...emptyProviderContact(),
|
||||
phoneSecondary: '0102030405',
|
||||
hasSecondaryPhone: false,
|
||||
})
|
||||
expect(masque.phoneSecondary).toBeNull()
|
||||
|
||||
const revele = buildProviderContactPayload({
|
||||
...emptyProviderContact(),
|
||||
phoneSecondary: '0102030405',
|
||||
hasSecondaryPhone: true,
|
||||
})
|
||||
expect(revele.phoneSecondary).toBe('0102030405')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// formatPhoneFR est auto-importe dans le helper via le chemin partage ; on le mocke
|
||||
// pour un rendu deterministe (la mise en forme exacte est testee ailleurs).
|
||||
vi.mock('~/shared/utils/phone', () => ({
|
||||
formatPhoneFR: (v: string) => `fmt(${v})`,
|
||||
}))
|
||||
|
||||
const {
|
||||
canEditProvider,
|
||||
categoryOptionsOf,
|
||||
contactOptionsOf,
|
||||
iriOf,
|
||||
irisOf,
|
||||
mapAccountingDraft,
|
||||
mapAddressToDraft,
|
||||
mapContactToDraft,
|
||||
mapRibToDraft,
|
||||
paymentTypeCodeOf,
|
||||
referentialOptionOf,
|
||||
showArchiveAction,
|
||||
showRestoreAction,
|
||||
siteOptionsOf,
|
||||
} = await import('../providerDetail')
|
||||
|
||||
/**
|
||||
* Helpers purs des ecrans Consultation / Modification (ERP-145) : mapping du
|
||||
* detail embarque vers les brouillons + regles d'affichage des actions (Modifier /
|
||||
* Archiver / Restaurer).
|
||||
*/
|
||||
describe('providerDetail helpers', () => {
|
||||
describe('iriOf / irisOf', () => {
|
||||
it('extrait l\'IRI d\'un objet embarque, d\'un IRI nu, ou null', () => {
|
||||
expect(iriOf({ '@id': '/api/banks/2' })).toBe('/api/banks/2')
|
||||
expect(iriOf('/api/banks/2')).toBe('/api/banks/2')
|
||||
expect(iriOf(null)).toBeNull()
|
||||
expect(iriOf(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('extrait les IRI d\'une collection embarquee', () => {
|
||||
expect(irisOf([{ '@id': '/api/sites/1' }, { '@id': '/api/sites/2' }])).toEqual(['/api/sites/1', '/api/sites/2'])
|
||||
expect(irisOf(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapContactToDraft', () => {
|
||||
it('mappe les champs, formate les telephones et derive hasSecondaryPhone', () => {
|
||||
const draft = mapContactToDraft({
|
||||
'@id': '/api/provider_contacts/5',
|
||||
id: 5,
|
||||
firstName: 'Jean',
|
||||
lastName: 'Dupont',
|
||||
phonePrimary: '0102030405',
|
||||
phoneSecondary: '0607080910',
|
||||
email: 'jean@x.fr',
|
||||
})
|
||||
expect(draft).toMatchObject({
|
||||
id: 5,
|
||||
iri: '/api/provider_contacts/5',
|
||||
firstName: 'Jean',
|
||||
lastName: 'Dupont',
|
||||
phonePrimary: 'fmt(0102030405)',
|
||||
phoneSecondary: 'fmt(0607080910)',
|
||||
email: 'jean@x.fr',
|
||||
hasSecondaryPhone: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('hasSecondaryPhone faux sans 2e numero', () => {
|
||||
const draft = mapContactToDraft({ '@id': '/api/provider_contacts/6', id: 6, lastName: 'Doe' })
|
||||
expect(draft.hasSecondaryPhone).toBe(false)
|
||||
expect(draft.phoneSecondary).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapAddressToDraft', () => {
|
||||
it('extrait les IRI des sites / categories / contacts embarques', () => {
|
||||
const draft = mapAddressToDraft({
|
||||
'@id': '/api/provider_addresses/3',
|
||||
id: 3,
|
||||
country: 'France',
|
||||
postalCode: '86100',
|
||||
city: 'Châtellerault',
|
||||
street: '1 rue du Test',
|
||||
sites: [{ '@id': '/api/sites/1' }],
|
||||
categories: [{ '@id': '/api/categories/7' }],
|
||||
contacts: [{ '@id': '/api/provider_contacts/5' }, '/api/provider_contacts/6'],
|
||||
})
|
||||
expect(draft.siteIris).toEqual(['/api/sites/1'])
|
||||
expect(draft.categoryIris).toEqual(['/api/categories/7'])
|
||||
expect(draft.contactIris).toEqual(['/api/provider_contacts/5', '/api/provider_contacts/6'])
|
||||
expect(draft.id).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapAccountingDraft / mapRibToDraft', () => {
|
||||
it('mappe les scalaires et les IRI des referentiels embarques', () => {
|
||||
const draft = mapAccountingDraft({
|
||||
'@id': '/api/providers/9',
|
||||
id: 9,
|
||||
siren: '123456789',
|
||||
accountNumber: '4010',
|
||||
nTva: 'FR123',
|
||||
tvaMode: { '@id': '/api/tva_modes/1', label: 'TVA' },
|
||||
paymentType: { '@id': '/api/payment_types/3', code: 'VIREMENT' },
|
||||
bank: { '@id': '/api/banks/2' },
|
||||
})
|
||||
expect(draft.tvaModeIri).toBe('/api/tva_modes/1')
|
||||
expect(draft.paymentTypeIri).toBe('/api/payment_types/3')
|
||||
expect(draft.bankIri).toBe('/api/banks/2')
|
||||
expect(draft.paymentDelayIri).toBeNull()
|
||||
expect(draft.siren).toBe('123456789')
|
||||
})
|
||||
|
||||
it('mappe un RIB embarque', () => {
|
||||
expect(mapRibToDraft({ '@id': '/api/provider_ribs/1', id: 1, label: 'Compte', bic: 'BIC', iban: 'IBAN' }))
|
||||
.toEqual({ id: 1, label: 'Compte', bic: 'BIC', iban: 'IBAN' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('options builders (libelles role-independants depuis l\'embed)', () => {
|
||||
it('categoryOptionsOf / siteOptionsOf / contactOptionsOf', () => {
|
||||
expect(categoryOptionsOf([{ '@id': '/api/categories/7', name: 'Maintenance', code: 'MAINT' }]))
|
||||
.toEqual([{ value: '/api/categories/7', label: 'Maintenance' }])
|
||||
expect(siteOptionsOf([{ '@id': '/api/sites/1', name: 'Châtellerault' }]))
|
||||
.toEqual([{ value: '/api/sites/1', label: 'Châtellerault' }])
|
||||
expect(contactOptionsOf([{ '@id': '/api/provider_contacts/5', id: 5, firstName: 'Jean', lastName: 'Dupont' }]))
|
||||
.toEqual([{ value: '/api/provider_contacts/5', label: 'Jean Dupont' }])
|
||||
})
|
||||
|
||||
it('referentialOptionOf / paymentTypeCodeOf', () => {
|
||||
expect(referentialOptionOf({ '@id': '/api/banks/2', label: 'SG' }))
|
||||
.toEqual([{ value: '/api/banks/2', label: 'SG' }])
|
||||
expect(referentialOptionOf(null)).toEqual([])
|
||||
expect(referentialOptionOf('/api/banks/2')).toEqual([])
|
||||
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/3', code: 'LCR' })).toBe('LCR')
|
||||
expect(paymentTypeCodeOf(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('actions selon permissions', () => {
|
||||
/** Fabrique un `can` qui n'autorise que les codes fournis. */
|
||||
const canFor = (granted: string[]) => (code: string) => granted.includes(code)
|
||||
const canAnyFor = (granted: string[]) => (codes: string[]) => codes.some(c => granted.includes(c))
|
||||
|
||||
it('« Modifier » visible avec manage OU accounting.manage (Compta inclus)', () => {
|
||||
expect(canEditProvider(canAnyFor(['technique.providers.manage']))).toBe(true)
|
||||
expect(canEditProvider(canAnyFor(['technique.providers.accounting.manage']))).toBe(true)
|
||||
expect(canEditProvider(canAnyFor(['technique.providers.view']))).toBe(false)
|
||||
})
|
||||
|
||||
it('« Archiver » visible seulement avec archive ET prestataire actif (Admin seul)', () => {
|
||||
const admin = canFor(['technique.providers.archive'])
|
||||
const bureau = canFor(['technique.providers.manage'])
|
||||
expect(showArchiveAction(admin, false)).toBe(true)
|
||||
expect(showArchiveAction(admin, true)).toBe(false) // deja archive -> Restaurer
|
||||
expect(showArchiveAction(bureau, false)).toBe(false) // pas la permission archive
|
||||
})
|
||||
|
||||
it('« Restaurer » visible seulement avec archive ET prestataire archive', () => {
|
||||
const admin = canFor(['technique.providers.archive'])
|
||||
expect(showRestoreAction(admin, true)).toBe(true)
|
||||
expect(showRestoreAction(admin, false)).toBe(false)
|
||||
expect(showRestoreAction(canFor([]), true)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Helpers purs de l'onglet Comptabilite prestataire (M3 Technique, ERP-144) —
|
||||
* miroir SIMPLIFIE des regles M2, reimplemente cote module Technique (regle
|
||||
* ABSOLUE n°1 : pas d'import inter-module). Portent les RG inter-champs RG-3.07
|
||||
* (banque si VIREMENT) et RG-3.08 (RIB si LCR), testables sans Vue ni API.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ProviderAccountingDraft,
|
||||
ProviderRibFormDraft,
|
||||
} from '~/modules/technique/types/providerForm'
|
||||
|
||||
/** Code pivot du type de reglement imposant une banque (RG-3.07). */
|
||||
const PAYMENT_TYPE_VIREMENT = 'VIREMENT'
|
||||
/** Code pivot du type de reglement imposant au moins un RIB (RG-3.08). */
|
||||
const PAYMENT_TYPE_LCR = 'LCR'
|
||||
|
||||
/** Champs RIB obligatoires non nullable cote back (NotBlank) — omis si vides au POST. */
|
||||
const RIB_REQUIRED_NON_NULLABLE_KEYS = ['label', 'bic', 'iban'] as const
|
||||
|
||||
/** Vrai si une chaine porte au moins un caractere non-espace. */
|
||||
function isFilled(value: string | null | undefined): boolean {
|
||||
return value !== null && value !== undefined && value.trim() !== ''
|
||||
}
|
||||
|
||||
/** RG-3.07 : la banque n'est requise/visible que pour un reglement par VIREMENT. */
|
||||
export function isBankRequiredForPaymentType(code: string | null | undefined): boolean {
|
||||
return code === PAYMENT_TYPE_VIREMENT
|
||||
}
|
||||
|
||||
/** RG-3.08 : au moins un RIB n'est requis que pour un reglement par LCR. */
|
||||
export function isRibRequiredForPaymentType(code: string | null | undefined): boolean {
|
||||
return code === PAYMENT_TYPE_LCR
|
||||
}
|
||||
|
||||
/** Vrai si AUCUN champ du bloc RIB n'est rempli (amorce vide a ignorer au submit). */
|
||||
export function isRibBlank(rib: ProviderRibFormDraft): boolean {
|
||||
return ![rib.label, rib.bic, rib.iban].some(isFilled)
|
||||
}
|
||||
|
||||
/** Vrai si les 3 champs du RIB sont remplis (gating « + RIB »). */
|
||||
export function isRibComplete(rib: ProviderRibFormDraft): boolean {
|
||||
return isFilled(rib.label) && isFilled(rib.bic) && isFilled(rib.iban)
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload du PATCH comptable (groupe `provider:write:accounting`). Les relations
|
||||
* sont en IRI ; la banque n'est envoyee que si elle est requise (RG-3.07), sinon
|
||||
* `null` (le back vide la relation hors VIREMENT).
|
||||
*/
|
||||
export function buildProviderAccountingPayload(
|
||||
accounting: ProviderAccountingDraft,
|
||||
isBankRequired: boolean,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
siren: accounting.siren || null,
|
||||
accountNumber: accounting.accountNumber || null,
|
||||
tvaMode: accounting.tvaModeIri,
|
||||
nTva: accounting.nTva || null,
|
||||
paymentDelay: accounting.paymentDelayIri,
|
||||
paymentType: accounting.paymentTypeIri,
|
||||
bank: isBankRequired ? accounting.bankIri : null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload d'un RIB (sous-ressource, groupe `provider:write:accounting`). Les
|
||||
* champs requis vides sont omis a la creation pour que la 422 NotBlank porte sur
|
||||
* le champ.
|
||||
*/
|
||||
export function buildProviderRibPayload(rib: ProviderRibFormDraft): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
label: rib.label,
|
||||
bic: rib.bic,
|
||||
iban: rib.iban,
|
||||
}
|
||||
|
||||
for (const key of RIB_REQUIRED_NON_NULLABLE_KEYS) {
|
||||
const value = payload[key]
|
||||
if (value === null || value === undefined || value === '') {
|
||||
delete payload[key]
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Helpers purs de l'onglet Adresse prestataire (M3 Technique, ERP-143) — miroir
|
||||
* SIMPLIFIE de `supplierFormRules`/`supplierEdit` (M2), reimplemente cote module
|
||||
* Technique (regle ABSOLUE n°1 : pas d'import inter-module). Testables sans Vue.
|
||||
*/
|
||||
|
||||
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
|
||||
|
||||
/**
|
||||
* Champs scalaires obligatoires non nullable cote back (NotBlank). A la creation
|
||||
* (POST), on OMET du payload ceux qui sont vides pour que la 422 porte la
|
||||
* violation NotBlank propre (sur le champ) plutot qu'une erreur de type.
|
||||
*/
|
||||
const REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const
|
||||
|
||||
/**
|
||||
* RG-3.05 (+ RG-3.09) : une adresse est « valide » pour autoriser l'ajout d'un
|
||||
* nouveau bloc des qu'elle porte au moins un site ET au moins une categorie. Les
|
||||
* scalaires (CP/ville/rue) restent valides par le back (422 inline).
|
||||
*/
|
||||
export function isProviderAddressValid(address: ProviderAddressFormDraft): boolean {
|
||||
return address.siteIris.length >= 1 && address.categoryIris.length >= 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload de la sous-ressource addresses (groupe `provider:write:addresses`).
|
||||
* Relations M2M en IRI. Les scalaires requis vides sont omis a la creation (cf.
|
||||
* REQUIRED_NON_NULLABLE_KEYS).
|
||||
*/
|
||||
export function buildProviderAddressPayload(address: ProviderAddressFormDraft): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
country: address.country,
|
||||
postalCode: address.postalCode || null,
|
||||
city: address.city || null,
|
||||
street: address.street || null,
|
||||
streetComplement: address.streetComplement || null,
|
||||
categories: [...address.categoryIris],
|
||||
sites: [...address.siteIris],
|
||||
contacts: [...address.contactIris],
|
||||
}
|
||||
|
||||
for (const key of REQUIRED_NON_NULLABLE_KEYS) {
|
||||
const value = payload[key]
|
||||
if (value === null || value === undefined || value === '') {
|
||||
delete payload[key]
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Helpers purs de l'onglet Contact prestataire (M3 Technique, ERP-142) — miroir
|
||||
* reduit de `supplierFormRules.ts` / `supplierEdit.ts` (M2). Testables sans Vue
|
||||
* ni API : detection de bloc vide (RG-3.04) et construction du payload de
|
||||
* sous-ressource contacts.
|
||||
*/
|
||||
|
||||
import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm'
|
||||
|
||||
/** Vrai si une chaine porte au moins un caractere non-espace. */
|
||||
function isFilled(value: string | null | undefined): boolean {
|
||||
return value !== null && value !== undefined && value.trim() !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.04 : un bloc Contact est VIDE tant qu'aucun des champs comptant pour la
|
||||
* validite n'est rempli — prenom / nom / fonction / telephone principal / email.
|
||||
*
|
||||
* `phoneSecondary` est volontairement EXCLU : le back (CHECK
|
||||
* `chk_provider_contact_name` + `ProviderContactProcessor`) ne le compte pas non
|
||||
* plus, un bloc ne portant qu'un 2e numero reste invalide. Garder la meme
|
||||
* definition cote front evite tout drift (un bloc « vide » front == bloc rejete
|
||||
* back).
|
||||
*/
|
||||
export function isProviderContactBlank(contact: ProviderContactFormDraft): boolean {
|
||||
return ![
|
||||
contact.firstName,
|
||||
contact.lastName,
|
||||
contact.jobTitle,
|
||||
contact.phonePrimary,
|
||||
contact.email,
|
||||
].some(isFilled)
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.04 : un contact est « nomme » (valide) des qu'il porte un prenom OU un nom
|
||||
* — aligne sur le M1/M2. Sert le gating « + Nouveau contact » et la notion de
|
||||
* contact valide (la fonction / le telephone / l'email seuls ne suffisent pas).
|
||||
*/
|
||||
export function isProviderContactNamed(contact: ProviderContactFormDraft): boolean {
|
||||
return isFilled(contact.firstName) || isFilled(contact.lastName)
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.12 : l'onglet Contact ne peut etre finalise que s'il reste au moins un
|
||||
* contact nomme (prenom ou nom).
|
||||
*/
|
||||
export function hasAtLeastOneFilledContact(contacts: ProviderContactFormDraft[]): boolean {
|
||||
return contacts.some(isProviderContactNamed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload de la sous-ressource contacts (groupe `provider:write:contacts`). Les
|
||||
* chaines vides sont envoyees a null (le serveur normalise/trim de toute facon).
|
||||
* `phoneSecondary` n'est envoye que si le 2e numero a ete revele (max 2 tel).
|
||||
*/
|
||||
export function buildProviderContactPayload(contact: ProviderContactFormDraft): Record<string, unknown> {
|
||||
return {
|
||||
firstName: contact.firstName || null,
|
||||
lastName: contact.lastName || null,
|
||||
jobTitle: contact.jobTitle || null,
|
||||
phonePrimary: contact.phonePrimary || null,
|
||||
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
|
||||
email: contact.email || null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Helpers purs des ecrans Consultation / Modification prestataire (M3 Technique,
|
||||
* ERP-145) — miroir SIMPLIFIE de `supplierConsultation.ts` (M2). Mappent le payload
|
||||
* `GET /api/providers/{id}` (relations embarquees, cf. groupes `provider:item:read`
|
||||
* + `provider:read:accounting`) vers les brouillons « plats » partages avec
|
||||
* `ProviderContactBlock` / `ProviderAddressBlock` et l'onglet Comptabilite.
|
||||
*
|
||||
* Ne touchent ni a l'API ni a l'etat reactif (testables unitairement).
|
||||
*
|
||||
* Rappels de contrat back (JSON reel fige — ERP-139, spec-back § 4.0.bis) :
|
||||
* - categories / sites du prestataire et des adresses : OBJETS embarques (avec @id) ;
|
||||
* - refs comptables (tvaMode/paymentDelay/paymentType/bank) : OBJETS embarques
|
||||
* `{@id, id, label, (code pour paymentType)}` ;
|
||||
* - champs nuls OMIS (skip_null_values) → toujours lire avec `?? null` ;
|
||||
* - champs comptables + `ribs` TOTALEMENT ABSENTS sans permission accounting.view.
|
||||
*
|
||||
* Differences M2 : pas de type d'adresse / bennes / triage, pas d'onglet Information.
|
||||
*/
|
||||
|
||||
import { formatPhoneFR } from '~/shared/utils/phone'
|
||||
import type {
|
||||
ProviderAccountingDraft,
|
||||
ProviderAddressFormDraft,
|
||||
ProviderContactFormDraft,
|
||||
ProviderRibFormDraft,
|
||||
} from '~/modules/technique/types/providerForm'
|
||||
import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
|
||||
|
||||
/** Reference Hydra embarquee minimale (@id toujours present). */
|
||||
export interface HydraRef {
|
||||
'@id': string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Une relation peut etre embarquee (objet), un IRI nu (chaine) ou absente. */
|
||||
export type Relation = HydraRef | string | null | undefined
|
||||
|
||||
/** Site embarque (groupe site:read). */
|
||||
export interface SiteRead extends HydraRef {
|
||||
name?: string
|
||||
postalCode?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
/** Categorie embarquee (groupe category:read). */
|
||||
export interface CategoryRead extends HydraRef {
|
||||
code?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
/** Contact embarque (groupe provider:item:read). */
|
||||
export interface ContactRead extends HydraRef {
|
||||
id: number
|
||||
firstName?: string | null
|
||||
lastName?: string | null
|
||||
jobTitle?: string | null
|
||||
phonePrimary?: string | null
|
||||
phoneSecondary?: string | null
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
/** Adresse embarquee (groupe provider:item:read) — version simplifiee M3. */
|
||||
export interface AddressRead extends HydraRef {
|
||||
id: number
|
||||
country?: string | null
|
||||
postalCode?: string | null
|
||||
city?: string | null
|
||||
street?: string | null
|
||||
streetComplement?: string | null
|
||||
sites?: SiteRead[]
|
||||
categories?: CategoryRead[]
|
||||
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
|
||||
contacts?: Array<HydraRef | string>
|
||||
}
|
||||
|
||||
/** RIB embarque (groupe provider:read:accounting, present ssi accounting.view). */
|
||||
export interface RibRead extends HydraRef {
|
||||
id: number
|
||||
label?: string | null
|
||||
bic?: string | null
|
||||
iban?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail d'un prestataire (`GET /api/providers/{id}`). Tous les champs sont
|
||||
* optionnels : skip_null_values + gating accounting peuvent omettre n'importe
|
||||
* quelle cle.
|
||||
*/
|
||||
export interface ProviderDetail extends HydraRef {
|
||||
id: number
|
||||
companyName?: string | null
|
||||
isArchived?: boolean
|
||||
categories?: CategoryRead[]
|
||||
sites?: SiteRead[]
|
||||
contacts?: ContactRead[]
|
||||
addresses?: AddressRead[]
|
||||
ribs?: RibRead[]
|
||||
// Onglet Comptabilite (present ssi accounting.view)
|
||||
siren?: string | null
|
||||
accountNumber?: string | null
|
||||
nTva?: string | null
|
||||
tvaMode?: Relation
|
||||
paymentDelay?: Relation
|
||||
paymentType?: Relation
|
||||
bank?: Relation
|
||||
}
|
||||
|
||||
/** Extrait l'IRI d'une relation (objet embarque, IRI nu, ou null si absente). */
|
||||
export function iriOf(relation: Relation): string | null {
|
||||
if (relation === null || relation === undefined) {
|
||||
return null
|
||||
}
|
||||
if (typeof relation === 'string') {
|
||||
return relation
|
||||
}
|
||||
return relation['@id'] ?? null
|
||||
}
|
||||
|
||||
/** IRI des elements d'une collection embarquee (categories / sites du prestataire). */
|
||||
export function irisOf(items: HydraRef[] | undefined): string[] {
|
||||
return (items ?? []).map(i => i['@id'])
|
||||
}
|
||||
|
||||
/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */
|
||||
export function mapContactToDraft(contact: ContactRead): ProviderContactFormDraft {
|
||||
const phoneSecondary = contact.phoneSecondary ?? null
|
||||
return {
|
||||
id: contact.id,
|
||||
iri: contact['@id'] ?? null,
|
||||
firstName: contact.firstName ?? null,
|
||||
lastName: contact.lastName ?? null,
|
||||
jobTitle: contact.jobTitle ?? null,
|
||||
phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
|
||||
phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
|
||||
email: contact.email ?? null,
|
||||
hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe une adresse embarquee vers un brouillon (IRI extraits des sous-collections). */
|
||||
export function mapAddressToDraft(address: AddressRead): ProviderAddressFormDraft {
|
||||
return {
|
||||
id: address.id,
|
||||
country: address.country ?? 'France',
|
||||
postalCode: address.postalCode ?? null,
|
||||
city: address.city ?? null,
|
||||
street: address.street ?? null,
|
||||
streetComplement: address.streetComplement ?? null,
|
||||
categoryIris: (address.categories ?? []).map(c => c['@id']),
|
||||
siteIris: (address.sites ?? []).map(s => s['@id']),
|
||||
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe un RIB embarque vers un brouillon. */
|
||||
export function mapRibToDraft(rib: RibRead): ProviderRibFormDraft {
|
||||
return {
|
||||
id: rib.id,
|
||||
label: rib.label ?? null,
|
||||
bic: rib.bic ?? null,
|
||||
iban: rib.iban ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe les champs comptables (scalaires + IRI des referentiels embarques). */
|
||||
export function mapAccountingDraft(provider: ProviderDetail): ProviderAccountingDraft {
|
||||
return {
|
||||
siren: provider.siren ?? null,
|
||||
accountNumber: provider.accountNumber ?? null,
|
||||
nTva: provider.nTva ?? null,
|
||||
tvaModeIri: iriOf(provider.tvaMode),
|
||||
paymentDelayIri: iriOf(provider.paymentDelay),
|
||||
paymentTypeIri: iriOf(provider.paymentType),
|
||||
bankIri: iriOf(provider.bank),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options de categories (value=IRI, label=nom) construites depuis l'embed.
|
||||
* Source role-independante : evite de dependre de `GET /categories` (403 possible
|
||||
* pour un role metier), qui laisserait les libelles vides en consultation.
|
||||
*/
|
||||
export function categoryOptionsOf(categories: CategoryRead[] | undefined): RefOption[] {
|
||||
return (categories ?? []).map(c => ({
|
||||
value: c['@id'],
|
||||
label: c.name ?? c.code ?? c['@id'],
|
||||
}))
|
||||
}
|
||||
|
||||
/** Options de sites (value=IRI, label=nom) construites depuis un embed. */
|
||||
export function siteOptionsOf(sites: SiteRead[] | undefined): RefOption[] {
|
||||
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] }))
|
||||
}
|
||||
|
||||
/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed prestataire. */
|
||||
export function contactOptionsOf(contacts: ContactRead[] | undefined): RefOption[] {
|
||||
return (contacts ?? []).map(c => ({
|
||||
value: c['@id'],
|
||||
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? c['@id']),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste a une seule option (ou vide) construite depuis un referentiel embarque
|
||||
* (TvaMode / PaymentDelay / PaymentType / Bank) pour alimenter un MalioSelect en
|
||||
* lecture seule. Le libelle vient de l'embed, jamais d'un GET de referentiel —
|
||||
* l'affichage reste correct quel que soit le role.
|
||||
*/
|
||||
export function referentialOptionOf(relation: Relation): RefOption[] {
|
||||
if (!relation || typeof relation === 'string') {
|
||||
return []
|
||||
}
|
||||
const label = (relation.label as string | undefined)
|
||||
?? (relation.name as string | undefined)
|
||||
?? relation['@id']
|
||||
return [{ value: relation['@id'], label }]
|
||||
}
|
||||
|
||||
/** Code metier d'un referentiel embarque (PaymentType.code = 'LCR' / 'VIREMENT'), ou null. */
|
||||
export function paymentTypeCodeOf(relation: Relation): string | null {
|
||||
if (!relation || typeof relation === 'string') {
|
||||
return null
|
||||
}
|
||||
return (relation.code as string | undefined) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet —
|
||||
* `manage` (onglets metier) OU `accounting.manage` (le role Compta doit pouvoir
|
||||
* ouvrir l'edition pour son onglet Comptabilite). Le readonly fin par onglet est
|
||||
* gere sur l'ecran d'edition.
|
||||
*/
|
||||
export function canEditProvider(canAny: (codes: string[]) => boolean): boolean {
|
||||
return canAny(['technique.providers.manage', 'technique.providers.accounting.manage'])
|
||||
}
|
||||
|
||||
/** Bouton « Archiver » : permission archive ET prestataire encore actif (Admin seul). */
|
||||
export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
||||
return can('technique.providers.archive') && !isArchived
|
||||
}
|
||||
|
||||
/** Bouton « Restaurer » : permission archive ET prestataire deja archive (Admin seul). */
|
||||
export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
||||
return can('technique.providers.archive') && isArchived
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
Generated
+5
-47
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 erreur→champ 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.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { removeCollectionRow, isRowRemovable, type DeletableRow } from '../collectionRow'
|
||||
|
||||
/**
|
||||
* Tests de `removeCollectionRow` — suppression d'une ligne de collection
|
||||
* (contact / adresse / RIB) avec DELETE immediat de la sous-ressource existante
|
||||
* (ERP-172). Coeur de logique mutualise par les 3 modules (Client / Fournisseur /
|
||||
* Prestataire) : un seul comportement teste ici couvre les 9 cas (3 modules x 3
|
||||
* blocs).
|
||||
*/
|
||||
interface Row extends DeletableRow {
|
||||
label?: string
|
||||
}
|
||||
|
||||
function makeEmpty(): Row {
|
||||
return { id: null, label: '' }
|
||||
}
|
||||
|
||||
describe('removeCollectionRow', () => {
|
||||
it('emet un DELETE sur la sous-ressource quand le bloc est existant (id non null)', async () => {
|
||||
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
|
||||
const errors: Record<string, string>[] = [{}, {}]
|
||||
const deleteRow = vi.fn().mockResolvedValue(undefined)
|
||||
const onError = vi.fn()
|
||||
|
||||
const removed = await removeCollectionRow({
|
||||
rows, errors, index: 0,
|
||||
endpoint: '/client_contacts',
|
||||
deleteRow, makeEmpty, onError,
|
||||
})
|
||||
|
||||
expect(deleteRow).toHaveBeenCalledOnce()
|
||||
expect(deleteRow).toHaveBeenCalledWith('/client_contacts/10')
|
||||
expect(removed).toBe(true)
|
||||
expect(rows).toEqual([{ id: 11, label: 'B' }])
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ne fait AUCUN appel reseau pour un bloc jamais persiste (id null) — retrait local', async () => {
|
||||
const rows: Row[] = [{ id: 10, label: 'A' }, { id: null, label: 'brouillon' }]
|
||||
const errors: Record<string, string>[] = [{}, {}]
|
||||
const deleteRow = vi.fn().mockResolvedValue(undefined)
|
||||
const onError = vi.fn()
|
||||
|
||||
const removed = await removeCollectionRow({
|
||||
rows, errors, index: 1,
|
||||
endpoint: '/client_contacts',
|
||||
deleteRow, makeEmpty, onError,
|
||||
})
|
||||
|
||||
expect(deleteRow).not.toHaveBeenCalled()
|
||||
expect(removed).toBe(true)
|
||||
expect(rows).toEqual([{ id: 10, label: 'A' }])
|
||||
})
|
||||
|
||||
it('conserve le bloc et remonte l\'erreur si le DELETE serveur echoue (ex. 409 dernier RIB LCR)', async () => {
|
||||
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
|
||||
const errors: Record<string, string>[] = [{}, {}]
|
||||
const error = { response: { status: 409 } }
|
||||
const deleteRow = vi.fn().mockRejectedValue(error)
|
||||
const onError = vi.fn()
|
||||
|
||||
const removed = await removeCollectionRow({
|
||||
rows, errors, index: 0,
|
||||
endpoint: '/client_ribs',
|
||||
deleteRow, makeEmpty, onError,
|
||||
})
|
||||
|
||||
expect(removed).toBe(false)
|
||||
expect(onError).toHaveBeenCalledWith(error)
|
||||
// Bloc NON retire : la suppression n'a pas ete confirmee par le serveur.
|
||||
expect(rows).toEqual([{ id: 10, label: 'A' }, { id: 11, label: 'B' }])
|
||||
expect(errors).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('garde au moins un bloc visible apres retrait du dernier (amorce vide)', async () => {
|
||||
const rows: Row[] = [{ id: 10, label: 'A' }]
|
||||
const errors: Record<string, string>[] = [{}]
|
||||
const deleteRow = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await removeCollectionRow({
|
||||
rows, errors, index: 0,
|
||||
endpoint: '/client_contacts',
|
||||
deleteRow, makeEmpty, onError: vi.fn(),
|
||||
})
|
||||
|
||||
expect(rows).toEqual([{ id: null, label: '' }])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests de `isRowRemovable` — la poubelle d'un bloc n'apparait que s'il reste un
|
||||
* AUTRE bloc deja enregistre (id en base). Empeche de supprimer un bloc tant que
|
||||
* rien n'est sauvegarde, et de supprimer son dernier bloc enregistre (ERP-172).
|
||||
*/
|
||||
describe('isRowRemovable', () => {
|
||||
it('faux quand aucun autre bloc n\'est enregistre (que des brouillons)', () => {
|
||||
const rows: Row[] = [{ id: null, label: 'brouillon 1' }, { id: null, label: 'brouillon 2' }]
|
||||
expect(isRowRemovable(rows, 0)).toBe(false)
|
||||
expect(isRowRemovable(rows, 1)).toBe(false)
|
||||
})
|
||||
|
||||
it('faux pour le seul bloc enregistre (un brouillon a cote ne compte pas)', () => {
|
||||
const rows: Row[] = [{ id: 10, label: 'enregistre' }, { id: null, label: 'brouillon' }]
|
||||
// Le bloc enregistre ne peut pas etre supprime : aucun AUTRE bloc enregistre.
|
||||
expect(isRowRemovable(rows, 0)).toBe(false)
|
||||
// Le brouillon peut etre jete : il reste le bloc enregistre id=10.
|
||||
expect(isRowRemovable(rows, 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('vrai pour chaque bloc des qu\'au moins deux sont enregistres', () => {
|
||||
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
|
||||
expect(isRowRemovable(rows, 0)).toBe(true)
|
||||
expect(isRowRemovable(rows, 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('faux pour un unique bloc', () => {
|
||||
expect(isRowRemovable([{ id: 10, label: 'A' }], 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -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 :
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/** Ligne de collection supprimable (contact / adresse / RIB). */
|
||||
export interface DeletableRow {
|
||||
id?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Indique si le bloc d'index `index` peut afficher sa poubelle (ERP-172).
|
||||
*
|
||||
* Regle metier : on ne peut supprimer un bloc QUE s'il reste au moins un AUTRE
|
||||
* bloc deja enregistre (`id` non null, donc persiste en base). Consequences :
|
||||
* - tant que rien n'est enregistre -> aucune poubelle (pas de suppression d'un
|
||||
* simple brouillon saisi mais pas valide) ;
|
||||
* - on peut jeter un brouillon non enregistre s'il reste un bloc enregistre ;
|
||||
* - on ne peut jamais supprimer son dernier bloc enregistre.
|
||||
*/
|
||||
export function isRowRemovable<T extends DeletableRow>(rows: T[], index: number): boolean {
|
||||
return rows.some((row, i) => i !== index && row.id != null)
|
||||
}
|
||||
|
||||
/** Options de {@link removeCollectionRow}. */
|
||||
export interface RemoveCollectionRowOptions<T extends DeletableRow> {
|
||||
/** Tableau reactif des brouillons (passer le `.value` de la ref). */
|
||||
rows: T[]
|
||||
/** Tableau reactif des erreurs par ligne, aligne sur l'index (passer le `.value`). */
|
||||
errors: Record<string, string>[]
|
||||
/** Index de la ligne a retirer. */
|
||||
index: number
|
||||
/** Endpoint de la sous-ressource SANS id (ex: '/client_contacts'). */
|
||||
endpoint: string
|
||||
/** Suppression serveur : DOIT rejeter en cas d'echec (ex: url => api.delete(url, {}, { toast: false })). */
|
||||
deleteRow: (url: string) => Promise<unknown>
|
||||
/** Fabrique d'un bloc vide pour garder au moins un bloc visible apres retrait. */
|
||||
makeEmpty: () => T
|
||||
/** Remontee d'erreur 409/422 mappee proprement (message back, pas de toast fourre-tout). */
|
||||
onError: (error: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire une ligne de collection (contact / adresse / RIB) sur les ecrans de
|
||||
* MODIFICATION, avec DELETE immediat de la sous-ressource (ERP-172). Comportement
|
||||
* aligne sur les 3 modules (Client / Fournisseur / Prestataire) :
|
||||
*
|
||||
* - Bloc jamais persiste (`id` null) : simple retrait local, aucun appel reseau.
|
||||
* - Bloc existant (`id` non null) : DELETE `/endpoint/{id}` AVANT le retrait du
|
||||
* tableau. On ne retire le bloc QUE si le serveur a confirme — sinon le bloc
|
||||
* reste affiche et l'erreur est remontee via `onError` (ex. dernier RIB d'une
|
||||
* LCR -> 409 back, RG-x.08).
|
||||
*
|
||||
* Etat purement local : `rows`/`errors` sont les `.value` des refs (proxies
|
||||
* reactifs), le `splice` declenche donc la reactivite.
|
||||
*
|
||||
* @returns `true` si la ligne a ete retiree (suppression confirmee ou bloc local),
|
||||
* `false` si la suppression serveur a echoue (bloc conserve).
|
||||
*/
|
||||
export async function removeCollectionRow<T extends DeletableRow>(
|
||||
options: RemoveCollectionRowOptions<T>,
|
||||
): Promise<boolean> {
|
||||
const { rows, errors, index, endpoint, deleteRow, makeEmpty, onError } = options
|
||||
const removed = rows[index]
|
||||
|
||||
// Bloc existant : suppression serveur d'abord, retrait local seulement si OK.
|
||||
if (removed?.id != null) {
|
||||
try {
|
||||
await deleteRow(`${endpoint}/${removed.id}`)
|
||||
}
|
||||
catch (error) {
|
||||
onError(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
rows.splice(index, 1)
|
||||
errors.splice(index, 1)
|
||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
||||
if (rows.length === 0) {
|
||||
rows.push(makeEmpty())
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -84,12 +84,24 @@ 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',
|
||||
// Technique — Repertoire prestataires (M3, ERP-138). Meme logique que
|
||||
// clients/fournisseurs : mappe sur le persona "tout", pas de nouveau
|
||||
// persona (regle ABSOLUE n°7). user-full porte deja sites.bypass_scope,
|
||||
// donc il voit les prestataires de tous les sites (M3 § 2.13).
|
||||
// technique.providers.view n'ajoute pas de lien dans la section
|
||||
// Administration, donc expectedAdminLinks reste inchange.
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
'technique.providers.accounting.view',
|
||||
'technique.providers.accounting.manage',
|
||||
'technique.providers.archive',
|
||||
// Transport — Repertoire transporteurs (M4, ERP-153). Meme logique :
|
||||
// mappe sur le persona "tout", pas de nouveau persona (regle ABSOLUE
|
||||
// n°7). transport.carriers.view n'ajoute pas de lien dans la section
|
||||
// Administration, donc expectedAdminLinks reste inchange.
|
||||
'transport.carriers.view',
|
||||
'transport.carriers.manage',
|
||||
'transport.carriers.archive',
|
||||
],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||
},
|
||||
|
||||
@@ -231,6 +231,7 @@ test-db-setup:
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_provider_company_name_active ON provider (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
|
||||
fixtures:
|
||||
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
||||
@@ -249,6 +250,22 @@ sync-permissions:
|
||||
seed-rbac:
|
||||
$(SYMFONY_CONSOLE) --no-interaction app:seed-rbac
|
||||
|
||||
# Synchronise le referentiel des transporteurs QUALIMAT (ERP-39) : upsert sur
|
||||
# le SIRET + soft-delete des absents + journal. Idempotent (refresh complet),
|
||||
# prevu pour un cron quotidien.
|
||||
# Options : --dry-run (analyse sans ecriture), --file=<chemin.json> (source
|
||||
# locale au lieu de l'API), --ppp=<n> (taille de page API, defaut 10000).
|
||||
qualimat-sync:
|
||||
$(SYMFONY_CONSOLE) --no-interaction app:qualimat:sync
|
||||
|
||||
# Synchronise le referentiel des codes IDTF (ERP-149) depuis l'export Excel
|
||||
# icrt-idtf.com : upsert sur (schema, idtf_number) + soft-delete + journal.
|
||||
# Idempotent (refresh complet).
|
||||
# Options : --schema=road|water (defaut road), --dry-run (analyse sans
|
||||
# ecriture), --file=<chemin.xlsx> (source locale au lieu du telechargement).
|
||||
idtf-sync:
|
||||
$(SYMFONY_CONSOLE) --no-interaction app:idtf:sync
|
||||
|
||||
# Attention, supprime votre bdd local
|
||||
db-reset:
|
||||
$(DOCKER_COMPOSE) down -v
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* M3 (ticket 1.1) — Taxonomie PRESTATAIRE (module Catalog, prerequis du module Technique).
|
||||
*
|
||||
* Contexte : le M3 (repertoire prestataires) a besoin d'une taxonomie distincte
|
||||
* des types CLIENT (M1) et FOURNISSEUR (M2). Decision Matthieu (11/06) : types
|
||||
* distincts CLIENT / FOURNISSEUR / PRESTATAIRE, chacun avec sa taxonomie. Le
|
||||
* multi-select « Categorie » du prestataire (formulaire principal + adresse)
|
||||
* ne reference que des `Category` rattachees au type PRESTATAIRE (RG-3.09).
|
||||
*
|
||||
* Cette migration :
|
||||
* 1. cree le `category_type` PRESTATAIRE (code PRESTATAIRE, label « Prestataire ») ;
|
||||
* 2. seede 3 `Category` de demonstration rattachees a ce type via la jonction
|
||||
* ManyToMany `category_category_type` (modele courant depuis Version20260608120000 ;
|
||||
* la colonne ManyToOne `category.category_type_id` n'existe plus).
|
||||
*
|
||||
* Aucune colonne creee/modifiee -> pas de `COMMENT ON COLUMN` (regle ABSOLUE n°12) :
|
||||
* la migration ne fait que des INSERT de donnees de reference.
|
||||
*
|
||||
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire :
|
||||
* avec plusieurs migrations_paths, Doctrine Migrations 3.x trie par FQCN
|
||||
* alphabetique -> une migration `App\Module\...` passerait avant les
|
||||
* `DoctrineMigrations\...` sur base vide, donc avant la creation des tables
|
||||
* `category` / `category_type` / `category_category_type`. Le namespace racine
|
||||
* garantit l'ordre par timestamp.
|
||||
*
|
||||
* Idempotence : `INSERT ... ON CONFLICT (code) DO NOTHING` pour le type,
|
||||
* `INSERT ... SELECT ... WHERE NOT EXISTS` pour chaque categorie et chaque ligne
|
||||
* de jonction (aligne sur le pattern ERP-84 / Version20260605120000). En prod la
|
||||
* table `category` est vide (aucune fixture metier). En dev/test, le purger
|
||||
* Doctrine vide `category` / `category_type` avant les fixtures qui reproduisent
|
||||
* le meme etat final (CategoryTypeFixtures / CategoryFixtures etendus a PRESTATAIRE).
|
||||
*/
|
||||
final class Version20260612080000 extends AbstractMigration
|
||||
{
|
||||
/**
|
||||
* Categories de demonstration du type PRESTATAIRE : nom => code stable. Le
|
||||
* code est la cle metier (slug MAJUSCULE du nom, miroir du
|
||||
* CategoryCodeGenerator) et reste unique parmi les actifs (uq_category_code,
|
||||
* partage avec les codes CLIENT / FOURNISSEUR — aucune collision ici). Le nom
|
||||
* est unique GLOBALEMENT parmi les actifs (uq_category_name_active) : les
|
||||
* libelles ci-dessous n'entrent en collision avec aucune categorie seedee.
|
||||
*/
|
||||
private const array PROVIDER_CATEGORIES = [
|
||||
'Maintenance industrielle' => 'MAINTENANCE_INDUSTRIELLE',
|
||||
'Nettoyage' => 'NETTOYAGE',
|
||||
'Transport' => 'TRANSPORT',
|
||||
];
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'M3 1.1 : cree le CategoryType PRESTATAIRE + seed des categories prestataires (Maintenance industrielle, Nettoyage, Transport).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// 1. Type PRESTATAIRE (idempotent via l'index unique uq_category_type_code).
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO category_type (code, label) VALUES ('PRESTATAIRE', 'Prestataire')
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
SQL);
|
||||
|
||||
foreach (self::PROVIDER_CATEGORIES as $name => $code) {
|
||||
// 2a. Categorie sous PRESTATAIRE (si le code est libre parmi les
|
||||
// actifs). created_at/updated_at NOT NULL -> NOW() ; le blame
|
||||
// reste null (seed hors contexte HTTP, libelle « Systeme » cote front).
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO category (name, code, created_at, updated_at)
|
||||
SELECT :name, :code, NOW(), NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM category c WHERE c.code = :code AND c.deleted_at IS NULL
|
||||
)
|
||||
SQL, ['name' => $name, 'code' => $code]);
|
||||
|
||||
// 2b. Jonction M2M categorie <-> type PRESTATAIRE (modele courant).
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO category_category_type (category_id, category_type_id)
|
||||
SELECT c.id, ct.id
|
||||
FROM category c
|
||||
CROSS JOIN category_type ct
|
||||
WHERE c.code = :code AND c.deleted_at IS NULL
|
||||
AND ct.code = 'PRESTATAIRE'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM category_category_type cct
|
||||
WHERE cct.category_id = c.id AND cct.category_type_id = ct.id
|
||||
)
|
||||
SQL, ['code' => $code]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Best-effort : on retire d'abord les categories seedees (par code) — la FK
|
||||
// category_category_type est ON DELETE CASCADE cote category, donc les
|
||||
// lignes de jonction partent avec —, puis le type s'il n'est plus reference.
|
||||
$this->addSql(
|
||||
'DELETE FROM category WHERE code IN (:codes) '
|
||||
."AND id IN (SELECT category_id FROM category_category_type cct "
|
||||
."JOIN category_type ct ON ct.id = cct.category_type_id WHERE ct.code = 'PRESTATAIRE')",
|
||||
['codes' => array_values(self::PROVIDER_CATEGORIES)],
|
||||
['codes' => ArrayParameterType::STRING],
|
||||
);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DELETE FROM category_type
|
||||
WHERE code = 'PRESTATAIRE'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM category_category_type cct WHERE cct.category_type_id = category_type.id
|
||||
)
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* M3 — Repertoire prestataires (ERP-132) : creation de toute la structure BDD
|
||||
* des prestataires sous le nouveau module Technique (jumeau du M2 fournisseur).
|
||||
*
|
||||
* Tables creees :
|
||||
* - Table principale : provider (formulaire principal + Comptabilite + archive
|
||||
* + soft-delete + Timestampable/Blamable). PAS d onglet Information.
|
||||
* - M2M du formulaire principal : provider_category (RG-3.09),
|
||||
* provider_site (sites du prestataire, RG-3.03 — NOUVEAU vs supplier).
|
||||
* - Sous-collections : provider_contact (1:n), provider_address (1:n),
|
||||
* provider_rib (1:n).
|
||||
* - Jointures de provider_address : provider_address_site (RG-3.05),
|
||||
* provider_address_contact, provider_address_category.
|
||||
*
|
||||
* Differences vs le M2 `supplier` (cf. spec M3 § 3.1) :
|
||||
* - PAS d onglet Information : aucun champ description / competitors /
|
||||
* founded_at / employees_count / revenue_amount / director_name /
|
||||
* profit_amount / volume_forecast. Le provider est minimal : nom + compta.
|
||||
* - AJOUT de provider_site (M2M) : sites rattaches au prestataire directement
|
||||
* sur le formulaire principal (RG-3.03, >= 1). Sert aussi le cloisonnement
|
||||
* par site (idx_provider_site_site, § 2.13).
|
||||
* - provider_address SIMPLIFIEE : pas de address_type / bennes /
|
||||
* triage_provider (specifiques fournisseur). Champs : country / postal_code
|
||||
* / city / street / street_complement / position + M2M sites/contacts/categories.
|
||||
*
|
||||
* Referentiels comptables NON recrees : tva_mode / payment_delay / payment_type
|
||||
* / bank sont ceux du M1 (FK partagees, zero duplication — spec § 2.3).
|
||||
*
|
||||
* CategoryType PRESTATAIRE NON re-seede : il est cree par ERP-131
|
||||
* (Version20260612080000) avec ses categories de demonstration. Le M2M
|
||||
* provider_category / provider_address_category s appuie sur ce type existant.
|
||||
*
|
||||
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et NON
|
||||
* `App\Module\Technique\...` : la migration cree un schema avec FK cross-module
|
||||
* (user, category, site, et les referentiels comptables M1). Avec plusieurs
|
||||
* migrations_paths, Doctrine Migrations 3.x trie par FQCN alphabetique — un
|
||||
* namespace modulaire s executerait avant la creation de user/category/site sur
|
||||
* base vide -> echec des FK. Le namespace racine garantit l ordre par timestamp.
|
||||
*
|
||||
* Style DDL aligne sur le M1/M2 (Version20260605130000) : `INT GENERATED BY
|
||||
* DEFAULT AS IDENTITY` (et non SERIAL), `TIMESTAMP(0) WITHOUT TIME ZONE` (et non
|
||||
* TIMESTAMPTZ, car le TimestampableBlamableTrait mappe `datetime_immutable`).
|
||||
* Garantit que `schema:update` restera un no-op quand les entites arriveront
|
||||
* (ticket ERP-133).
|
||||
*
|
||||
* Decision unicite (alignee Q4 M1 / § 2.6 M2) : unicite metier sur le NOM DE
|
||||
* SOCIETE uniquement (uq_provider_company_name_active, partiel). Pas d index
|
||||
* unique sur siren ni email.
|
||||
*
|
||||
* COMMENT ON COLUMN inline (regle ABSOLUE n°12) : chaque colonne metier porte sa
|
||||
* description ici-meme. Volontairement NON ajoutees a `ColumnCommentsCatalog` /
|
||||
* `makefile test-db-setup` a ce stade : tant que les entites Provider* n existent
|
||||
* pas (ERP-133), `schema:update --force` du setup de test droppe ces tables non
|
||||
* mappees — les referencer dans le catalogue ferait planter
|
||||
* `app:apply-column-comments`. Le catalogue + la ligne `dbal:run-sql`
|
||||
* (uq_provider_company_name_active) seront ajoutes au ticket entites (ERP-133),
|
||||
* exactement comme supplier (ERP-86) apres sa migration (ERP-85). Les 4 colonnes
|
||||
* Timestampable/Blamable reutilisent les textes standardises du catalogue
|
||||
* (`timestampableBlamableComments()`, simple tableau statique sans dependance DB).
|
||||
*/
|
||||
final class Version20260612100000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-132 (M3) : tables provider + sous-collections + jointures M2M (referentiels comptables et CategoryType PRESTATAIRE reutilises).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->createProviderTable();
|
||||
$this->createProviderCategory();
|
||||
$this->createProviderSite();
|
||||
$this->createProviderContact();
|
||||
$this->createProviderAddress();
|
||||
$this->createProviderAddressJoinTables();
|
||||
$this->createProviderRib();
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Ordre inverse des dependances FK : jointures et sous-collections
|
||||
// d abord, puis provider. Les referentiels comptables et le
|
||||
// CategoryType PRESTATAIRE ne sont pas touches (crees ailleurs).
|
||||
$this->addSql('DROP TABLE IF EXISTS provider_address_category');
|
||||
$this->addSql('DROP TABLE IF EXISTS provider_address_contact');
|
||||
$this->addSql('DROP TABLE IF EXISTS provider_address_site');
|
||||
$this->addSql('DROP TABLE IF EXISTS provider_rib');
|
||||
$this->addSql('DROP TABLE IF EXISTS provider_address');
|
||||
$this->addSql('DROP TABLE IF EXISTS provider_contact');
|
||||
$this->addSql('DROP TABLE IF EXISTS provider_site');
|
||||
$this->addSql('DROP TABLE IF EXISTS provider_category');
|
||||
$this->addSql('DROP TABLE IF EXISTS provider');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Table principale `provider`
|
||||
// =================================================================
|
||||
|
||||
private function createProviderTable(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE provider (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
company_name VARCHAR(180) NOT NULL,
|
||||
siren VARCHAR(20) DEFAULT NULL,
|
||||
account_number VARCHAR(40) DEFAULT NULL,
|
||||
tva_mode_id INT DEFAULT NULL,
|
||||
n_tva VARCHAR(40) DEFAULT NULL,
|
||||
payment_delay_id INT DEFAULT NULL,
|
||||
payment_type_id INT DEFAULT NULL,
|
||||
bank_id INT DEFAULT NULL,
|
||||
is_archived BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
archived_at TIMESTAMP(0) WITHOUT TIME ZONE 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_provider_tva_mode
|
||||
FOREIGN KEY (tva_mode_id) REFERENCES tva_mode (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_provider_payment_delay
|
||||
FOREIGN KEY (payment_delay_id) REFERENCES payment_delay (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_provider_payment_type
|
||||
FOREIGN KEY (payment_type_id) REFERENCES payment_type (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_provider_bank
|
||||
FOREIGN KEY (bank_id) REFERENCES bank (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_provider_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_provider_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_provider_is_archived ON provider (is_archived)');
|
||||
$this->addSql('CREATE INDEX idx_provider_deleted_at ON provider (deleted_at)');
|
||||
$this->addSql('CREATE INDEX idx_provider_created_by ON provider (created_by)');
|
||||
$this->addSql('CREATE INDEX idx_provider_updated_by ON provider (updated_by)');
|
||||
|
||||
// Index sur les FK des referentiels comptables (Postgres n indexe pas
|
||||
// automatiquement les colonnes portant une FOREIGN KEY).
|
||||
$this->addSql('CREATE INDEX idx_provider_tva_mode_id ON provider (tva_mode_id)');
|
||||
$this->addSql('CREATE INDEX idx_provider_payment_delay_id ON provider (payment_delay_id)');
|
||||
$this->addSql('CREATE INDEX idx_provider_payment_type_id ON provider (payment_type_id)');
|
||||
$this->addSql('CREATE INDEX idx_provider_bank_id ON provider (bank_id)');
|
||||
|
||||
// Unicite metier partielle : nom de societe insensible a la casse, parmi
|
||||
// les non-archives ET non soft-deletes uniquement (RG-3.10). Pas d index
|
||||
// unique sur siren ni email.
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE UNIQUE INDEX uq_provider_company_name_active
|
||||
ON provider (LOWER(company_name))
|
||||
WHERE is_archived = FALSE AND deleted_at IS NULL
|
||||
SQL);
|
||||
|
||||
$this->comment('provider', '_table', 'Repertoire prestataires (M3 Technique) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M4). Pas d onglet Information (≠ supplier).');
|
||||
$this->comment('provider', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('provider', 'company_name', 'Raison sociale du prestataire (stockee en MAJUSCULES). Unique case-insensitive parmi les actifs non archives/non supprimes (uq_provider_company_name_active, RG-3.10).');
|
||||
$this->comment('provider', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-3.10).');
|
||||
$this->comment('provider', 'account_number', 'Onglet Comptabilite : numero de compte comptable du prestataire.');
|
||||
$this->comment('provider', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id (referentiel partage M1), ON DELETE RESTRICT.');
|
||||
$this->comment('provider', 'n_tva', 'Onglet Comptabilite : numero de TVA intracommunautaire.');
|
||||
$this->comment('provider', 'payment_delay_id', 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id (M1), ON DELETE RESTRICT.');
|
||||
$this->comment('provider', 'payment_type_id', 'Onglet Comptabilite : type de reglement — FK -> payment_type.id (M1), ON DELETE RESTRICT. Pilote RG-3.07 (Banque si VIREMENT) et RG-3.08 (RIB).');
|
||||
$this->comment('provider', 'bank_id', 'Onglet Comptabilite : banque — FK -> bank.id (M1), ON DELETE RESTRICT. Obligatoire ssi payment_type = VIREMENT (RG-3.07), null sinon.');
|
||||
$this->comment('provider', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission technique.providers.archive.');
|
||||
$this->comment('provider', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.');
|
||||
$this->comment('provider', 'deleted_at', 'Horodatage du soft-delete technique (HP M4) — non expose par l API au M3. Null = ligne active.');
|
||||
$this->addTimestampableBlamableComments('provider');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// M2M provider <-> category (type PRESTATAIRE — RG-3.09)
|
||||
// =================================================================
|
||||
|
||||
private function createProviderCategory(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE provider_category (
|
||||
provider_id INT NOT NULL,
|
||||
category_id INT NOT NULL,
|
||||
PRIMARY KEY (provider_id, category_id),
|
||||
CONSTRAINT fk_provider_category_provider
|
||||
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_provider_category_category
|
||||
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_provider_category_category ON provider_category (category_id)');
|
||||
|
||||
$this->comment('provider_category', '_table', 'Jointure M2M provider <-> category (Catalog) — categories de type PRESTATAIRE du prestataire, au moins une obligatoire (RG-3.09).');
|
||||
$this->comment('provider_category', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire porteur de la categorie.');
|
||||
$this->comment('provider_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie de type PRESTATAIRE rattachee au prestataire (RG-3.09).');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// M2M provider <-> site (formulaire principal — RG-3.03)
|
||||
// =================================================================
|
||||
|
||||
private function createProviderSite(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE provider_site (
|
||||
provider_id INT NOT NULL,
|
||||
site_id INT NOT NULL,
|
||||
PRIMARY KEY (provider_id, site_id),
|
||||
CONSTRAINT fk_provider_site_provider
|
||||
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_provider_site_site
|
||||
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
// Index sur site_id : sert le filtre de cloisonnement par site
|
||||
// (WHERE site = :currentSite, § 2.13).
|
||||
$this->addSql('CREATE INDEX idx_provider_site_site ON provider_site (site_id)');
|
||||
|
||||
$this->comment('provider_site', '_table', 'Jointure M2M provider <-> site (Sites) — sites du prestataire, selecteur du formulaire principal, au moins un obligatoire (RG-3.03). Sert le cloisonnement par site (§ 2.13).');
|
||||
$this->comment('provider_site', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire porteur du site.');
|
||||
$this->comment('provider_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache au prestataire (RG-3.03, idx_provider_site_site).');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sous-collection : contacts (1:n)
|
||||
// =================================================================
|
||||
|
||||
private function createProviderContact(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE provider_contact (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
provider_id INT NOT NULL,
|
||||
first_name VARCHAR(120) DEFAULT NULL,
|
||||
last_name VARCHAR(120) DEFAULT NULL,
|
||||
job_title VARCHAR(120) DEFAULT NULL,
|
||||
phone_primary VARCHAR(20) DEFAULT NULL,
|
||||
phone_secondary VARCHAR(20) DEFAULT NULL,
|
||||
email VARCHAR(180) DEFAULT NULL,
|
||||
position INT DEFAULT 0 NOT 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 chk_provider_contact_name
|
||||
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL),
|
||||
CONSTRAINT fk_provider_contact_provider
|
||||
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_provider_contact_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_provider_contact_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_provider_contact_provider ON provider_contact (provider_id)');
|
||||
|
||||
$this->comment('provider_contact', '_table', 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/fonction/telephone/email (RG-3.04, chk_provider_contact_name).');
|
||||
$this->comment('provider_contact', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('provider_contact', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.');
|
||||
$this->comment('provider_contact', 'first_name', 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
|
||||
$this->comment('provider_contact', 'last_name', 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
|
||||
$this->comment('provider_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
|
||||
$this->comment('provider_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (normalisation serveur).');
|
||||
$this->comment('provider_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).');
|
||||
$this->comment('provider_contact', 'email', 'Email du contact (lowercase serveur).');
|
||||
$this->comment('provider_contact', 'position', 'Ordre d affichage du contact dans la liste du prestataire (croissant).');
|
||||
$this->addTimestampableBlamableComments('provider_contact');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sous-collection : adresses (1:n) — SANS address_type / bennes / triage
|
||||
// =================================================================
|
||||
|
||||
private function createProviderAddress(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE provider_address (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
provider_id INT NOT NULL,
|
||||
country VARCHAR(80) DEFAULT 'France' NOT NULL,
|
||||
postal_code VARCHAR(20) NOT NULL,
|
||||
city VARCHAR(120) NOT NULL,
|
||||
street VARCHAR(255) NOT NULL,
|
||||
street_complement VARCHAR(255) DEFAULT NULL,
|
||||
position INT DEFAULT 0 NOT 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_provider_address_provider
|
||||
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_provider_address_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_provider_address_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_provider_address_provider ON provider_address (provider_id)');
|
||||
|
||||
$this->comment('provider_address', '_table', 'Adresses d un prestataire (1:n) — >= 1 site rattache (RG-3.05). SANS address_type / bennes / triage_provider (specifiques fournisseur).');
|
||||
$this->comment('provider_address', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('provider_address', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire de l adresse.');
|
||||
$this->comment('provider_address', 'country', 'Pays de l adresse — defaut France.');
|
||||
$this->comment('provider_address', 'postal_code', 'Code postal (4-5 chiffres attendus) — declenche l autocompletion ville via l API BAN cote front (RG-3.06).');
|
||||
$this->comment('provider_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front.');
|
||||
$this->comment('provider_address', 'street', 'Numero et voie de l adresse.');
|
||||
$this->comment('provider_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.');
|
||||
$this->comment('provider_address', 'position', 'Ordre d affichage de l adresse dans la liste du prestataire (croissant).');
|
||||
$this->addTimestampableBlamableComments('provider_address');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Jointures de provider_address (M2M)
|
||||
// =================================================================
|
||||
|
||||
private function createProviderAddressJoinTables(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE provider_address_site (
|
||||
provider_address_id INT NOT NULL,
|
||||
site_id INT NOT NULL,
|
||||
PRIMARY KEY (provider_address_id, site_id),
|
||||
CONSTRAINT fk_provider_address_site_address
|
||||
FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_provider_address_site_site
|
||||
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
$this->comment('provider_address_site', '_table', 'Jointure M2M provider_address <-> site (Sites) — sites rattaches a l adresse (>= 1 obligatoire, RG-3.05).');
|
||||
$this->comment('provider_address_site', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||
$this->comment('provider_address_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE provider_address_contact (
|
||||
provider_address_id INT NOT NULL,
|
||||
provider_contact_id INT NOT NULL,
|
||||
PRIMARY KEY (provider_address_id, provider_contact_id),
|
||||
CONSTRAINT fk_provider_address_contact_address
|
||||
FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_provider_address_contact_contact
|
||||
FOREIGN KEY (provider_contact_id) REFERENCES provider_contact (id) ON DELETE CASCADE
|
||||
)
|
||||
SQL);
|
||||
$this->comment('provider_address_contact', '_table', 'Jointure M2M provider_address <-> provider_contact — contacts associes a une adresse.');
|
||||
$this->comment('provider_address_contact', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||
$this->comment('provider_address_contact', 'provider_contact_id', 'FK -> provider_contact.id, ON DELETE CASCADE — contact associe a l adresse.');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE provider_address_category (
|
||||
provider_address_id INT NOT NULL,
|
||||
category_id INT NOT NULL,
|
||||
PRIMARY KEY (provider_address_id, category_id),
|
||||
CONSTRAINT fk_provider_address_category_address
|
||||
FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_provider_address_category_category
|
||||
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
$this->comment('provider_address_category', '_table', 'Jointure M2M provider_address <-> category — categories d adresse de type PRESTATAIRE (RG-3.09).');
|
||||
$this->comment('provider_address_category', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||
$this->comment('provider_address_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type PRESTATAIRE (RG-3.09).');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sous-collection : RIB (1:n)
|
||||
// =================================================================
|
||||
|
||||
private function createProviderRib(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE provider_rib (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
provider_id INT NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
bic VARCHAR(20) NOT NULL,
|
||||
iban VARCHAR(34) NOT NULL,
|
||||
position INT DEFAULT 0 NOT 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_provider_rib_provider
|
||||
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_provider_rib_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_provider_rib_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_provider_rib_provider ON provider_rib (provider_id)');
|
||||
|
||||
$this->comment('provider_rib', '_table', 'Coordonnees bancaires d un prestataire (1:n) — >= 1 RIB attendu selon le type de reglement (RG-3.08). Tous les champs audites (pas d AuditIgnore).');
|
||||
$this->comment('provider_rib', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('provider_rib', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du RIB.');
|
||||
$this->comment('provider_rib', 'label', 'Libelle du RIB (ex: compte principal).');
|
||||
$this->comment('provider_rib', 'bic', 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).');
|
||||
$this->comment('provider_rib', 'iban', 'IBAN du compte (≤ 34 caracteres).');
|
||||
$this->comment('provider_rib', 'position', 'Ordre d affichage du RIB dans la liste du prestataire (croissant).');
|
||||
$this->addTimestampableBlamableComments('provider_rib');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Helpers
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
|
||||
* en reutilisant le catalogue partage (source unique, cf. ERP-67). Seul le
|
||||
* tableau statique des textes est reutilise — aucune dependance a l etat DB.
|
||||
*/
|
||||
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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ERP-149 (Module Transport) : referentiel des codes IDTF (regimes de nettoyage
|
||||
* transport).
|
||||
*
|
||||
* Tables alimentees par la commande `app:idtf:sync` (parsing de l'export Excel
|
||||
* icrt-idtf.com, upsert sur (schema, idtf_number) + soft-delete + journal).
|
||||
* Aucune FK cross-module : migration au namespace racine `DoctrineMigrations`.
|
||||
*/
|
||||
final class Version20260612160000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-149 : tables idtf_product + idtf_sync_log (referentiel codes IDTF, synchro console depuis l\'export Excel).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE idtf_product (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
idtf_number INTEGER NOT NULL,
|
||||
schema VARCHAR(8) NOT NULL,
|
||||
product_group VARCHAR(255) DEFAULT NULL,
|
||||
name TEXT NOT NULL,
|
||||
cleaning_regime VARCHAR(64) NOT NULL,
|
||||
important_requirements TEXT DEFAULT NULL,
|
||||
mandatory_date DATE DEFAULT NULL,
|
||||
related_products TEXT DEFAULT NULL,
|
||||
formula VARCHAR(255) DEFAULT NULL,
|
||||
eural_code VARCHAR(64) DEFAULT NULL,
|
||||
cas_numbers JSONB DEFAULT '[]' NOT NULL,
|
||||
footnotes TEXT DEFAULT NULL,
|
||||
source_export_date DATE NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
last_synced_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT uq_idtf_product_schema_number UNIQUE (schema, idtf_number),
|
||||
CONSTRAINT chk_idtf_product_schema CHECK (schema IN ('road', 'water'))
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_idtf_product_active ON idtf_product (schema, is_active)');
|
||||
|
||||
$this->comment('idtf_product', '_table', "Referentiel des codes IDTF (marchandise + regime de nettoyage transport), synchronise depuis l'export Excel icrt-idtf.com.");
|
||||
$this->comment('idtf_product', 'id', 'Cle technique auto-incrementee.');
|
||||
$this->comment('idtf_product', 'idtf_number', 'Numero IDTF de la marchandise (identifiant metier source). Unique par schema.');
|
||||
$this->comment('idtf_product', 'schema', "Mode de transport / schema IDTF : 'road' (routier) ou 'water' (fluvial). Discriminant d'unicite avec idtf_number.");
|
||||
$this->comment('idtf_product', 'product_group', "Groupe de produit (colonne Product Group de l'export). Nullable.");
|
||||
$this->comment('idtf_product', 'name', "Nom de la marchandise (libelle FR de l'export).");
|
||||
$this->comment('idtf_product', 'cleaning_regime', 'Regime de nettoyage minimal exige (A, B, C, Interdit, ...).');
|
||||
$this->comment('idtf_product', 'important_requirements', 'Exigences importantes associees. Nullable.');
|
||||
$this->comment('idtf_product', 'mandatory_date', "Date d'application obligatoire du regime (convertie depuis dd-mm-yyyy). Nullable.");
|
||||
$this->comment('idtf_product', 'related_products', 'Produits apparentes (texte libre). Nullable.');
|
||||
$this->comment('idtf_product', 'formula', 'Formule chimique de la marchandise. Nullable.');
|
||||
$this->comment('idtf_product', 'eural_code', 'Code EURAL (dechet) associe. Nullable.');
|
||||
$this->comment('idtf_product', 'cas_numbers', 'Liste des numeros CAS (JSONB), eclatee depuis la cellule "Numero CAS" separee par ";". Tableau vide si absent.');
|
||||
$this->comment('idtf_product', 'footnotes', "Annotations / notes de bas de page de l'export. Nullable.");
|
||||
$this->comment('idtf_product', 'source_export_date', 'Date d\'export du fichier source (preambule "Export date:").');
|
||||
$this->comment('idtf_product', 'is_active', 'Faux = ligne absente du dernier export (soft-delete). Toute ligne non revue par le dernier run passe a FALSE.');
|
||||
$this->comment('idtf_product', 'last_synced_at', 'Horodatage du run de synchro ayant vu cette ligne en dernier (soft-delete : last_synced_at < run courant).');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE idtf_sync_log (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
schema VARCHAR(8) NOT NULL,
|
||||
export_date DATE NOT NULL,
|
||||
rows_total INT NOT NULL,
|
||||
rows_upserted INT NOT NULL,
|
||||
rows_deactivated INT NOT NULL,
|
||||
created_at TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW() NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->comment('idtf_sync_log', '_table', 'Journal des synchronisations IDTF (une ligne par run de la commande app:idtf:sync).');
|
||||
$this->comment('idtf_sync_log', 'id', 'Cle technique auto-incrementee.');
|
||||
$this->comment('idtf_sync_log', 'schema', "Mode de transport synchronise : 'road' ou 'water'.");
|
||||
$this->comment('idtf_sync_log', 'export_date', "Date d'export du fichier source traite par ce run.");
|
||||
$this->comment('idtf_sync_log', 'rows_total', 'Nombre de lignes exploitables lues dans le fichier.');
|
||||
$this->comment('idtf_sync_log', 'rows_upserted', 'Nombre de lignes inserees ou mises a jour.');
|
||||
$this->comment('idtf_sync_log', 'rows_deactivated', 'Nombre de lignes passees a is_active=false (absentes de cet export).');
|
||||
$this->comment('idtf_sync_log', 'created_at', 'Horodatage de fin du run (insertion du journal).');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS idtf_sync_log');
|
||||
$this->addSql('DROP TABLE IF EXISTS idtf_product');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pose un COMMENT ON TABLE/COLUMN en dollar-quoting Postgres ($_$...$_$)
|
||||
* pour eviter tout echappement d'apostrophes dans les descriptions.
|
||||
*/
|
||||
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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* RG-3.04 (correctif) — aligne la regle de validite d'un contact prestataire sur
|
||||
* le M1/M2 : au moins le PRENOM OU le NOM (et non plus « un champ quelconque parmi
|
||||
* prenom/nom/fonction/telephone/email »). Remplace le CHECK chk_provider_contact_name
|
||||
* et met a jour les commentaires de colonnes. La garde applicative
|
||||
* (ProviderContactProcessor::validateName) est alignee dans le meme commit.
|
||||
*
|
||||
* Placee au namespace racine DoctrineMigrations (et non en modulaire Technique) :
|
||||
* elle ALTERE une table creee par une migration racine (Version20260612100000) ;
|
||||
* le tri par version au sein du meme namespace garantit qu'elle joue APRES l'init
|
||||
* (cf. CLAUDE.md regle 11 — le tri cross-namespace casserait l'ordre sur base vide).
|
||||
*/
|
||||
final class Version20260615120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'RG-3.04 : contact prestataire valide si prenom OU nom (alignement M1/M2) — CHECK chk_provider_contact_name.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name');
|
||||
$this->addSql('ALTER TABLE provider_contact ADD CONSTRAINT chk_provider_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL)');
|
||||
|
||||
$this->addSql('COMMENT ON TABLE provider_contact IS $_$Contacts d un prestataire (1:n) — au moins le prenom OU le nom rempli (RG-3.04, chk_provider_contact_name).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Facultatif — ne suffit plus a valider le contact (RG-3.04).$_$');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name');
|
||||
$this->addSql('ALTER TABLE provider_contact ADD CONSTRAINT chk_provider_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL)');
|
||||
|
||||
$this->addSql('COMMENT ON TABLE provider_contact IS $_$Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/fonction/telephone/email (RG-3.04, chk_provider_contact_name).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ERP-154 — Infra d'upload de fichiers generique et reutilisable (src/Shared).
|
||||
*
|
||||
* Cree la table `uploaded_document` : reference technique d'un fichier televerse
|
||||
* (PDF / image), gere par le service Shared\Infrastructure\Upload\FileUploader.
|
||||
* La « Decharge » du M4 transporteurs en sera le premier consommateur, mais ce
|
||||
* ticket ne touche AUCUN module : la table vit cote Shared.
|
||||
*
|
||||
* Caracteristiques :
|
||||
* - Document IMMUABLE : pas d'onglet edition, pas de updated_at / updated_by.
|
||||
* Seules les colonnes created_at (UTC, remplie par le FileUploader via
|
||||
* l'horloge injectee) et created_by (auteur HTTP, null hors HTTP) tracent
|
||||
* l'origine. C'est pourquoi l'entite Shared n'implemente PAS
|
||||
* Timestampable/Blamable (qui imposeraient les 4 colonnes).
|
||||
* - checksum sha256 (64 caracteres hex) : controle d'integrite + future
|
||||
* deduplication eventuelle (hors scope ici).
|
||||
*
|
||||
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et non
|
||||
* modulaire : la table porte une FK cross-module vers "user" (created_by). Le
|
||||
* tri par version au sein du namespace racine garantit qu'elle joue APRES la
|
||||
* creation de "user" sur base vide.
|
||||
*
|
||||
* Style DDL aligne sur le M1/M2/M3 : `INT GENERATED BY DEFAULT AS IDENTITY` et
|
||||
* `TIMESTAMP(0) WITHOUT TIME ZONE` (mapping ORM `datetime_immutable`), pour que
|
||||
* `schema:update --force` reste un no-op une fois l'entite mappee.
|
||||
*
|
||||
* COMMENT ON COLUMN inline (regle ABSOLUE n°12) : chaque colonne porte sa
|
||||
* description ici. La table est aussi ajoutee a `ColumnCommentsCatalog` car
|
||||
* l'entite UploadedDocument existe des ce ticket — `app:apply-column-comments`
|
||||
* du `test-db-setup` rejoue donc ces COMMENT apres le `schema:update --force`.
|
||||
*/
|
||||
final class Version20260615130000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-154 : table uploaded_document (infra upload generique Shared) — fichier televerse immuable, checksum sha256.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE uploaded_document (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
original_filename VARCHAR(255) NOT NULL,
|
||||
stored_path VARCHAR(512) NOT NULL,
|
||||
mime_type VARCHAR(100) NOT NULL,
|
||||
size_bytes INT NOT NULL,
|
||||
checksum VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_uploaded_document_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
// Postgres n'indexe pas automatiquement les colonnes de FK.
|
||||
$this->addSql('CREATE INDEX idx_uploaded_document_created_by ON uploaded_document (created_by)');
|
||||
// Recherche d'integrite / future deduplication par empreinte sha256.
|
||||
$this->addSql('CREATE INDEX idx_uploaded_document_checksum ON uploaded_document (checksum)');
|
||||
|
||||
$this->addSql('COMMENT ON TABLE uploaded_document IS $_$Fichiers televerses (infra generique Shared, ERP-154) — documents immuables (PDF / images), 1er consommateur la Decharge M4.$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.id IS $_$Identifiant interne auto-incremente.$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.original_filename IS $_$Nom de fichier d origine fourni par le client (≤ 255) — metadonnee d affichage uniquement, jamais utilise pour le stockage disque.$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.stored_path IS $_$Chemin relatif du fichier sous var/uploads (ex: 2026/06/<hash>.pdf) — nom genere aleatoirement, jamais le nom client.$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.mime_type IS $_$Type MIME detecte SERVER-SIDE via getMimeType (jamais getClientMimeType, spoofable) — borne a la whitelist FileUploader (PDF + images).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.size_bytes IS $_$Taille du fichier en octets — bornee par FileUploader::MAX_SIZE_BYTES.$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.checksum IS $_$Empreinte SHA-256 du contenu (64 caracteres hex) — controle d integrite + deduplication eventuelle (hors scope).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.created_at IS $_$Horodatage UTC du televersement — rempli par FileUploader via l horloge injectee (pas via TimestampableBlamableSubscriber).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.created_by IS $_$ID de l utilisateur ayant televerse le fichier — null hors HTTP (CLI, fixture). FK -> "user".id, ON DELETE SET NULL.$_$');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS uploaded_document');
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user