Compare commits
130 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd430bc123 | |||
| a6b48b1dd1 | |||
| 97f2402ae4 | |||
| faafd99ef8 | |||
| a4158d4e37 | |||
| 5e15c1f69f | |||
| 6c938756cc | |||
| ac1a51a7f4 | |||
| a9935fbf97 | |||
| 36e947fd8e | |||
| b4e550b5de | |||
| 10c113dbad | |||
| c0dadd79ff | |||
| 1ffa38282a | |||
| 036b075d5e | |||
| 25466b18d8 | |||
| 2fde5844e5 | |||
| 02a22597b3 | |||
| 76e7a59ba7 | |||
| e88bb059e6 | |||
| 312c119c06 | |||
| 8491f55072 | |||
| c63a5f971f | |||
| 5f2aa5334b | |||
| 21b1c64a5f | |||
| fd89160c4b | |||
| 8daf0ff5d4 | |||
| 87c53c354b | |||
| f8b45cb30b | |||
| 0c0b57f898 | |||
| c18566124a | |||
| f767733b87 | |||
| 40da5dfb31 | |||
| d304b74289 | |||
| 80b3741f64 | |||
| c468374b16 | |||
| 7ddf495d7f | |||
| 9fcf5c24f6 | |||
| 76fb01c063 | |||
| e76bd1dd63 | |||
| 498cef8cc0 | |||
| 7668d77c78 | |||
| 1d5110d000 | |||
| b6b5bb06e8 | |||
| fb9c15c52a | |||
| c371057c0b | |||
| e1712465f1 | |||
| 5125883e21 | |||
| 6ff5b13ce2 | |||
| 5bbd4ddb47 | |||
| a26bb09ee1 | |||
| 20296ac149 | |||
| 07e0bcbcce | |||
| fe1d012548 | |||
| d86dc69cf2 | |||
| 07ed57f283 | |||
| b5749520bc | |||
| 02d2fde653 | |||
| 0d284fe488 | |||
| 48ca963a9d | |||
| b11968f5e5 | |||
| 5109b5f57a | |||
| d5a01ac85f | |||
| 7adf3a511a | |||
| e612eae391 | |||
| f29266e5e8 | |||
| f27db02cb6 | |||
| 5765ba7178 | |||
| ef996c3672 | |||
| c6259a96cd | |||
| 726be37ccf | |||
| 40fdded7e2 | |||
| 4202977950 | |||
| 45158af920 | |||
| c09b3cda2b | |||
| 0733a239a8 | |||
| cf645493c1 | |||
| 388d39a379 | |||
| d6d2144cc1 | |||
| 6a519874ed | |||
| 3804362546 | |||
| 9864dbc00f | |||
| be03f4e51a | |||
| 8cc2cea444 | |||
| f70e701854 | |||
| f1b18cfbbe | |||
| 5734aaef54 | |||
| 597c63bb2e | |||
| 8046de76c6 | |||
| 1ef4215ebf | |||
| 3b474f83f5 | |||
| c60daebf3e | |||
| 6dab7cfd17 | |||
| c1fcd9a7c8 | |||
| 18c88156e5 | |||
| c0fa00c9c5 | |||
| e688fe7e0b | |||
| 7d2812cea6 | |||
| daa8224b8b | |||
| 7012306a78 | |||
| 397fb22c62 | |||
| 13d4a08bc9 | |||
| aa23189fe1 | |||
| dc75945f3e | |||
| 2be9cd05d4 | |||
| f61e189441 | |||
| 9d9f9861b1 | |||
| 39071cbec0 | |||
| b82acdac01 | |||
| 8b8fb8c2aa | |||
| f9fec3e908 | |||
| 4f8ed075b6 | |||
| 1e783bd753 | |||
| 9f4f45f761 | |||
| e99747ac72 | |||
| 36edd11854 | |||
| 45cb5c834c | |||
| 2689b85ebe | |||
| f4bbc79550 | |||
| f057866e75 | |||
| 19fdb50cec | |||
| 368bb50ffb | |||
| 6a83adc00a | |||
| c76c447aa2 | |||
| 19ac8833eb | |||
| c25c33116d | |||
| 17aa61d014 | |||
| 3d4ae391fe | |||
| 04c794addb | |||
| c1e45cd582 |
@@ -56,7 +56,10 @@ jobs:
|
|||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: '8.4'
|
php-version: '8.4'
|
||||||
extensions: pdo, pdo_pgsql, intl, opcache, zip, mbstring, sodium
|
# gd requis par phpoffice/phpspreadsheet (export XLSX). Doit etre explicite :
|
||||||
|
# sinon `composer install` echoue sur la verification de plateforme des que
|
||||||
|
# le runner ne fournit pas l'extension par defaut (ext-gd manquante).
|
||||||
|
extensions: pdo, pdo_pgsql, intl, opcache, zip, mbstring, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
tools: composer:v2
|
tools: composer:v2
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -12,6 +12,7 @@
|
|||||||
"doctrine/doctrine-bundle": "^3.2",
|
"doctrine/doctrine-bundle": "^3.2",
|
||||||
"doctrine/doctrine-migrations-bundle": "^4.0",
|
"doctrine/doctrine-migrations-bundle": "^4.0",
|
||||||
"doctrine/orm": "^3.6",
|
"doctrine/orm": "^3.6",
|
||||||
|
"dompdf/dompdf": "^3.0",
|
||||||
"lexik/jwt-authentication-bundle": "^3.2",
|
"lexik/jwt-authentication-bundle": "^3.2",
|
||||||
"nelmio/cors-bundle": "^2.6",
|
"nelmio/cors-bundle": "^2.6",
|
||||||
"nyholm/psr7": "^1.8",
|
"nyholm/psr7": "^1.8",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"symfony/expression-language": "8.0.*",
|
"symfony/expression-language": "8.0.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "8.0.*",
|
"symfony/framework-bundle": "8.0.*",
|
||||||
|
"symfony/http-client": "8.0.*",
|
||||||
"symfony/intl": "8.0.*",
|
"symfony/intl": "8.0.*",
|
||||||
"symfony/mime": "8.0.*",
|
"symfony/mime": "8.0.*",
|
||||||
"symfony/monolog-bundle": "^4.0",
|
"symfony/monolog-bundle": "^4.0",
|
||||||
@@ -95,7 +97,6 @@
|
|||||||
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
||||||
"friendsofphp/php-cs-fixer": "^3.94",
|
"friendsofphp/php-cs-fixer": "^3.94",
|
||||||
"phpunit/phpunit": "^13.0",
|
"phpunit/phpunit": "^13.0",
|
||||||
"symfony/browser-kit": "8.0.*",
|
"symfony/browser-kit": "8.0.*"
|
||||||
"symfony/http-client": "8.0.*"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+620
-175
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "2dc5db01e7f5d6aecd5956749b21a092",
|
"content-hash": "224bae08ec63f217eabf5b2b611deaa0",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/doctrine-common",
|
"name": "api-platform/doctrine-common",
|
||||||
@@ -2520,6 +2520,161 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-02-08T16:21:46+00:00"
|
"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",
|
"name": "lcobucci/jwt",
|
||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
@@ -2894,6 +3049,73 @@
|
|||||||
},
|
},
|
||||||
"time": "2022-12-02T22:17:43+00:00"
|
"time": "2022-12-02T22:17:43+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "masterminds/html5",
|
||||||
|
"version": "2.10.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Masterminds/html5-php.git",
|
||||||
|
"reference": "fd5018f6815fff903946d0564977b44ce8010e29"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29",
|
||||||
|
"reference": "fd5018f6815fff903946d0564977b44ce8010e29",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-dom": "*",
|
||||||
|
"php": ">=5.3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10"
|
||||||
|
},
|
||||||
|
"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.1"
|
||||||
|
},
|
||||||
|
"time": "2026-06-23T18:43:15+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "monolog/monolog",
|
"name": "monolog/monolog",
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
@@ -3937,6 +4159,86 @@
|
|||||||
},
|
},
|
||||||
"time": "2021-10-29T13:26:27+00:00"
|
"time": "2021-10-29T13:26:27+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "sabberworm/php-css-parser",
|
||||||
|
"version": "v9.4.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
|
||||||
|
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
|
||||||
|
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
|
||||||
|
"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.33 || 2.2.2",
|
||||||
|
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.16",
|
||||||
|
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11",
|
||||||
|
"phpunit/phpunit": "8.5.52",
|
||||||
|
"rawr/phpunit-data-provider": "3.3.1",
|
||||||
|
"rector/rector": "1.2.10 || 2.4.6",
|
||||||
|
"rector/type-perfect": "1.0.0 || 2.1.3",
|
||||||
|
"squizlabs/php_codesniffer": "4.0.1",
|
||||||
|
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-mbstring": "for parsing UTF-8 CSS"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "9.5.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.4.0"
|
||||||
|
},
|
||||||
|
"time": "2026-06-18T15:10:53+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/asset",
|
"name": "symfony/asset",
|
||||||
"version": "v8.0.8",
|
"version": "v8.0.8",
|
||||||
@@ -5412,6 +5714,180 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-03-30T15:14:47+00:00"
|
"time": "2026-03-30T15:14:47+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/http-client",
|
||||||
|
"version": "v8.0.13",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/http-client.git",
|
||||||
|
"reference": "c7f40f9103233630167c25c9a4570acf805fdade"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/http-client/zipball/c7f40f9103233630167c25c9a4570acf805fdade",
|
||||||
|
"reference": "c7f40f9103233630167c25c9a4570acf805fdade",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.4",
|
||||||
|
"psr/log": "^1|^2|^3",
|
||||||
|
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
|
||||||
|
"symfony/service-contracts": "^2.5|^3"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"amphp/amp": "<3",
|
||||||
|
"php-http/discovery": "<1.15"
|
||||||
|
},
|
||||||
|
"provide": {
|
||||||
|
"php-http/async-client-implementation": "*",
|
||||||
|
"php-http/client-implementation": "*",
|
||||||
|
"psr/http-client-implementation": "1.0",
|
||||||
|
"symfony/http-client-implementation": "3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"amphp/http-client": "^5.3.2",
|
||||||
|
"amphp/http-tunnel": "^2.0",
|
||||||
|
"guzzlehttp/promises": "^1.4|^2.0",
|
||||||
|
"nyholm/psr7": "^1.0",
|
||||||
|
"php-http/httplug": "^1.0|^2.0",
|
||||||
|
"psr/http-client": "^1.0",
|
||||||
|
"symfony/cache": "^7.4|^8.0",
|
||||||
|
"symfony/dependency-injection": "^7.4|^8.0",
|
||||||
|
"symfony/http-kernel": "^7.4|^8.0",
|
||||||
|
"symfony/messenger": "^7.4|^8.0",
|
||||||
|
"symfony/process": "^7.4|^8.0",
|
||||||
|
"symfony/rate-limiter": "^7.4|^8.0",
|
||||||
|
"symfony/stopwatch": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\HttpClient\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Nicolas Grekas",
|
||||||
|
"email": "p@tchwork.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"http"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/http-client/tree/v8.0.13"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-05-24T09:58:02+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/http-client-contracts",
|
||||||
|
"version": "v3.6.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/http-client-contracts.git",
|
||||||
|
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
|
||||||
|
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"thanks": {
|
||||||
|
"url": "https://github.com/symfony/contracts",
|
||||||
|
"name": "symfony/contracts"
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.6-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Contracts\\HttpClient\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Test/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Nicolas Grekas",
|
||||||
|
"email": "p@tchwork.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Generic abstractions related to HTTP clients",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"abstractions",
|
||||||
|
"contracts",
|
||||||
|
"decoupling",
|
||||||
|
"interfaces",
|
||||||
|
"interoperability",
|
||||||
|
"standards"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-04-29T11:18:49+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/http-foundation",
|
"name": "symfony/http-foundation",
|
||||||
"version": "v8.0.8",
|
"version": "v8.0.8",
|
||||||
@@ -8605,6 +9081,149 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-03-30T15:14:47+00:00"
|
"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",
|
"name": "twig/twig",
|
||||||
"version": "v3.24.0",
|
"version": "v3.24.0",
|
||||||
@@ -11785,180 +12404,6 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-03-30T15:14:47+00:00"
|
"time": "2026-03-30T15:14:47+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "symfony/http-client",
|
|
||||||
"version": "v8.0.8",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/symfony/http-client.git",
|
|
||||||
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e",
|
|
||||||
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": ">=8.4",
|
|
||||||
"psr/log": "^1|^2|^3",
|
|
||||||
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
|
|
||||||
"symfony/service-contracts": "^2.5|^3"
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"amphp/amp": "<3",
|
|
||||||
"php-http/discovery": "<1.15"
|
|
||||||
},
|
|
||||||
"provide": {
|
|
||||||
"php-http/async-client-implementation": "*",
|
|
||||||
"php-http/client-implementation": "*",
|
|
||||||
"psr/http-client-implementation": "1.0",
|
|
||||||
"symfony/http-client-implementation": "3.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"amphp/http-client": "^5.3.2",
|
|
||||||
"amphp/http-tunnel": "^2.0",
|
|
||||||
"guzzlehttp/promises": "^1.4|^2.0",
|
|
||||||
"nyholm/psr7": "^1.0",
|
|
||||||
"php-http/httplug": "^1.0|^2.0",
|
|
||||||
"psr/http-client": "^1.0",
|
|
||||||
"symfony/cache": "^7.4|^8.0",
|
|
||||||
"symfony/dependency-injection": "^7.4|^8.0",
|
|
||||||
"symfony/http-kernel": "^7.4|^8.0",
|
|
||||||
"symfony/messenger": "^7.4|^8.0",
|
|
||||||
"symfony/process": "^7.4|^8.0",
|
|
||||||
"symfony/rate-limiter": "^7.4|^8.0",
|
|
||||||
"symfony/stopwatch": "^7.4|^8.0"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Symfony\\Component\\HttpClient\\": ""
|
|
||||||
},
|
|
||||||
"exclude-from-classmap": [
|
|
||||||
"/Tests/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Nicolas Grekas",
|
|
||||||
"email": "p@tchwork.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Symfony Community",
|
|
||||||
"homepage": "https://symfony.com/contributors"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
|
|
||||||
"homepage": "https://symfony.com",
|
|
||||||
"keywords": [
|
|
||||||
"http"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"source": "https://github.com/symfony/http-client/tree/v8.0.8"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://symfony.com/sponsor",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/fabpot",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/nicolas-grekas",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-03-30T15:14:47+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "symfony/http-client-contracts",
|
|
||||||
"version": "v3.6.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/symfony/http-client-contracts.git",
|
|
||||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
|
|
||||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": ">=8.1"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"thanks": {
|
|
||||||
"url": "https://github.com/symfony/contracts",
|
|
||||||
"name": "symfony/contracts"
|
|
||||||
},
|
|
||||||
"branch-alias": {
|
|
||||||
"dev-main": "3.6-dev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Symfony\\Contracts\\HttpClient\\": ""
|
|
||||||
},
|
|
||||||
"exclude-from-classmap": [
|
|
||||||
"/Test/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Nicolas Grekas",
|
|
||||||
"email": "p@tchwork.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Symfony Community",
|
|
||||||
"homepage": "https://symfony.com/contributors"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Generic abstractions related to HTTP clients",
|
|
||||||
"homepage": "https://symfony.com",
|
|
||||||
"keywords": [
|
|
||||||
"abstractions",
|
|
||||||
"contracts",
|
|
||||||
"decoupling",
|
|
||||||
"interfaces",
|
|
||||||
"interoperability",
|
|
||||||
"standards"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://symfony.com/sponsor",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/fabpot",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2025-04-29T11:18:49+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "symfony/process",
|
"name": "symfony/process",
|
||||||
"version": "v8.0.8",
|
"version": "v8.0.8",
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ declare(strict_types=1);
|
|||||||
use App\Module\Catalog\CatalogModule;
|
use App\Module\Catalog\CatalogModule;
|
||||||
use App\Module\Commercial\CommercialModule;
|
use App\Module\Commercial\CommercialModule;
|
||||||
use App\Module\Core\CoreModule;
|
use App\Module\Core\CoreModule;
|
||||||
|
use App\Module\Logistique\LogistiqueModule;
|
||||||
use App\Module\Sites\SitesModule;
|
use App\Module\Sites\SitesModule;
|
||||||
use App\Module\Technique\TechniqueModule;
|
use App\Module\Technique\TechniqueModule;
|
||||||
|
use App\Module\Transport\TransportModule;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
CoreModule::class,
|
CoreModule::class,
|
||||||
@@ -13,4 +15,6 @@ return [
|
|||||||
SitesModule::class,
|
SitesModule::class,
|
||||||
CatalogModule::class,
|
CatalogModule::class,
|
||||||
TechniqueModule::class,
|
TechniqueModule::class,
|
||||||
|
TransportModule::class,
|
||||||
|
LogistiqueModule::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ api_platform:
|
|||||||
# Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource]
|
# Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource]
|
||||||
# en dehors de Domain/Entity : AuditLogResource, etc.
|
# en dehors de Domain/Entity : AuditLogResource, etc.
|
||||||
- '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource'
|
- '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource'
|
||||||
|
# Entites techniques partagees portant un #[ApiResource]
|
||||||
|
# (UploadedDocument — infra upload generique ERP-154).
|
||||||
|
- '%kernel.project_dir%/src/Shared/Domain/Entity'
|
||||||
formats:
|
formats:
|
||||||
jsonld: ['application/ld+json']
|
jsonld: ['application/ld+json']
|
||||||
json: ['application/json']
|
json: ['application/json']
|
||||||
|
|||||||
@@ -8,16 +8,34 @@ doctrine:
|
|||||||
default:
|
default:
|
||||||
url: '%env(resolve:DATABASE_URL)%'
|
url: '%env(resolve:DATABASE_URL)%'
|
||||||
profiling_collect_backtrace: '%kernel.debug%'
|
profiling_collect_backtrace: '%kernel.debug%'
|
||||||
# Exclut `audit_log` de toute operation de comparaison de schema
|
# Exclut certaines tables de toute operation de comparaison de
|
||||||
# (doctrine:schema:update, schema:validate, diff de migrations...).
|
# schema (doctrine:schema:update, schema:validate, diff de
|
||||||
# Cette table n'a volontairement aucune entite mappee : elle est
|
# migrations...). Ces tables n'ont volontairement aucune entite
|
||||||
# append-only via DBAL brut (AuditLogWriter) pour eviter la
|
# mappee :
|
||||||
# recursion du listener Doctrine. Sans ce filtre, schema:update
|
# - `audit_log` : append-only via DBAL brut (AuditLogWriter) pour
|
||||||
# la considere comme "orpheline" et genere un `DROP TABLE
|
# eviter la recursion du listener Doctrine.
|
||||||
# audit_log` qui casse la base de test apres chaque
|
# - `qualimat_sync_log` : journal de synchro transporteurs
|
||||||
# `make test-db-setup`. La creation / suppression de la table
|
# QUALIMAT, ecrit en DBAL brut par `app:qualimat:sync`, hors ORM.
|
||||||
# reste pilotee par les migrations (cf. Version20260420202749).
|
# NB : `qualimat_carrier` n'est PLUS filtree depuis M4 (ERP-155) :
|
||||||
schema_filter: '~^(?!audit_log$).+~'
|
# elle est desormais mappee en LECTURE SEULE par l'entite
|
||||||
|
# App\Module\Transport\Domain\Entity\QualimatCarrier (cible de la
|
||||||
|
# FK editable carrier.qualimat_carrier_id). Son mapping reproduit
|
||||||
|
# a l'identique le DDL de la migration ERP-39 (unique siret, index
|
||||||
|
# is_active, TIMESTAMP(6)) -> schema:update reste un no-op.
|
||||||
|
# - `idtf_product` / `idtf_sync_log` : referentiel codes IDTF
|
||||||
|
# synchronise en DBAL brut par `app:idtf:sync`, hors ORM.
|
||||||
|
# - `weighing_ticket_counter` / `weighbridge_dsd_counter` : compteurs
|
||||||
|
# par site (numero de ticket de pesee RG-5.02 / DSD du pont RG-5.04,
|
||||||
|
# M5 Logistique), incrementes en DBAL brut sous verrou `FOR UPDATE`
|
||||||
|
# par l'allocateur — jamais mappes en ORM (cf. spec M5 § 2.5 / § 2.7).
|
||||||
|
# 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 ; compteurs M5 :
|
||||||
|
# Version20260617150000).
|
||||||
|
schema_filter: '~^(?!(?:audit_log|qualimat_sync_log|idtf_product|idtf_sync_log|weighing_ticket_counter|weighbridge_dsd_counter)$).+~'
|
||||||
audit:
|
audit:
|
||||||
url: '%env(resolve:DATABASE_URL)%'
|
url: '%env(resolve:DATABASE_URL)%'
|
||||||
orm:
|
orm:
|
||||||
@@ -41,7 +59,26 @@ doctrine:
|
|||||||
# Permet au module Commercial de referencer une Category via le contrat
|
# Permet au module Commercial de referencer une Category via le contrat
|
||||||
# Shared sans importer la classe concrete du module Catalog (regle n°1).
|
# Shared sans importer la classe concrete du module Catalog (regle n°1).
|
||||||
App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category
|
App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category
|
||||||
|
# Cibles des ManyToOne de CarrierPrice (M4 Transport, onglet Prix) :
|
||||||
|
# permet au module Transport de referencer Client / Supplier et leurs
|
||||||
|
# adresses (M1/M2 Commercial) via des contrats Shared sans importer les
|
||||||
|
# classes concretes (regle n°1). L'embed JSON passe par les read-groups
|
||||||
|
# des entites concretes (client:read / supplier:read / ...).
|
||||||
|
App\Shared\Domain\Contract\ClientInterface: App\Module\Commercial\Domain\Entity\Client
|
||||||
|
App\Shared\Domain\Contract\ClientAddressInterface: App\Module\Commercial\Domain\Entity\ClientAddress
|
||||||
|
App\Shared\Domain\Contract\SupplierInterface: App\Module\Commercial\Domain\Entity\Supplier
|
||||||
|
App\Shared\Domain\Contract\SupplierAddressInterface: App\Module\Commercial\Domain\Entity\SupplierAddress
|
||||||
mappings:
|
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:
|
Core:
|
||||||
type: attribute
|
type: attribute
|
||||||
is_bundle: false
|
is_bundle: false
|
||||||
@@ -90,6 +127,28 @@ doctrine:
|
|||||||
dir: '%kernel.project_dir%/src/Module/Technique/Domain/Entity'
|
dir: '%kernel.project_dir%/src/Module/Technique/Domain/Entity'
|
||||||
prefix: 'App\Module\Technique\Domain\Entity'
|
prefix: 'App\Module\Technique\Domain\Entity'
|
||||||
alias: Technique
|
alias: Technique
|
||||||
|
# Mapping inconditionnel du module Transport (meme logique que Technique) :
|
||||||
|
# les tables transporteurs (carrier + sous-collections) creees par la
|
||||||
|
# migration M4 (Version20260615150000) et le mapping lecture-seule de
|
||||||
|
# qualimat_carrier (referentiel ERP-39) doivent etre connus de l'ORM.
|
||||||
|
# L'activation fonctionnelle passe par config/modules.php.
|
||||||
|
Transport:
|
||||||
|
type: attribute
|
||||||
|
is_bundle: false
|
||||||
|
dir: '%kernel.project_dir%/src/Module/Transport/Domain/Entity'
|
||||||
|
prefix: 'App\Module\Transport\Domain\Entity'
|
||||||
|
alias: Transport
|
||||||
|
# Mapping inconditionnel du module Logistique (meme logique que Transport) :
|
||||||
|
# la table weighing_ticket (tickets de pesee M5) creee par la migration
|
||||||
|
# Version20260617150000 doit etre connue de l'ORM, sinon schema:update la
|
||||||
|
# drope sur la base de test. L'activation fonctionnelle passe par
|
||||||
|
# config/modules.php.
|
||||||
|
Logistique:
|
||||||
|
type: attribute
|
||||||
|
is_bundle: false
|
||||||
|
dir: '%kernel.project_dir%/src/Module/Logistique/Domain/Entity'
|
||||||
|
prefix: 'App\Module\Logistique\Domain\Entity'
|
||||||
|
alias: Logistique
|
||||||
controller_resolver:
|
controller_resolver:
|
||||||
auto_mapping: false
|
auto_mapping: false
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ doctrine_migrations:
|
|||||||
migrations_paths:
|
migrations_paths:
|
||||||
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
||||||
'App\Module\Core\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/Core/Infrastructure/Doctrine/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
|
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'
|
||||||
@@ -33,3 +33,14 @@ services:
|
|||||||
|
|
||||||
App\Module\Sites\Application\Service\CurrentSiteProviderInterface:
|
App\Module\Sites\Application\Service\CurrentSiteProviderInterface:
|
||||||
alias: App\Module\Sites\Application\Service\CurrentSiteProvider
|
alias: App\Module\Sites\Application\Service\CurrentSiteProvider
|
||||||
|
|
||||||
|
# M5 Logistique — pesee pont bascule (ERP-184)
|
||||||
|
App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface:
|
||||||
|
alias: App\Module\Logistique\Infrastructure\Weighbridge\RandomWeighbridgeReader
|
||||||
|
|
||||||
|
App\Module\Logistique\Application\Service\DsdAllocatorInterface:
|
||||||
|
alias: App\Module\Logistique\Infrastructure\Service\DsdAllocator
|
||||||
|
|
||||||
|
# M5 Logistique — Provider/Processor ticket de pesee (ERP-185)
|
||||||
|
App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface:
|
||||||
|
alias: App\Module\Logistique\Infrastructure\Service\WeighingTicketNumberAllocator
|
||||||
|
|||||||
+34
-2
@@ -38,7 +38,27 @@ declare(strict_types=1);
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// Section "Commerciale" : pole metier principal, remontee en tete de sidebar (ERP-71).
|
// Section "Logistique" (M5, ERP-181) : nouveau pole "operations physiques sur
|
||||||
|
// site", distinct du repertoire Transport (M4, desormais rattache a la section
|
||||||
|
// Administration cote develop). Porte le ticket de pesee au pont bascule.
|
||||||
|
// Placee en tete de sidebar (avant Commerciale). L'item est gate par
|
||||||
|
// `logistique.weighing_tickets.view` ; la section disparait automatiquement
|
||||||
|
// (SidebarProvider) si le module `logistique` est desactive ou si l'user n'a
|
||||||
|
// pas la permission (Compta / Commerciale).
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.logistique.section',
|
||||||
|
'icon' => 'mdi:truck-outline',
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.logistique.weighing_tickets',
|
||||||
|
'to' => '/weighing-tickets',
|
||||||
|
'icon' => 'mdi:truck-outline',
|
||||||
|
'module' => 'logistique',
|
||||||
|
'permission' => 'logistique.weighing_tickets.view',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
// Section "Commerciale" : pole metier principal (ERP-71).
|
||||||
// L'ordre interne des onglets et les permissions restent inchanges (simple deplacement
|
// L'ordre interne des onglets et les permissions restent inchanges (simple deplacement
|
||||||
// du bloc, aucun gate touche).
|
// du bloc, aucun gate touche).
|
||||||
[
|
[
|
||||||
@@ -100,8 +120,20 @@ return [
|
|||||||
// individuelles"), ajouter : 'permission' => 'core.admin.access'.
|
// individuelles"), ajouter : 'permission' => 'core.admin.access'.
|
||||||
[
|
[
|
||||||
'label' => 'sidebar.administration.section',
|
'label' => 'sidebar.administration.section',
|
||||||
'icon' => 'mdi:cog-outline',
|
'icon' => 'mdi:file-settings-cog-outline',
|
||||||
'items' => [
|
'items' => [
|
||||||
|
// Transport — Repertoire transporteurs (M4, ERP-164). Rattache a
|
||||||
|
// l'Administration (premier item) plutot qu'a une section dediee :
|
||||||
|
// referentiel global de configuration applicative, sans cloisonnement
|
||||||
|
// par site. Reste gate par sa propre permission `transport.carriers.view`
|
||||||
|
// (Admin / Bureau / Commerciale) et son module owner `transport`.
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.transport.carriers',
|
||||||
|
'to' => '/carriers',
|
||||||
|
'icon' => 'mdi:truck-outline',
|
||||||
|
'module' => 'transport',
|
||||||
|
'permission' => 'transport.carriers.view',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'label' => 'sidebar.core.roles',
|
'label' => 'sidebar.core.roles',
|
||||||
'to' => '/admin/roles',
|
'to' => '/admin/roles',
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.116'
|
app.version: '0.1.149'
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# M4 — Plan maître worktrees (back, Matthieu)
|
||||||
|
|
||||||
|
> **Rôle de ce fichier** : vue d'ensemble que la *conversation maître* tient à jour.
|
||||||
|
> Chaque worktree = une conversation Claude isolée + une branche + une PR vers `develop`.
|
||||||
|
> Les prompts à coller sont dans `WT*.md`.
|
||||||
|
|
||||||
|
## Principe
|
||||||
|
|
||||||
|
- 1 worktree = 1 branche partant de `origin/develop` (à jour des deps).
|
||||||
|
- 1 ticket = 1 PR atomique vers **`develop`** (jamais `main`).
|
||||||
|
- Commit autorisé sur la branche du worktree (ces prompts SONT la demande explicite) ;
|
||||||
|
`git commit --no-verify` OK si `make test` est déjà vert (le hook relance toute la suite).
|
||||||
|
- **Chaque worktree ouvre SA PR** vers `develop` en fin de tâche (cf. bloc PR ci-dessous).
|
||||||
|
|
||||||
|
## Bloc PR standard (repris dans chaque prompt)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin <branche>
|
||||||
|
tea pr create --base develop --head <branche> \
|
||||||
|
--title "<type>(<scope>) : <titre>" \
|
||||||
|
--description "Résumé + lien ticket Lesstime ERP-XXX"
|
||||||
|
```
|
||||||
|
Puis **labelliser la PR via l'API Gitea** (tea ne pose pas les labels en CLI — `gitea.malio.fr`).
|
||||||
|
Cible **`develop`**, jamais `main`. **Aucune mention de Claude/IA** dans titre ou description.
|
||||||
|
|
||||||
|
## Vagues & ordre de merge
|
||||||
|
|
||||||
|
```
|
||||||
|
VAGUE 0 (en parallèle, dès maintenant)
|
||||||
|
WT1 1.2 upload Shared base: origin/develop ──┐
|
||||||
|
WT2 1.1 RBAC + sidebar base: origin/develop (≥ERP-150) ──┤ indépendants
|
||||||
|
│
|
||||||
|
VAGUE 1 (critique, séquentiel) │
|
||||||
|
WT3 1.3 migration + 1.5 entités/resource/provider + i18n audit
|
||||||
|
base: origin/develop APRÈS merge WT1 (FK uploaded_document)
|
||||||
|
⭐ livre le CONTRAT JSON liste+détail → débloque le front (Tristan)
|
||||||
|
|
||||||
|
VAGUE 2 (fan-out, tous en parallèle dès WT3 mergé)
|
||||||
|
WT4 1.6 processor base: develop ≥ WT3
|
||||||
|
WT5 1.4 qualimat endpoint base: develop ≥ WT2 (perm) + ERP-39 (indépendant de WT3)
|
||||||
|
WT6 1.7 adresses base: develop ≥ WT3
|
||||||
|
WT7 1.8 contacts base: develop ≥ WT3
|
||||||
|
WT8 1.9 prix base: develop ≥ WT3
|
||||||
|
WT9 1.10 export XLSX base: develop ≥ WT3
|
||||||
|
|
||||||
|
VAGUE 3 (final)
|
||||||
|
WT10 1.11 tests + fixtures + contrat base: develop ≥ TOUT
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parallélisme réel** : 2 worktrees en V0, puis 1 goulot (WT3), puis **jusqu'à 6 en V2**, puis 1 (WT10).
|
||||||
|
|
||||||
|
## Règle anti-conflit worktree (IMPORTANT)
|
||||||
|
|
||||||
|
Pour que WT4→WT9 tournent en parallèle sans conflit de merge :
|
||||||
|
|
||||||
|
| Fichier partagé | Qui le touche | Les autres |
|
||||||
|
|---|---|---|
|
||||||
|
| `CarrierFixtures` | **WT10 uniquement** | interdit (WT3 met un fixture minimal, WT6-9 n'y touchent pas) |
|
||||||
|
| Entité `Carrier` (ApiResource) | **WT3** crée, **WT4** ajoute le Processor | WT6-9 créent des **resources/processors dédiés** par sous-entité, ne modifient pas `Carrier` |
|
||||||
|
| `ColumnCommentsCatalog` | WT1 (`uploaded_document`), WT3 (`carrier*`) | personne d'autre |
|
||||||
|
| `fr.json` (clés audit) | **WT3** (clés `audit.entity.transport_*`) | personne d'autre côté back |
|
||||||
|
| `migrations/` | WT1 puis WT3 (ordre timestamp) | aucune autre migration |
|
||||||
|
|
||||||
|
## Mode retenu : STACK séquentiel, SANS worktree (repo principal)
|
||||||
|
|
||||||
|
Matthieu empile les MR, un ticket à la fois, **directement dans `/home/matthieu/dev_malio/Starseed`** (pas de worktree).
|
||||||
|
- **Ignorer les blocs `git worktree add` des `WT*.md`** → remplacés par une branche normale :
|
||||||
|
```bash
|
||||||
|
git fetch origin
|
||||||
|
git checkout -b feat/erp-XXX-... origin/<branche-précédente>
|
||||||
|
```
|
||||||
|
- **WT1 hors pile** (déjà mergé). Pile M4 — chaque branche basée sur la précédente :
|
||||||
|
`WT2 → WT3 → WT4 → WT5 → WT6 → WT7 → WT8 → WT9 → WT10`
|
||||||
|
- PR de chaque maillon : `--base <branche-précédente>` (bas de pile WT2 = `develop`). Au merge, les MR du dessus se recible auto.
|
||||||
|
- Docker tourne sur le repo principal → `make test`/`php-cs-fixer` OK sans rebind (le piège worktree-vs-mount ne s'applique plus).
|
||||||
|
- Worktrees créés pour WT1/WT2 à nettoyer : `git worktree remove ../sb-erp154-upload ../sb-erp153-rbac`.
|
||||||
|
- Garder les MR basses propres ; merger dans l'ordre.
|
||||||
|
|
||||||
|
## Suivi (tenu par la conv maître)
|
||||||
|
|
||||||
|
| WT | Ticket | ERP | État | PR | Notes |
|
||||||
|
|----|--------|-----|------|----|----|
|
||||||
|
| WT1 | 1.2 upload | 154 | ✅ MERGÉ | #108 | migration `Version20260615130000` |
|
||||||
|
| WT2 | 1.1 RBAC | 153 | ✅ PR ouverte | #111 | bas de pile (cible develop) |
|
||||||
|
| WT3 | 1.3+1.5 | 155+157 | ▶️ À LANCER | — | stack sur `feat/erp-153-rbac` ; gate contrat front |
|
||||||
|
| WT4 | 1.6 proc | 158 | ⛔ bloqué par WT3 | — | |
|
||||||
|
| WT5 | 1.4 qualimat | 156 | ⛔ bloqué par WT2+ERP-39 | — | |
|
||||||
|
| WT6 | 1.7 adresses | 159 | ⛔ bloqué par WT3 | — | |
|
||||||
|
| WT7 | 1.8 contacts | 160 | ⛔ bloqué par WT3 | — | |
|
||||||
|
| WT8 | 1.9 prix | 161 | ⛔ bloqué par WT3 | — | |
|
||||||
|
| WT9 | 1.10 export | 162 | ⛔ bloqué par WT3 | — | |
|
||||||
|
| WT10 | 1.11 tests | 163 | ⛔ bloqué par tout | — | |
|
||||||
|
|
||||||
|
## Cadre commun à tous les prompts (rappels projet)
|
||||||
|
|
||||||
|
- Carrier vit dans `src/Module/Transport/` (créé par ERP-150). **Miroir = `src/Module/Commercial/`** (Supplier).
|
||||||
|
- Tests sous `tests/Module/Transport/Api/` (miroir `tests/Module/Commercial/Api/`).
|
||||||
|
- `declare(strict_types=1);` partout ; commentaires **FR**, code EN.
|
||||||
|
- `make test` + `make php-cs-fixer-allow-risky` avant de dire « fini ».
|
||||||
|
- Ne jamais mentionner Claude/IA dans commit/PR.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# WT1 — Infra upload générique `Shared` (ticket 1.2 / ERP-154)
|
||||||
|
|
||||||
|
> Créer le worktree puis lancer Claude dedans :
|
||||||
|
> ```bash
|
||||||
|
> git fetch origin
|
||||||
|
> git worktree add ../sb-erp154-upload -b feat/erp-154-upload origin/develop
|
||||||
|
> cd ../sb-erp154-upload && claude
|
||||||
|
> ```
|
||||||
|
> **Base** : `origin/develop` (aucune dépendance — peut démarrer tout de suite, même avant le merge du socle Transport).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt à coller
|
||||||
|
|
||||||
|
Tu travailles sur le projet Starseed (modular monolith DDD, Symfony 8 / API Platform 4). Lis `CLAUDE.md` et `.claude/rules/backend.md` avant de coder. Charge le skill `backend-entity-conventions`.
|
||||||
|
|
||||||
|
**Mission** : poser une infra d'upload de fichiers **générique et réutilisable** dans `src/Shared/` (la « Décharge » du M4 en sera le 1er consommateur, mais ce ticket ne touche PAS au module Transport).
|
||||||
|
|
||||||
|
**Spec** : `docs/specs/M4-transporteurs/spec-back.md § 2.7`.
|
||||||
|
|
||||||
|
**À livrer** :
|
||||||
|
1. Table `uploaded_document` (migration namespace racine `DoctrineMigrations` dans `migrations/`, postérieure à la dernière présente — vérifie `ls migrations/`). Colonnes : `id`, `original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum`, `created_at`, `created_by`.
|
||||||
|
2. Service `Shared\Infrastructure\Upload\FileUploader` :
|
||||||
|
- validation MIME **server-side via `$file->getMimeType()`** (JAMAIS `getClientMimeType()`),
|
||||||
|
- whitelist MIME explicite (PDF + images),
|
||||||
|
- bornage taille, checksum sha256, écriture disque `var/uploads/{yyyy}/{mm}/`.
|
||||||
|
3. Endpoint `POST /api/uploaded_documents` (multipart) → renvoie l'IRI. MIME hors whitelist → **422**.
|
||||||
|
|
||||||
|
**Gardes-fous (cassent `make test` sinon)** :
|
||||||
|
- **`COMMENT ON COLUMN` sur TOUTES les colonnes** de `uploaded_document` (FR, ≤200 car., règle n°12) ET ajoute le bloc `'uploaded_document' => [...]` dans `src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php` — sinon `make test-db-setup` drope les COMMENT et `ColumnsHaveSqlCommentTest` casse.
|
||||||
|
- Pagination : si tu exposes une `GetCollection`, elle reste paginée (`CollectionsArePaginatedTest`).
|
||||||
|
|
||||||
|
**Scope STRICT** : uniquement `src/Shared/` + migration + catalog. Ne crée AUCUN fichier sous `src/Module/Transport/`. Pas d'antivirus/S3/purge (hors périmètre, § 9).
|
||||||
|
|
||||||
|
**Tests à écrire** (PHPUnit) : MIME hors whitelist → 422 ; MIME valide → IRI + ligne persistée + checksum calculé.
|
||||||
|
|
||||||
|
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky` propre. Commit (`--no-verify` OK si `make test` déjà vert), puis **ouvre la PR** :
|
||||||
|
```bash
|
||||||
|
git push -u origin feat/erp-154-upload
|
||||||
|
tea pr create --base develop --head feat/erp-154-upload \
|
||||||
|
--title "feat(shared) : infra upload générique (ERP-154)" \
|
||||||
|
--description "Table uploaded_document + FileUploader + endpoint POST. Ticket ERP-154."
|
||||||
|
```
|
||||||
|
Puis labellise la PR via l'API Gitea (tea ne pose pas les labels en CLI). Cible **develop**. Aucune mention IA.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# WT10 — Tests PHPUnit + fixtures + contrat JSON (ticket 1.11 / ERP-163)
|
||||||
|
|
||||||
|
> ```bash
|
||||||
|
> git fetch origin
|
||||||
|
> git worktree add ../sb-erp163-tests -b feat/erp-163-carrier-tests origin/develop
|
||||||
|
> cd ../sb-erp163-tests && claude
|
||||||
|
> ```
|
||||||
|
> **Base** : `origin/develop` **après merge de TOUS les worktrees back** (WT1→WT9). C'est le filet final.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt à coller
|
||||||
|
|
||||||
|
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`, `.claude/rules/testing.md`. Charge le skill `backend-entity-conventions`. **Miroir** : `tests/Module/Commercial/Api/Supplier*Test.php`.
|
||||||
|
|
||||||
|
**Mission** : couverture complète des RG + capture du contrat de sérialisation + fixtures consolidées. C'est le DoD back avant intégration front.
|
||||||
|
|
||||||
|
**Spec** : `spec-back.md § 4.0.bis / 8.1 / 8.4`.
|
||||||
|
|
||||||
|
**À livrer** :
|
||||||
|
- Matrice **RG-4.01→4.14** couverte (§ 8.1) + RBAC par rôle (Compta/Usine → 403, Commerciale → 403 sur write, Admin → archive).
|
||||||
|
- `CarrierSerializationContractTest` : capture JSON réel **liste + détail** ; `prices[].client`/`.supplier`/sites **embarqués** (pas IRI) ; `qualimatCarrier` embarqué ; `isArchived` présent. Colle les JSON dans `spec-back.md § 4.0.bis`.
|
||||||
|
- Anti-N+1 liste ; pagination Hydra ; audit (`entity_type='Carrier'`) ; `AuditableEntitiesHaveI18nLabelTest` vert.
|
||||||
|
- **`CarrierFixtures` idempotent (§ 8.4)** — c'est ICI que les fixtures complètes vivent : transporteur QUALIMAT (validité passée → RG-4.04), AUTRE+décharge, affrété, LIOT, complet (contacts/adresses/prix CLIENT+FOURNISSEUR), 1 archivé.
|
||||||
|
|
||||||
|
**Piège CI (mémoire projet)** : la CI tourne `APP_DEBUG=0`. Les tests de **comptage de requêtes (anti-N+1)** passent en local mais cassent en CI (DoctrineDataHolder absent) → vérifie/active `profiling: true` dans la config Doctrine de l'environnement `test`. Sans ça le test anti-N+1 sera rouge en CI.
|
||||||
|
|
||||||
|
**Scope** : tests + `CarrierFixtures` + remplissage § 4.0.bis. Tu peux ajuster un test cassé hérité d'un autre WT mais signale-le à la conv maître (ne masque pas un vrai bug).
|
||||||
|
|
||||||
|
**Fini quand** : `make test` **intégralement vert** + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||||
|
```bash
|
||||||
|
git push -u origin feat/erp-163-carrier-tests
|
||||||
|
tea pr create --base develop --head feat/erp-163-carrier-tests \
|
||||||
|
--title "test(transport) : couverture RG-4.01→4.14 + contrat + fixtures (ERP-163)" \
|
||||||
|
--description "Matrice RG + CarrierSerializationContractTest + CarrierFixtures + § 4.0.bis. Ticket ERP-163."
|
||||||
|
```
|
||||||
|
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# WT2 — Permissions `transport.carriers.*` + sidebar (ticket 1.1 / ERP-153)
|
||||||
|
|
||||||
|
> ```bash
|
||||||
|
> git fetch origin
|
||||||
|
> git worktree add ../sb-erp153-rbac -b feat/erp-153-rbac origin/develop
|
||||||
|
> cd ../sb-erp153-rbac && claude
|
||||||
|
> ```
|
||||||
|
> **Base** : `origin/develop` **après merge d'ERP-150** (le module `Transport` doit exister). Vérifie : `ls src/Module/Transport/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt à coller
|
||||||
|
|
||||||
|
Projet Starseed (modular monolith DDD). Lis `CLAUDE.md`, `.claude/rules/architecture.md` et `.claude/rules/testing.md` avant de coder.
|
||||||
|
|
||||||
|
**Mission** : poser le socle RBAC du module Transport et son entrée de menu. `TransportModule::permissions()` renvoie `[]` aujourd'hui.
|
||||||
|
|
||||||
|
**Spec** : `spec-back.md § 5` + `spec-front.md § Accès`.
|
||||||
|
|
||||||
|
**À livrer** :
|
||||||
|
1. `TransportModule::permissions()` déclare `transport.carriers.view`, `transport.carriers.manage`, `transport.carriers.archive`. `app:sync-permissions` les enregistre.
|
||||||
|
2. **Matrice § 5.2** : Admin (view+manage+archive), Bureau (view+manage), Commerciale (view), Compta + Usine (**aucune**).
|
||||||
|
3. **RÈGLE ABSOLUE n°8 — les 3 sources RBAC dans le MÊME commit** :
|
||||||
|
- `config/sidebar.php` : section « Transport » + item `/carriers` + `permission: transport.carriers.view`,
|
||||||
|
- `frontend/tests/e2e/_fixtures/personas.ts` : ajuster `permissions` + `expectedAdminLinks` des personas existants,
|
||||||
|
- `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` : miroir back des mêmes personas.
|
||||||
|
4. Item sidebar masqué pour Compta/Usine ; visible Admin/Bureau/Commerciale.
|
||||||
|
|
||||||
|
**Pièges** :
|
||||||
|
- Ne touche QUE le RBAC/sidebar — pas d'entité, pas de migration.
|
||||||
|
- Toute modif d'une seule des 3 sources sans les 2 autres = drift / test cassé.
|
||||||
|
- Section « Transport » vs « Logistique » : prends « Transport » (cosmétique, alignable plus tard).
|
||||||
|
|
||||||
|
**Tests à écrire/vérifier** : `app:sync-permissions` OK ; cohérence personas (pas de drift). Lance `make test`.
|
||||||
|
|
||||||
|
**Scope STRICT** : RBAC + sidebar + 3 miroirs. Rien d'autre.
|
||||||
|
|
||||||
|
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si test vert), puis **ouvre la PR** :
|
||||||
|
```bash
|
||||||
|
git push -u origin feat/erp-153-rbac
|
||||||
|
tea pr create --base develop --head feat/erp-153-rbac \
|
||||||
|
--title "feat(transport) : permissions carriers + sidebar (ERP-153)" \
|
||||||
|
--description "RBAC transport.carriers.* + 3 sources RBAC alignées. Ticket ERP-153."
|
||||||
|
```
|
||||||
|
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# WT3 ⭐ — Migration + entités Carrier* + ApiResource + Provider (tickets 1.3 + 1.5 / ERP-155 + ERP-157)
|
||||||
|
|
||||||
|
> **Worktree pivot : il livre le CONTRAT JSON qui débloque tout le front.**
|
||||||
|
> **Mode STACK, sans worktree** (repo principal) — base = branche de WT2 :
|
||||||
|
> ```bash
|
||||||
|
> cd /home/matthieu/dev_malio/Starseed && git fetch origin
|
||||||
|
> git checkout -b feat/erp-155-carrier-schema-entities origin/feat/erp-153-rbac
|
||||||
|
> ```
|
||||||
|
> **Base** : `feat/erp-153-rbac` (contient ERP-150 + WT1 + RBAC WT2). Quand #111 sera mergé dans develop, la PR de WT3 se recible automatiquement sur develop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt à coller
|
||||||
|
|
||||||
|
Projet Starseed (modular monolith DDD, Symfony 8 / API Platform 4). Lis `CLAUDE.md`, `.claude/rules/backend.md`, `.claude/rules/architecture.md`. **Charge le skill `backend-entity-conventions`** (patterns entités/migrations complets).
|
||||||
|
|
||||||
|
**Mission** : créer le schéma BDD du répertoire transporteurs + les entités + le contrat de lecture (liste + détail). Tu poses le contrat JSON sur lequel le front s'appuiera — c'est le livrable critique.
|
||||||
|
|
||||||
|
**Spec** : `spec-back.md § 3.2 / 3.3 / 3.4 / 4.0 / 4.1 / 4.2`. **Miroir = le module Supplier** : `src/Module/Commercial/Domain/Entity/Supplier*.php`, `…/Infrastructure/ApiPlatform/State/Provider/SupplierProvider.php`, `…/Serializer/SupplierReadGroupContextBuilder.php`. Carrier vit dans `src/Module/Transport/`.
|
||||||
|
|
||||||
|
### Étape A — Migration (`migrations/`, namespace racine `DoctrineMigrations`)
|
||||||
|
- **PAS de migration modulaire** : même si la spec dit « modulaire », toute migration va dans `migrations/` namespace racine (tri FQCN cassant sinon). Postérieure à la dernière présente — vérifie `ls migrations/` (à ce jour `Version20260615120000`).
|
||||||
|
- Tables `carrier`, `carrier_address`, `carrier_contact`, `carrier_price` + FK : `qualimat_carrier`, `uploaded_document`, `client`, `client_address`, `supplier`, `supplier_address`, `site`, `user`.
|
||||||
|
- `certification_type` **nullable** (null en cas LIOT) + CHECK enum ; CHECK sur `container_type`, `direction`, `pricing_unit`, `price_state`, branches Prix client/fournisseur.
|
||||||
|
- Index partiel `uq_carrier_name_active` : `LOWER(name)` WHERE non archivé ET non supprimé.
|
||||||
|
- **`COMMENT ON COLUMN` sur TOUTES les colonnes** (FR, ≤200 car.) + helper standard pour les 4 colonnes Timestampable/Blamable. Bonus `COMMENT ON TABLE`.
|
||||||
|
|
||||||
|
### Étape B — Entités + repos
|
||||||
|
- `Carrier`, `CarrierAddress`, `CarrierContact`, `CarrierPrice` : `#[Auditable]`, `implements TimestampableInterface, BlamableInterface` + `use TimestampableBlamableTrait`. Repos `*RepositoryInterface` (Domain) + `Doctrine*Repository` (Infrastructure).
|
||||||
|
- `ApiResource` Carrier (attribut sur l'entité, comme Supplier) : `GetCollection` + `Get` + `Post` + `Patch` avec `security` (§ 3.3). **PAS de Delete**.
|
||||||
|
- Groupes : `carrier:read`, `carrier:item:read`, `qualimat:read`. **Embed au détail** (pas IRI) : `client:read`/`client_address:read`/`supplier:read`/`supplier_address:read`/`site:read` + `qualimatCarrier`. ⚠ les adresses de l'onglet Prix sont des `ClientAddress`/`SupplierAddress` distinctes.
|
||||||
|
- `CarrierProvider` paginé (`ApiPlatform\Doctrine\Orm\Paginator`), liste **sans cloisonnement site** (§ 2.3), **anti-N+1** (fetch joins, § 2.11), exclut les archivés par défaut + `?includeArchived=true`.
|
||||||
|
- Piège booléen : `#[SerializedName('isArchived')]` sur le getter.
|
||||||
|
|
||||||
|
### Gardes-fous qui CASSENT `make test` (à traiter dans CE worktree)
|
||||||
|
- `ColumnsHaveSqlCommentTest` → COMMENT partout **+ ajouter les blocs `carrier`, `carrier_address`, `carrier_contact`, `carrier_price` dans `src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php`** (sinon `test-db-setup` drope les COMMENT).
|
||||||
|
- `makefile test-db-setup` : l'index partiel `uq_carrier_name_active` n'est PAS exprimé par `schema:update` → **ajoute-le à la ligne `dbal:run-sql` du target `test-db-setup`** du `makefile`, sinon `make test` casse.
|
||||||
|
- `AuditableEntitiesHaveI18nLabelTest` → ajoute dans `frontend/i18n/locales/fr.json` les clés `audit.entity.transport_carrier`, `transport_carrieraddress`, `transport_carriercontact`, `transport_carrierprice` (clé = strtolower(module)+'_'+strtolower(Entity)).
|
||||||
|
- `EntitiesAreTimestampableBlamableTest`, `EntityConstraintsHaveFrenchMessageTest` (messages FR + `Length.max` = longueur colonne), `CollectionsArePaginatedTest`.
|
||||||
|
|
||||||
|
**Scope STRICT** : schéma + entités + ApiResource lecture + Provider + i18n audit. **PAS** le Processor d'écriture (→ WT4), **PAS** les sous-ressources POST/PATCH adresses/contacts/prix (→ WT6/7/8), **PAS** l'export (→ WT9). Mets un `CarrierFixtures` **minimal** (1-2 lignes) juste pour faire tourner tes tests de lecture ; les fixtures complètes sont faites par WT10 — n'y investis pas.
|
||||||
|
|
||||||
|
**Tests à écrire** : liste exclut archivés / `?includeArchived=true` ; enveloppe Hydra (`member`/`totalItems`) ; `isArchived` présent dans le JSON ; embeds détail présents (pas IRI).
|
||||||
|
|
||||||
|
**LIVRABLE GATE** : une fois vert, **capture le JSON réel liste + détail** (`curl` ou test) et colle-le dans `spec-back.md § 4.0.bis`. C'est le signal pour démarrer le front. Préviens la conv maître.
|
||||||
|
|
||||||
|
**Fini quand** : `make db-reset` OK + `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si test vert), puis **ouvre la PR** :
|
||||||
|
```bash
|
||||||
|
git push -u origin feat/erp-155-carrier-schema-entities
|
||||||
|
tea pr create --base feat/erp-153-rbac --head feat/erp-155-carrier-schema-entities \
|
||||||
|
--title "feat(transport) : schéma + entités Carrier + contrat lecture (ERP-155/157)" \
|
||||||
|
--description "Migration + entités Carrier* + ApiResource lecture + Provider + i18n audit + contrat JSON. Tickets ERP-155, ERP-157."
|
||||||
|
```
|
||||||
|
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# WT4 — CarrierProcessor (ticket 1.6 / ERP-158)
|
||||||
|
|
||||||
|
> ```bash
|
||||||
|
> git fetch origin
|
||||||
|
> git worktree add ../sb-erp158-processor -b feat/erp-158-carrier-processor origin/develop
|
||||||
|
> cd ../sb-erp158-processor && claude
|
||||||
|
> ```
|
||||||
|
> **Base** : `origin/develop` **après merge de WT3** (entités Carrier) **et WT1** (upload, pour la décharge).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt à coller
|
||||||
|
|
||||||
|
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`.
|
||||||
|
|
||||||
|
**Mission** : logique d'écriture du formulaire principal Carrier (POST/PATCH) — normalisation, champs conditionnels, archivage. **Miroir** : `src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php` + `Application/Service/SupplierFieldNormalizer.php`.
|
||||||
|
|
||||||
|
**Spec** : `spec-back.md § 4.3 / 4.4 / 7`.
|
||||||
|
|
||||||
|
**Règles métier à implémenter (un test PHPUnit par RG)** :
|
||||||
|
- **RG-4.01** : POST avec `qualimatCarrier` → `certificationType=QUALIMAT` + FK persistée ; cas LIOT (`name='LIOT'`) ⇒ `certificationType` non requis, `liotPlates` accepté.
|
||||||
|
- **RG-4.02** : `certificationType='AUTRE'` sans `dischargeDocument` → **422** (`#[Assert\Callback]`).
|
||||||
|
- **RG-4.03** : `isChartered=true` sans `indexationRate` / `containerType` / `volumeM3` → **422**.
|
||||||
|
- **RG-4.13** : normalisation via `CarrierFieldNormalizer` (miroir Supplier) — `name` UPPER, contacts Capitalize, phones digits-only, email lower, `liotPlates` (`;`-split/trim/UPPER).
|
||||||
|
- **RG-4.12** : doublon `name` (parmi actifs) → **409** + `setError` ciblé.
|
||||||
|
- **RG-4.14** : PATCH `isArchived` exige `transport.carriers.archive` (Admin) ; mode strict → 403 sinon.
|
||||||
|
|
||||||
|
**Pièges** :
|
||||||
|
- Messages de validation **FR explicites** sur chaque contrainte (`EntityConstraintsHaveFrenchMessageTest`).
|
||||||
|
- Le back renvoie **toutes** les violations d'un coup avec `propertyPath` aligné sur les champs front.
|
||||||
|
|
||||||
|
**Scope STRICT** : `CarrierProcessor` + `CarrierFieldNormalizer` + contraintes sur l'entité `Carrier` (formulaire principal). **NE TOUCHE PAS** : les sous-ressources adresses/contacts/prix (WT6/7/8), `CarrierFixtures` (WT10), l'export (WT9). Ajoute tes contraintes sur `Carrier` sans réécrire l'ApiResource posée par WT3.
|
||||||
|
|
||||||
|
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||||
|
```bash
|
||||||
|
git push -u origin feat/erp-158-carrier-processor
|
||||||
|
tea pr create --base develop --head feat/erp-158-carrier-processor \
|
||||||
|
--title "feat(transport) : CarrierProcessor (RG-4.01→4.03/4.12→4.14) (ERP-158)" \
|
||||||
|
--description "Normalisation + champs conditionnels + archive. Ticket ERP-158."
|
||||||
|
```
|
||||||
|
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# WT5 — Endpoint QualimatCarrier lecture seule (ticket 1.4 / ERP-156)
|
||||||
|
|
||||||
|
> ```bash
|
||||||
|
> git fetch origin
|
||||||
|
> git worktree add ../sb-erp156-qualimat -b feat/erp-156-qualimat-search origin/develop
|
||||||
|
> cd ../sb-erp156-qualimat && claude
|
||||||
|
> ```
|
||||||
|
> **Base** : `origin/develop` **après merge de WT2** (permission `transport.carriers.view`) **et ERP-39** (table `qualimat_carrier` peuplée). **Indépendant de WT3** — peut tourner en parallèle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt à coller
|
||||||
|
|
||||||
|
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`.
|
||||||
|
|
||||||
|
**Mission** : exposer le référentiel QUALIMAT (table existante `qualimat_carrier`, alimentée par console) en **lecture seule** + endpoint de recherche pour la saisie assistée du nom (RG-4.01). **Ne touche pas** la commande de sync.
|
||||||
|
|
||||||
|
**Spec** : `spec-back.md § 4.7` + RG-4.01.
|
||||||
|
|
||||||
|
**À livrer** :
|
||||||
|
1. Entité `QualimatCarrier` (lecture seule) mappée sur la table existante `qualimat_carrier`. **Aucune écriture exposée** (pas de Post/Patch/Delete). Probablement pas `#[Auditable]` ni Timestampable (référentiel externe synchronisé) — vérifie le mapping existant.
|
||||||
|
2. `GET /api/qualimat_carriers?search=` : fuzzy sur `name` (+ `siret`), **seulement `is_active = true`**, tri `name`, **paginé** (règle n°13 — `CollectionsArePaginatedTest`).
|
||||||
|
3. **Security** `is_granted('transport.carriers.view')`.
|
||||||
|
4. Champs exposés : `id, siret, name, address, postalCode, city, phone, department, status, validityDate, isActive`.
|
||||||
|
|
||||||
|
**Tests à écrire** : recherche ne renvoie que les actifs ; pagination Hydra ; 403 sans permission ; tri `name`.
|
||||||
|
|
||||||
|
**Scope STRICT** : uniquement l'exposition lecture de `qualimat_carrier`. Ne crée rien autour de `Carrier` (autres worktrees). Si la table n'a pas de COMMENT (référentiel pré-existant), vérifie si elle est dans `EXCLUDED_TABLES` de `ColumnsHaveSqlCommentTest` — ne casse pas ce test.
|
||||||
|
|
||||||
|
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||||
|
```bash
|
||||||
|
git push -u origin feat/erp-156-qualimat-search
|
||||||
|
tea pr create --base develop --head feat/erp-156-qualimat-search \
|
||||||
|
--title "feat(transport) : endpoint recherche QualimatCarrier (ERP-156)" \
|
||||||
|
--description "Entité lecture seule + GET /api/qualimat_carriers?search=. Ticket ERP-156."
|
||||||
|
```
|
||||||
|
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# WT6 — Sous-ressource Adresses (ticket 1.7 / ERP-159)
|
||||||
|
|
||||||
|
> ```bash
|
||||||
|
> git fetch origin
|
||||||
|
> git worktree add ../sb-erp159-adresses -b feat/erp-159-carrier-addresses origin/develop
|
||||||
|
> cd ../sb-erp159-adresses && claude
|
||||||
|
> ```
|
||||||
|
> **Base** : `origin/develop` **après merge de WT3** (entités `CarrierAddress`). Parallèle à WT5/WT7/WT8/WT9.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt à coller
|
||||||
|
|
||||||
|
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`. **Miroir** : `SupplierAddressProcessor.php` (`src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/`).
|
||||||
|
|
||||||
|
**Mission** : opérations d'écriture sur les adresses transporteur.
|
||||||
|
|
||||||
|
**Spec** : `spec-back.md § 4.5` + RG-4.05→4.07.
|
||||||
|
|
||||||
|
**À livrer** :
|
||||||
|
- `POST /api/carriers/{id}/addresses`, `PATCH`/`DELETE /api/carrier_addresses/{id}` (security `manage`) — **resource/processor dédiés à `CarrierAddress`**, ne modifie pas l'ApiResource `Carrier`.
|
||||||
|
- **RG-4.06** : `postalCode` matche `^[0-9]{4,5}$` (autocomplete ville = front). Message FR.
|
||||||
|
- **RG-4.05** : si affrété → adresse obligatoire (Pays/CP/Ville/Adresse) — validation conditionnelle.
|
||||||
|
- RG-4.07 (bouton Valider masqué si QUALIMAT) = front ; côté back, accepter le PATCH normalement.
|
||||||
|
|
||||||
|
**Tests à écrire** : CP invalide → 422 ; adresse affrété incomplète → 422 ; PATCH/DELETE OK avec `manage`, 403 sans.
|
||||||
|
|
||||||
|
**Scope STRICT** : uniquement `CarrierAddress` (resource + processor + tests). **NE TOUCHE PAS** `CarrierFixtures` (WT10), l'entité `Carrier`, les autres sous-ressources. Messages de validation FR (`EntityConstraintsHaveFrenchMessageTest`).
|
||||||
|
|
||||||
|
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||||
|
```bash
|
||||||
|
git push -u origin feat/erp-159-carrier-addresses
|
||||||
|
tea pr create --base develop --head feat/erp-159-carrier-addresses \
|
||||||
|
--title "feat(transport) : sous-ressource adresses transporteur (ERP-159)" \
|
||||||
|
--description "POST/PATCH/DELETE carrier_address + RG-4.05→4.07. Ticket ERP-159."
|
||||||
|
```
|
||||||
|
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# WT7 — Sous-ressource Contacts (ticket 1.8 / ERP-160)
|
||||||
|
|
||||||
|
> ```bash
|
||||||
|
> git fetch origin
|
||||||
|
> git worktree add ../sb-erp160-contacts -b feat/erp-160-carrier-contacts origin/develop
|
||||||
|
> cd ../sb-erp160-contacts && claude
|
||||||
|
> ```
|
||||||
|
> **Base** : `origin/develop` **après merge de WT3**. Parallèle à WT5/WT6/WT8/WT9.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt à coller
|
||||||
|
|
||||||
|
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`. **Miroir** : `SupplierContactProcessor.php` (`src/Module/Commercial/…/State/Processor/`).
|
||||||
|
|
||||||
|
**Mission** : opérations d'écriture sur les contacts transporteur.
|
||||||
|
|
||||||
|
**Spec** : `spec-back.md § 4.5` + RG-4.08.
|
||||||
|
|
||||||
|
**À livrer** :
|
||||||
|
- `POST /api/carriers/{id}/contacts`, `PATCH`/`DELETE /api/carrier_contacts/{id}` (security `manage`) — resource/processor dédiés à `CarrierContact`.
|
||||||
|
- **RG-4.08** : bloc valide si **≥ 1 champ rempli** (CHECK `chk_carrier_contact_filled` côté migration WT3 + validation Processor) ; **max 2 téléphones**.
|
||||||
|
|
||||||
|
**Tests à écrire** : contact vide → 422 ; 1 champ → 200/201 ; 3ᵉ téléphone → 422.
|
||||||
|
|
||||||
|
**Scope STRICT** : uniquement `CarrierContact`. **NE TOUCHE PAS** `CarrierFixtures` (WT10), `Carrier`, les autres sous-ressources. Messages FR. Si le CHECK `chk_carrier_contact_filled` manque (WT3 ne l'a pas posé), valide côté Processor et signale-le à la conv maître.
|
||||||
|
|
||||||
|
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||||
|
```bash
|
||||||
|
git push -u origin feat/erp-160-carrier-contacts
|
||||||
|
tea pr create --base develop --head feat/erp-160-carrier-contacts \
|
||||||
|
--title "feat(transport) : sous-ressource contacts transporteur (ERP-160)" \
|
||||||
|
--description "POST/PATCH/DELETE carrier_contact + RG-4.08 (≥1 champ, max 2 tel). Ticket ERP-160."
|
||||||
|
```
|
||||||
|
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# WT8 — Sous-ressource Prix + RG branches (ticket 1.9 / ERP-161)
|
||||||
|
|
||||||
|
> ```bash
|
||||||
|
> git fetch origin
|
||||||
|
> git worktree add ../sb-erp161-prix -b feat/erp-161-carrier-prices origin/develop
|
||||||
|
> cd ../sb-erp161-prix && claude
|
||||||
|
> ```
|
||||||
|
> **Base** : `origin/develop` **après merge de WT3**. Parallèle à WT5/WT6/WT7/WT9.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt à coller
|
||||||
|
|
||||||
|
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`.
|
||||||
|
|
||||||
|
**Mission** : opérations d'écriture sur les prix transporteur, avec branches Client / Fournisseur.
|
||||||
|
|
||||||
|
**Spec** : `spec-back.md § 4.5 / 7` + RG-4.09→4.11.
|
||||||
|
|
||||||
|
**À livrer** :
|
||||||
|
- `POST /api/carriers/{id}/prices`, `PATCH`/`DELETE /api/carrier_prices/{id}` (security `manage`) — resource/processor dédiés à `CarrierPrice`.
|
||||||
|
- **RG-4.10 (CLIENT)** : `client`, `clientDeliveryAddress`, `departureSite` requis ; `clientDeliveryAddress` **doit appartenir au `client`** → sinon 422.
|
||||||
|
- **RG-4.11 (FOURNISSEUR)** : `supplier`, `supplierSupplyAddress`, `deliverySite` requis ; `supplierSupplyAddress` appartient au `supplier` → sinon 422.
|
||||||
|
- Communs obligatoires : `containerType`, `pricingUnit`, `price`, `priceState`. CHECK branches respectés.
|
||||||
|
|
||||||
|
**Rappels FK** : « Adresse départ/livraison 86/17/82 » = `Site` (FK). Livraison client = `ClientAddress`, appro = `SupplierAddress` (relations ORM partagées — pas de M2M).
|
||||||
|
|
||||||
|
**Tests à écrire** : branche CLIENT/FOURNISSEUR incomplète → 422 ; adresse étrangère au client/supplier → 422 ; prix valide → 201.
|
||||||
|
|
||||||
|
**Scope STRICT** : uniquement `CarrierPrice`. **NE TOUCHE PAS** `CarrierFixtures` (WT10), `Carrier`, les autres sous-ressources. Messages FR.
|
||||||
|
|
||||||
|
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||||
|
```bash
|
||||||
|
git push -u origin feat/erp-161-carrier-prices
|
||||||
|
tea pr create --base develop --head feat/erp-161-carrier-prices \
|
||||||
|
--title "feat(transport) : sous-ressource prix transporteur (ERP-161)" \
|
||||||
|
--description "POST/PATCH/DELETE carrier_price + RG-4.09→4.11 (branches client/fournisseur). Ticket ERP-161."
|
||||||
|
```
|
||||||
|
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# WT9 — Export XLSX (ticket 1.10 / ERP-162)
|
||||||
|
|
||||||
|
> ```bash
|
||||||
|
> git fetch origin
|
||||||
|
> git worktree add ../sb-erp162-export -b feat/erp-162-carrier-export origin/develop
|
||||||
|
> cd ../sb-erp162-export && claude
|
||||||
|
> ```
|
||||||
|
> **Base** : `origin/develop` **après merge de WT3** (lecture Carrier). Parallèle à WT5/WT6/WT7/WT8.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt à coller
|
||||||
|
|
||||||
|
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. **Miroir** : `src/Module/Commercial/Infrastructure/Controller/SupplierExportController.php` (PhpSpreadsheet déjà présent).
|
||||||
|
|
||||||
|
**Mission** : export Excel du répertoire et du tableau Prix regroupé.
|
||||||
|
|
||||||
|
**Spec** : `spec-back.md § 4.6`.
|
||||||
|
|
||||||
|
**À livrer** :
|
||||||
|
- `GET /api/carriers/export.xlsx` : transporteurs affichés (**mêmes filtres** que la liste) ; colonnes § 4.6.
|
||||||
|
- `GET /api/carriers/{id}/prices/export.xlsx` : tableau Prix regroupé Benne / Fond Mouvant (colonnes docx p.10).
|
||||||
|
- **Controllers custom** avec `#[Route(priority: 1)]` (sinon conflit API Platform `{id}`) ; en-tête `Content-Disposition`.
|
||||||
|
|
||||||
|
**Tests à écrire** : 200 + en-tête fichier (Content-Disposition + type XLSX) ; respect des filtres.
|
||||||
|
|
||||||
|
**Scope STRICT** : controllers d'export + service de génération. **NE TOUCHE PAS** entités, processors, `CarrierFixtures` (WT10). Réutilise le Provider/filtres de WT3 pour la cohérence des données exportées.
|
||||||
|
|
||||||
|
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||||
|
```bash
|
||||||
|
git push -u origin feat/erp-162-carrier-export
|
||||||
|
tea pr create --base develop --head feat/erp-162-carrier-export \
|
||||||
|
--title "feat(transport) : export XLSX répertoire + prix (ERP-162)" \
|
||||||
|
--description "GET /api/carriers/export.xlsx + /carriers/{id}/prices/export.xlsx. Ticket ERP-162."
|
||||||
|
```
|
||||||
|
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,354 @@
|
|||||||
|
---
|
||||||
|
# === IDENTITÉ ===
|
||||||
|
module: M4
|
||||||
|
nom: "Répertoire transporteurs"
|
||||||
|
ecran: repertoire-transporteurs
|
||||||
|
owner_spec: Matthieu
|
||||||
|
backup_spec: Tristan
|
||||||
|
version: V0.1
|
||||||
|
date_redaction: 2026-06-15
|
||||||
|
# Historique :
|
||||||
|
# V0.1 (2026-06-15) — Restitution Markdown du docx « M4-repertoire-transporteurs-V0 »
|
||||||
|
# (validé 27/05/2026) + maquette Figma (node 1132-45376). Précisions techniques (back)
|
||||||
|
# dans spec-back.md. Réutilise le pattern et les composants M1/M2/M3.
|
||||||
|
|
||||||
|
# === LIENS ===
|
||||||
|
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-45376&p=f&m=dev"
|
||||||
|
regles_metier: [RG-4.01, RG-4.02, RG-4.03, RG-4.04, RG-4.05, RG-4.06, RG-4.07, RG-4.08, RG-4.09, RG-4.10, RG-4.11]
|
||||||
|
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||||
|
lien_spec_back: ./spec-back.md
|
||||||
|
|
||||||
|
# === VALIDATION CLIENT ===
|
||||||
|
client_validation_1:
|
||||||
|
statut: validee
|
||||||
|
date: 2026-05-27
|
||||||
|
version: V0
|
||||||
|
valide_par: "Matthieu (CP MALIO)"
|
||||||
|
|
||||||
|
# === LIEN LESSTIME ===
|
||||||
|
lesstime_project_id: 6
|
||||||
|
lesstime_taskgroup_id: 31 # M4 — Répertoire transporteurs (tickets ERP-153 → ERP-171)
|
||||||
|
statut_global: pret_a_dev
|
||||||
|
---
|
||||||
|
|
||||||
|
# Module 4 — Répertoire transporteurs (V0.1 front)
|
||||||
|
|
||||||
|
> **Origine** : spec fonctionnelle `M4-repertoire-transporteurs-V0` (validée le 27/05/2026) + maquette Figma. Restitution Markdown pour intégration au workflow MALIO. Toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M4 réutilise le pattern et les composants posés aux [M1 clients](../M1-clients/spec-front.md), [M2 fournisseurs](../M2-suppliers/spec-front.md) et [M3 prestataires](../M3-prestataires/spec-front.md).
|
||||||
|
|
||||||
|
> **Socle déjà en place** : le module back `Transport` existe (ERP-150) et porte deux référentiels **synchronisés par commandes console** : transporteurs **QUALIMAT** (`qualimat_carrier`, ERP-39) et codes **IDTF** (`idtf_product`, ERP-149). Le M4 ajoute le **répertoire éditable** (`Carrier`) **par-dessus** ces référentiels — la saisie assistée du nom interroge le référentiel QUALIMAT (RG-4.01). L'IDTF n'est **pas** utilisé par ces écrans.
|
||||||
|
|
||||||
|
> **Décisions Matthieu (15/06/2026)** : (1) lien QUALIMAT = FK + **copie éditable** des champs (nom / certification / adresse) ; (2) **pas de cloisonnement par site** (référentiel global) ; (3) le champ « Décharge » s'appuie sur une **infra d'upload réutilisable** (`Shared`), car d'autres uploads suivront. Détails : [`spec-back.md § 2.5 / § 2.3 / § 2.7`](./spec-back.md).
|
||||||
|
|
||||||
|
## But
|
||||||
|
|
||||||
|
Lister tous les transporteurs de l'organisation et accéder rapidement à leurs fiches : consultation, création, modification, archivage. Le nom est **relié à QUALIMAT** (saisie assistée) ; les transporteurs hors QUALIMAT (GMP+, OVOCOM, compte-propre, LIOT, autre) sont saisis manuellement.
|
||||||
|
|
||||||
|
## Accès
|
||||||
|
|
||||||
|
- **Depuis** : menu principal → section **Transport** (route `/carriers`). *(Section « Transport » dédiée ou rattachement à une section « Logistique » — à confirmer, cf. [`spec-back.md § 5.3`](./spec-back.md).)*
|
||||||
|
- **Rôles autorisés** (tableau « Rôles & permissions » du docx) :
|
||||||
|
|
||||||
|
| Rôle | Consultation | Ajout / Modification | Archive |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Admin** | ✅ Tout | ✅ Tout | ✅ |
|
||||||
|
| **Bureau** | ✅ Tout | ✅ Tout | ❌ |
|
||||||
|
| **Compta** | ❌ | ❌ | ❌ |
|
||||||
|
| **Commerciale** | ✅ Tout | ❌ | ❌ |
|
||||||
|
| **Usine** | ❌ | ❌ | ❌ |
|
||||||
|
|
||||||
|
> **Notes** :
|
||||||
|
> - RBAC transposée sur `transport.carriers.*` (cf. [`spec-back.md § 5`](./spec-back.md)). **Commerciale** = consultation seule (pas de « + Ajouter » ni « Modifier »). **Compta** et **Usine** n'ont **aucun** accès au module (item sidebar masqué).
|
||||||
|
> - **Pas de cloisonnement par site** (≠ M3) : tout rôle autorisé voit tous les transporteurs.
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
Page d'entrée du module **Transport** (route `/carriers`). Titre : « **Répertoire transporteurs** ».
|
||||||
|
|
||||||
|
- Affichage principal : un **datatable** listant tous les transporteurs **actifs** (les archivés sont masqués par défaut — filtre dédié).
|
||||||
|
- **Clic sur une ligne** → écran **Consultation transporteur** (page dédiée).
|
||||||
|
- **Bouton « + Ajouter »** (haut droite, si `manage`) → écran **Ajouter un transporteur**.
|
||||||
|
- **Bouton « Filtrer »** (haut droite) → panneau de filtres.
|
||||||
|
- **Bouton « Exporter »** (haut droite) → télécharge un **XLSX** des transporteurs **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/M3. Filtres branchés sur les query params de `GET /api/carriers` (cf. [`spec-back.md § 4.1`](./spec-back.md)) :
|
||||||
|
|
||||||
|
| Filtre | Composant | Query param back |
|
||||||
|
|---|---|---|
|
||||||
|
| **Recherche** (nom) | `<MalioInputText>` | `?search=` |
|
||||||
|
| **Certification** | `<MalioSelectCheckbox>` (QUALIMAT / GMP+ / OVOCOM / Compte-propre / Autre) | `?certificationType=` |
|
||||||
|
| **Inclure les archivés** | `<MalioCheckbox>` | `?includeArchived=true` |
|
||||||
|
|
||||||
|
- À l'application des filtres → `setFilters(...)` de `usePaginatedList` (retombe en **page 1**).
|
||||||
|
- **État 100 % local** (jamais dans l'URL — règle ABSOLUE n°6).
|
||||||
|
|
||||||
|
## Datatable du Répertoire
|
||||||
|
|
||||||
|
Composant : `<MalioDataTable>` branché sur `usePaginatedList<Carrier>({ url: '/carriers' })` (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes :
|
||||||
|
|
||||||
|
| Colonne | Source | Tri |
|
||||||
|
|---|---|---|
|
||||||
|
| **Nom** | `carrier.name` | ASC par défaut |
|
||||||
|
| **Certification** | `carrier.certificationType` (libellé i18n) | Non |
|
||||||
|
| **Date de validité** | `carrier.qualimatCarrier.validityDate` (format `JJ-MM-AAAA`) — **fond rouge si < aujourd'hui** (RG-4.04) | Non |
|
||||||
|
| **Dernière activité** | `carrier.updatedAt` (format `JJ-MM-AAAA`) | Oui |
|
||||||
|
|
||||||
|
> **Clic sur une ligne** → écran Consultation. **Pagination** : standard Starseed 10 / 25 / 50 (défaut 10). Tri serveur `name ASC` par défaut.
|
||||||
|
|
||||||
|
## Écran « Ajouter un transporteur »
|
||||||
|
|
||||||
|
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. **L'onglet Adresses n'est accessible qu'une fois le formulaire principal validé.** Cf. [`spec-back.md § 2.9`](./spec-back.md) (PATCH partiels par groupe de sérialisation).
|
||||||
|
|
||||||
|
**Accès** : bouton « + Ajouter » du Répertoire. **Rôles** : Admin, Bureau.
|
||||||
|
|
||||||
|
**Barre d'onglets** : `Qualimat` · `Adresses` · `Contacts` · `Prix`.
|
||||||
|
|
||||||
|
### Formulaire principal (pré-onglets)
|
||||||
|
|
||||||
|
1er bloc à remplir. Sans validation, les onglets ne sont pas accessibles. Une fois validé → POST `/api/carriers`, puis bascule sur l'onglet Qualimat/Adresses ; les champs passent en readonly.
|
||||||
|
|
||||||
|
| Champ | Type composant | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Nom** (saisie assistée reliée à QUALIMAT) | `<MalioInputText>` (autocomplete) | Oui | RG-4.01 ; RG-4.13 (UPPERCASE serveur) ; RG-4.12 (unicité) |
|
||||||
|
| **Liste certification transport** | `<MalioSelect>` (GMP+ / OVOCOM / Compte-propre / Autre) | Oui | RG-4.02 ; auto = `QUALIMAT` (lecture seule) si transporteur QUALIMAT sélectionné |
|
||||||
|
| **Affréter** | `<MalioCheckbox>` | Non | RG-4.03 |
|
||||||
|
| **Indexation %** | `<MalioInputNumber>` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché |
|
||||||
|
| **Benne / Fond mouvant** | `<MalioRadioButton>` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché |
|
||||||
|
| **Volume m³** | `<MalioInputNumber>` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché |
|
||||||
|
| **Décharge** | `<MalioInputUpload>` *(cf. note)* | Conditionnel (**obligatoire si AUTRE**) | RG-4.02 — visible **et obligatoire** si certification = `AUTRE`. Upload via infra Shared ([`spec-back.md § 2.7`](./spec-back.md)) |
|
||||||
|
| **Liste immatriculation LIOT** | `<MalioInputText>` (ou TextArea) | Cas LIOT | RG-4.01 — visible **uniquement** si nom = `LIOT` ; les autres champs disparaissent. Immatriculations séparées par `;` |
|
||||||
|
|
||||||
|
> **Comportement RG-4.01 (saisie assistée)** : à la saisie du nom, recherche dans le référentiel QUALIMAT via `GET /api/qualimat_carriers?search=`. Sélection d'un résultat → **modal de confirmation** « Êtes-vous sûr de vouloir intégrer ce transporteur ? ». Si confirmé : le **Nom** et la **certification** (= `QUALIMAT`, lecture seule) se remplissent automatiquement, **ainsi que l'onglet Adresse** (copie pays/CP/ville/voie depuis le référentiel). La FK QUALIMAT est conservée (traçabilité + date de validité RG-4.04).
|
||||||
|
> - **Cas transporteur non trouvé** (pas QUALIMAT) : l'utilisateur choisit une autre certification (RG-4.02) → affichage des champs associés.
|
||||||
|
> - **Cas LIOT** : si le nom saisi est exactement `LIOT`, seul le champ « Liste immatriculation LIOT » s'affiche, les autres champs sont masqués.
|
||||||
|
|
||||||
|
> **Note `<MalioInputUpload>`** : si le composant ne couvre pas le drag & drop / type fichier requis, exception autorisée documentée (`// TODO migrer quand Malio couvre`) — cf. exceptions @.claude/rules/frontend.md.
|
||||||
|
|
||||||
|
**Action** : « Valider » (`<MalioButton>`) → POST `/api/carriers` ([`spec-back.md § 4.3`](./spec-back.md)). Succès → onglet « Qualimat » / « Adresses ».
|
||||||
|
|
||||||
|
### Onglet « Qualimat »
|
||||||
|
|
||||||
|
Sélectionner un transporteur de la liste QUALIMAT afin de mettre à jour les informations du transporteur (saisie assistée — voir RG-4.01).
|
||||||
|
|
||||||
|
**Colonnes du tableau de sélection** :
|
||||||
|
|
||||||
|
| Colonne | Règle |
|
||||||
|
|---|---|
|
||||||
|
| **Sélection** (bouton / clic ligne) | RG-4.03 *(docx)* — clic → modal « Êtes-vous sûr de vouloir intégrer ce transporteur ? » → remplit Nom + certification + onglet adresse |
|
||||||
|
| **Nom** | — |
|
||||||
|
| **Adresse** | — |
|
||||||
|
| **Date de validité** | RG-4.04 — **fond rouge si < date du jour** |
|
||||||
|
|
||||||
|
> Cet onglet alimente le formulaire principal et l'onglet Adresse par copie (RG-4.01 / RG-4.05). Source : `GET /api/qualimat_carriers?search=` (lecture seule, lignes actives uniquement).
|
||||||
|
|
||||||
|
### Onglet « Adresses »
|
||||||
|
|
||||||
|
Saisir l'adresse du transporteur (un bloc par adresse).
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Pays** | `<MalioSelect>` (préremplie « France ») | Conditionnel | RG-4.05 |
|
||||||
|
| **Code postal** | `<MalioInputText>` (saisie assistée) | Conditionnel | RG-4.06, RG-4.05 — déclenche autocomplete ville (BAN) |
|
||||||
|
| **Ville** | `<MalioSelect>` (saisie assistée) | Conditionnel | RG-4.06, RG-4.05 — alimentée par api-adresse.data.gouv.fr |
|
||||||
|
| **Adresse** | `<MalioInputText>` (saisie assistée) | Conditionnel | RG-4.05 |
|
||||||
|
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
|
||||||
|
|
||||||
|
> **RG-4.05** : les champs sont **déjà remplis** si le transporteur est QUALIMAT (copie). Si « Affréter » est coché, l'adresse devient **obligatoire** (Pays, Code postal, Ville, Adresse).
|
||||||
|
> **RG-4.06** : la ville est préremplie automatiquement à partir du code postal via l'API BAN (`useAddressAutocomplete()`, réutilisé M1/M2/M3). Si plusieurs villes → choix dans le select. L'adresse est une saisie assistée basée sur le CP et la ville.
|
||||||
|
> **RG-4.07** : le bouton « Valider » **n'apparaît pas** pour un transporteur QUALIMAT (adresse remplie automatiquement).
|
||||||
|
|
||||||
|
**Actions** : « Valider » → PATCH `/api/carriers/{id}/addresses` (sauf QUALIMAT, RG-4.07).
|
||||||
|
|
||||||
|
### Onglet « Contacts »
|
||||||
|
|
||||||
|
Saisir un ou plusieurs contacts associés au transporteur.
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Nom** | `<MalioInputText>` | Non | RG-4.08 + RG-4.13 (Capitalize) |
|
||||||
|
| **Prénom** | `<MalioInputText>` | Non | RG-4.08 + RG-4.13 (Capitalize) |
|
||||||
|
| **Fonction** | `<MalioInputText>` | Non | RG-4.08 |
|
||||||
|
| **Téléphone** (x1, +1 possible, **max 2**) | `<MalioInputText>` | Non | RG-4.08 + RG-4.13 (format) |
|
||||||
|
| **Email** | `<MalioInputText>` type email | Non | RG-4.08 + RG-4.13 (lowercase) |
|
||||||
|
|
||||||
|
**RG-4.08** : un bloc Contact est valide dès qu'au moins 1 champ est rempli. Impossible d'ajouter un nouveau bloc tant que le précédent n'est pas valide.
|
||||||
|
|
||||||
|
**Actions** :
|
||||||
|
- « + Nouveau contact » : ajoute un bloc. **Désactivé tant que le bloc précédent n'a aucun champ rempli** (RG-4.08).
|
||||||
|
- « Supprimer » (icône) : modal de confirmation, puis suppression du bloc.
|
||||||
|
- « Valider » → PATCH `/api/carriers/{id}/contacts`.
|
||||||
|
|
||||||
|
### Onglet « Prix »
|
||||||
|
|
||||||
|
Saisir un suivi de prix du transporteur (un bloc par prix). Tous les champs sont masqués par défaut sauf le radio « Client / Fournisseur » (RG-4.09).
|
||||||
|
|
||||||
|
**Bloc Prix** :
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Client / Fournisseur** | `<MalioRadioButton>` | Oui | RG-4.09 |
|
||||||
|
| **Client** | `<MalioSelect>` (liste des clients) | Conditionnel | RG-4.10 — si Client |
|
||||||
|
| **Adresse de livraison** | `<MalioSelect>` (adresses du client sélectionné) | Conditionnel | RG-4.10 — si Client |
|
||||||
|
| **Adresse de départ** | `<MalioSelect>` (86 / 17 / 82) | Conditionnel | RG-4.10 — si Client ; = un des 3 sites |
|
||||||
|
| **Fournisseur** | `<MalioSelect>` (liste des fournisseurs) | Conditionnel | RG-4.11 — si Fournisseur |
|
||||||
|
| **Adresse d'approvisionnement** | `<MalioSelect>` (adresses du fournisseur) | Conditionnel | RG-4.11 — si Fournisseur |
|
||||||
|
| **Adresse de livraison** | `<MalioSelect>` (86 / 17 / 82) | Conditionnel | RG-4.11 — si Fournisseur ; = un des 3 sites |
|
||||||
|
| **Benne / Fond mouvant (FM)** | `<MalioRadioButton>` | Oui | — |
|
||||||
|
| **Forfait / Tonne** | `<MalioRadioButton>` | Oui | — |
|
||||||
|
| **Prix** | `<MalioInputAmount>` (monnaie) | Oui | — |
|
||||||
|
| **État du prix** | `<MalioSelect>` (En cours / Validé / Non validé) | Oui | — |
|
||||||
|
|
||||||
|
> **RG-4.10** : si **Client** sélectionné → champs liés au client affichés et obligatoires ; champs fournisseur masqués et non obligatoires.
|
||||||
|
> **RG-4.11** : si **Fournisseur** sélectionné → champs liés au fournisseur affichés et obligatoires ; champs client masqués et non obligatoires.
|
||||||
|
> **Adresse de départ / livraison « 86 / 17 / 82 »** = les 3 `Site` fixes (cf. switcher de site Châtellerault / Saint-Jean / Pommevic en haut de l'app). La sélection stocke un **ID de Site** ([`spec-back.md § 3.2`](./spec-back.md)).
|
||||||
|
|
||||||
|
**Actions** :
|
||||||
|
- « + Nouveau prix » : ajoute un bloc. Bloqué tant que le précédent n'est pas valide.
|
||||||
|
- « Supprimer » (icône) : modal de confirmation puis suppression.
|
||||||
|
- « Valider » → PATCH `/api/carriers/{id}/prices`.
|
||||||
|
|
||||||
|
## Écran « Consultation d'un transporteur »
|
||||||
|
|
||||||
|
Consulter en **lecture seule** la fiche complète. Affiche en haut du bloc les infos principales du transporteur (comme l'écran d'ajout) ainsi que les onglets Adresses, Contacts, Prix. **Tous les champs sont en lecture seule.**
|
||||||
|
|
||||||
|
**Accès** : clic sur une ligne du Répertoire. La page s'ouvre par défaut sur l'onglet **Adresses**. Icône « flèche » à gauche pour revenir au répertoire. Deux boutons à droite :
|
||||||
|
- **« Modifier »** (visible si `transport.carriers.manage` → Admin, Bureau).
|
||||||
|
- **« Archiver »** (visible **uniquement Admin** via `transport.carriers.archive`) → modal de confirmation, puis PATCH `/api/carriers/{id}` `{ "isArchived": true }`.
|
||||||
|
|
||||||
|
> Un transporteur archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé.
|
||||||
|
|
||||||
|
### Onglet Adresses (consultation)
|
||||||
|
|
||||||
|
Un bloc par adresse du transporteur. Chaque bloc, 5 champs en lecture seule : Pays / Code postal / Ville / Adresse / Adresse complémentaire.
|
||||||
|
|
||||||
|
### Onglet Contacts (consultation)
|
||||||
|
|
||||||
|
Un bloc par contact. 5 champs en lecture seule : Nom / Prénom / Fonction / Téléphone (x1 ou x2) / Email.
|
||||||
|
|
||||||
|
### Onglet Prix (consultation)
|
||||||
|
|
||||||
|
Un tableau regroupant les prix par type (**Fond Mouvant / Benne**) :
|
||||||
|
|
||||||
|
| Colonne | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Colonne de regroupement** | « Fond Mouvant » / « Benne » |
|
||||||
|
| **Transporteurs** | Nom du transporteur |
|
||||||
|
| **Adresse APRO ou Adresse Sites** | Si prix « Client » → Adresse APRO sinon Adresse Sites |
|
||||||
|
| **Adresse livraisons** | — |
|
||||||
|
| **Forfait €** | Prix |
|
||||||
|
| **Tonne €** | Prix |
|
||||||
|
| **Indexation** | Pourcentage d'indexation (vide si non rempli) |
|
||||||
|
| **État du prix** | Validé / Non Validé / En cours |
|
||||||
|
|
||||||
|
**Action** : « Exporter » → exporte le tableau au **format Excel** (`GET /api/carriers/{id}/prices/export.xlsx`).
|
||||||
|
|
||||||
|
## Écran « Modification d'un transporteur »
|
||||||
|
|
||||||
|
Modifier les informations d'un transporteur existant. **Identique à l'écran « Ajouter un transporteur »** — mêmes formulaires, mêmes règles métier (RG-4.01 à RG-4.11) — sauf :
|
||||||
|
- Les champs sont **pré-remplis** avec les valeurs actuelles.
|
||||||
|
- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel).
|
||||||
|
- **Accès** : depuis l'écran Consultation, bouton « Modifier » (Admin, Bureau).
|
||||||
|
|
||||||
|
## Composants UI à utiliser (`@malio/layer-ui`)
|
||||||
|
|
||||||
|
- **Datatable** : `<MalioDataTable>` (+ `usePaginatedList`)
|
||||||
|
- **Input texte** : `<MalioInputText>`
|
||||||
|
- **Input nombre / montant** : `<MalioInputNumber>` (indexation, volume), `<MalioInputAmount>` (prix)
|
||||||
|
- **Select simple** : `<MalioSelect>` (certification, pays, ville, client, fournisseur, adresses, sites, état du prix)
|
||||||
|
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (filtres certification)
|
||||||
|
- **Radio** : `<MalioRadioButton>` (Benne/Fond mouvant, Forfait/Tonne, Client/Fournisseur)
|
||||||
|
- **Checkbox** : `<MalioCheckbox>` (Affréter, inclure archivés)
|
||||||
|
- **Upload** : `<MalioInputUpload>` (Décharge — exception documentée si type non couvert)
|
||||||
|
- **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 : wrapper partagé dans `frontend/shared/` (réutiliser celui du M1/M2/M3).
|
||||||
|
- `<MalioInputUpload>` si le type fichier / drag & drop n'est pas couvert.
|
||||||
|
|
||||||
|
## Composables & appels API
|
||||||
|
|
||||||
|
- `usePaginatedList<Carrier>({ url: '/carriers' })` — liste paginée (obligatoire). Consomme `name`, `certificationType`, `qualimatCarrier.validityDate` (RG-4.04), `updatedAt` (cf. [`spec-back.md § 2.11 / § 4.0`](./spec-back.md)).
|
||||||
|
- `useCarrier(id)` — charge le détail via `GET /api/carriers/{id}`, qui **embarque** `addresses`, `contacts`, `prices` (avec `client`/`supplier`/sites imbriqués) + `qualimatCarrier`. Écrans Consultation et Modification peuplés depuis cette seule réponse. **DoD avant intégration** : vérifier le JSON réel (cf. [`spec-back.md § 4.0.bis`](./spec-back.md)).
|
||||||
|
- `useCarrierForm()` — workflow par onglet (POST principal + PATCH partiels par groupe), miroir de `useSupplierForm()`/`useProviderForm()` + gestion des **champs conditionnels** (Affréter, AUTRE→Décharge, cas LIOT).
|
||||||
|
- `useQualimatSearch()` — saisie assistée du nom : `GET /api/qualimat_carriers?search=`, modal de confirmation, copie des champs + FK (RG-4.01).
|
||||||
|
- `useAddressAutocomplete()` — **réutilisé** du M1/M2/M3 (BAN), pas de réécriture (RG-4.06).
|
||||||
|
- `useUpload()` (NOUVEAU, infra Shared) — POST multipart `/api/uploaded_documents` → renvoie l'IRI à poser sur `carrier.dischargeDocument` (RG-4.02).
|
||||||
|
- `usePermissions()` — masque l'item sidebar et les boutons selon les permissions.
|
||||||
|
- 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-4.13 — cf. [`spec-back.md`](./spec-back.md)) :
|
||||||
|
|
||||||
|
| Champ | Normalisation serveur | Affichage front |
|
||||||
|
|---|---|---|
|
||||||
|
| Nom transporteur (`name`) | UPPERCASE intégral | UPPERCASE |
|
||||||
|
| Nom + Prénom contact | Capitalize | identique |
|
||||||
|
| Téléphones (`CarrierContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` (filter Vue) |
|
||||||
|
| Email | lowercase intégral | identique |
|
||||||
|
| Immatriculations LIOT | `;`-split, trim, UPPER | listées |
|
||||||
|
|
||||||
|
> 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/M3** (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-4.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 les modules précédents
|
||||||
|
|
||||||
|
| Zone | M2/M3 | M4 transporteurs |
|
||||||
|
|---|---|---|
|
||||||
|
| Source du nom | saisie libre | **saisie assistée reliée à QUALIMAT** (référentiel synchronisé) |
|
||||||
|
| Onglet Comptabilité / RIB | présent (M2/M3) | **Absent** |
|
||||||
|
| Cloisonnement par site | M3 : oui | **Non** (référentiel global) |
|
||||||
|
| Champs conditionnels formulaire principal | peu | **Nombreux** (Affréter, AUTRE→Décharge, cas LIOT) |
|
||||||
|
| Onglet Prix | absent | **Présent** (Client/Fournisseur, sites départ/livraison) |
|
||||||
|
| Upload de fichier | aucun | **Décharge** (infra upload Shared, réutilisable) |
|
||||||
|
| Module | Commercial / Technique | **Transport** (existant, ERP-150) |
|
||||||
|
|
||||||
|
## Points résolus côté back
|
||||||
|
|
||||||
|
| # | Zone d'ombre | Résolution (cf. `spec-back.md`) |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Lien QUALIMAT | FK `qualimatCarrier` + **copie éditable** des champs (§ 2.5) |
|
||||||
|
| 2 | Cas LIOT | Champ `liotPlates` (`;`-séparé), autres champs masqués (RG-4.01) |
|
||||||
|
| 3 | Certification QUALIMAT | Valeur `QUALIMAT` lecture seule si lié (§ 2.5) |
|
||||||
|
| 4 | Décharge (upload) | Infra upload générique `Shared` réutilisable (§ 2.7) |
|
||||||
|
| 5 | Onglet Prix — branches | M2M absentes : FK Client/Supplier + adresses + sites (RG-4.10/4.11, § 3.2) |
|
||||||
|
| 6 | Adresse de départ/livraison 86/17/82 | = les 3 `Site` fixes (FK Site) |
|
||||||
|
| 7 | Workflow par onglet | Sauvegarde incrémentale (POST principal + PATCH partiels) — pas d'état « draft » |
|
||||||
|
| 8 | Archive vs delete | Flag `is_archived` séparé ; archivage Admin seul ; soft delete = HP |
|
||||||
|
| 9 | Unicité métier | Nom seul (§ 2.6) |
|
||||||
|
| 10 | Référentiel QUALIMAT | Endpoint lecture seule `GET /api/qualimat_carriers?search=` (§ 4.7) |
|
||||||
|
| 11 | Format export | XLSX (répertoire + onglet Prix regroupé Benne/FM) |
|
||||||
|
| 12 | RBAC | `transport.carriers.view/manage/archive` ; Compta + Usine sans accès (§ 5.2) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Tickets Lesstime
|
||||||
|
|
||||||
|
**TaskGroup Lesstime** : à créer — `M4 — Répertoire transporteurs` (projet `ERP / Starseed`, projectId=6). Découpe détaillée (back en tête) → [`spec-back.md § Tickets Lesstime`](./spec-back.md#-tickets-lesstime-à-découper).
|
||||||
|
|
||||||
|
| Ordre | Sujet | Tag |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | Permissions `transport.carriers.*` + sidebar + 3 sources RBAC | Backend |
|
||||||
|
| 1 | Infra upload générique `Shared` (uploaded_document + FileUploader + endpoint) | Backend |
|
||||||
|
| 2 | Migration BDD M4 (carrier + sous-collections + index + COMMENT) | Backend |
|
||||||
|
| 3 | Entité `QualimatCarrier` (lecture seule) + endpoint recherche | Backend |
|
||||||
|
| 4 | Entités + Repositories Carrier* | Backend |
|
||||||
|
| 5 | CarrierProvider + CarrierProcessor (champs conditionnels, archive, LIOT) | Backend |
|
||||||
|
| 6 | Sous-ressources Adresses / Contacts / Prix (RG-4.10/4.11) | Backend |
|
||||||
|
| 7 | Export XLSX (répertoire + onglet Prix) | Backend |
|
||||||
|
| 8 | Tests PHPUnit RG-4.01→4.14 + capture contrat JSON | Backend |
|
||||||
|
| 9 | Page Répertoire (`/carriers`) + usePaginatedList | Frontend |
|
||||||
|
| 10 | Page Ajouter + formulaire principal + saisie assistée QUALIMAT | Frontend |
|
||||||
|
| 11 | Onglets Adresses (BAN) / Contacts / Prix | Frontend |
|
||||||
|
| 12 | Pages Consultation + Modification | Frontend |
|
||||||
|
| 13 | i18n + libellés audit + upload front (useUpload) | Frontend |
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
# M4 — Répertoire transporteurs · Découpe en tickets Lesstime
|
||||||
|
|
||||||
|
> **Statut** : ✅ **poussé dans Lesstime** — TaskGroup **#31 « M4 — Répertoire transporteurs »** (projet STARSEED), 19 tickets **ERP-153 → ERP-171** au statut **Prêt à dev**.
|
||||||
|
> **Assignation** : tickets **Backend (1.1→1.11, ERP-153→163) → Matthieu** · tickets **Frontend (1.12→1.19, ERP-164→171) → Tristan**.
|
||||||
|
>
|
||||||
|
> | Pos | Ticket | Réf |
|
||||||
|
> |---|---|---|
|
||||||
|
> | 1.1 | Permissions transport.carriers.* + sidebar | ERP-153 |
|
||||||
|
> | 1.2 | Infra upload générique Shared | ERP-154 |
|
||||||
|
> | 1.3 | Migration BDD M4 | ERP-155 |
|
||||||
|
> | 1.4 | QualimatCarrier + endpoint recherche | ERP-156 |
|
||||||
|
> | 1.5 | Entités Carrier* + ApiResource + Provider | ERP-157 |
|
||||||
|
> | 1.6 | CarrierProcessor (RG-4.01/02/03 + LIOT) | ERP-158 |
|
||||||
|
> | 1.7 | Sous-ressource Adresses | ERP-159 |
|
||||||
|
> | 1.8 | Sous-ressource Contacts | ERP-160 |
|
||||||
|
> | 1.9 | Sous-ressource Prix + branches | ERP-161 |
|
||||||
|
> | 1.10 | Export XLSX | ERP-162 |
|
||||||
|
> | 1.11 | Tests PHPUnit + contrat JSON | ERP-163 |
|
||||||
|
> | 1.12 | Page Répertoire /carriers | ERP-164 |
|
||||||
|
> | 1.13 | Page Ajouter (layout + formulaire) | ERP-165 |
|
||||||
|
> | 1.14 | Saisie assistée QUALIMAT + conditionnels | ERP-166 |
|
||||||
|
> | 1.15 | Onglet Adresses (BAN) | ERP-167 |
|
||||||
|
> | 1.16 | Onglet Contacts | ERP-168 |
|
||||||
|
> | 1.17 | Onglet Prix | ERP-169 |
|
||||||
|
> | 1.18 | Consultation + Modification | ERP-170 |
|
||||||
|
> | 1.19 | Upload front + i18n + audit | ERP-171 |
|
||||||
|
> **Specs sources** : [`spec-back.md`](./spec-back.md) · [`spec-front.md`](./spec-front.md) — validées (docx V0 du 27/05/2026).
|
||||||
|
> **Maquette Figma** : node `1132-45376` ([lien](https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-45376&p=f&m=dev)).
|
||||||
|
|
||||||
|
## ⚠️ Dépendance amont (socle Tristan — en cours de merge)
|
||||||
|
|
||||||
|
Le M4 s'appuie sur le module `Transport` et le référentiel QUALIMAT, livrés par les PR de Tristan **en cours de merge** dans `develop` :
|
||||||
|
|
||||||
|
- **ERP-150** (PR #97) — module `Transport` (`TransportModule`, layer front, `config/modules.php`). **Requis** par tout le M4.
|
||||||
|
- **ERP-39** (PR #99) — sync QUALIMAT (`qualimat_carrier` + commande `app:qualimat:sync`). **Requis** par la saisie assistée (ticket 1.4).
|
||||||
|
- **ERP-149** (PR #101) — sync IDTF (`idtf_product`). **NON requis** par le M4 (référentiel autonome, hors écrans transporteurs).
|
||||||
|
|
||||||
|
> Les 3 PR sont **empilées** (`develop → ERP-150 → ERP-39 → ERP-149`). Démarrer le M4 une fois **ERP-150 + ERP-39 dans `develop`** (DoR des tickets 1.1 et 1.4). Brancher le M4 sur `develop` post-merge.
|
||||||
|
|
||||||
|
## Vue d'ensemble (ordre d'exécution)
|
||||||
|
|
||||||
|
| # | Ticket | Tag | Effort | RG / dépend |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1.1 | Déclarer permissions `transport.carriers.*` + sidebar | Backend | S | DoR : ERP-150 mergé |
|
||||||
|
| 1.2 | Créer l'infra d'upload générique `Shared` | Backend | M | § 2.7 |
|
||||||
|
| 1.3 | Migrer le schéma BDD M4 (carrier + sous-tables) | Backend | M | § 3.2 |
|
||||||
|
| 1.4 | Exposer `QualimatCarrier` (lecture seule) + endpoint recherche | Backend | S | RG-4.01 · DoR : ERP-39 mergé |
|
||||||
|
| 1.5 | Créer entités `Carrier*` + repos + `ApiResource` + `CarrierProvider` | Backend | M | § 3.3 / 4.0 |
|
||||||
|
| 1.6 | Implémenter `CarrierProcessor` (RG-4.01/4.02/4.03 + LIOT + normalisation + archive) | Backend | M | RG-4.01→4.03, 4.13, 4.14 |
|
||||||
|
| 1.7 | Sous-ressource Adresses (`carrier_address`) | Backend | S | RG-4.05→4.07 |
|
||||||
|
| 1.8 | Sous-ressource Contacts (`carrier_contact`) | Backend | S | RG-4.08 |
|
||||||
|
| 1.9 | Sous-ressource Prix (`carrier_price`) + RG branches | Backend | M | RG-4.09→4.11 |
|
||||||
|
| 1.10 | Export XLSX (répertoire + onglet Prix regroupé) | Backend | M | § 4.6 |
|
||||||
|
| 1.11 | Tests PHPUnit RG-4.01→4.14 + capture contrat JSON (DoD) | Backend | M | § 4.0.bis / 8.1 |
|
||||||
|
| 1.12 | Page Répertoire `/carriers` (datatable, filtres, export) | Frontend | M | RG-4.04 |
|
||||||
|
| 1.13 | Page Ajouter `/carriers/new` (layout, onglets, formulaire principal POST) | Frontend | M | RG-4.12 |
|
||||||
|
| 1.14 | Saisie assistée QUALIMAT + champs conditionnels (Affréter / AUTRE→Décharge / LIOT) | Frontend | M | RG-4.01→4.03 |
|
||||||
|
| 1.15 | Onglet Adresses (autocomplete BAN) | Frontend | M | RG-4.05→4.07 |
|
||||||
|
| 1.16 | Onglet Contacts | Frontend | S | RG-4.08 |
|
||||||
|
| 1.17 | Onglet Prix (Client/Fournisseur, sites) | Frontend | M | RG-4.09→4.11 |
|
||||||
|
| 1.18 | Pages Consultation + Modification | Frontend | M | — |
|
||||||
|
| 1.19 | Upload front (`useUpload`) + i18n + libellés audit | Frontend | S | § 2.8 |
|
||||||
|
|
||||||
|
**Total** : 19 tickets · ~11 back / 8 front · mini-MR de 1 à 4h.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tickets — détail
|
||||||
|
|
||||||
|
### 1.1 — Déclarer permissions `transport.carriers.*` + sidebar
|
||||||
|
**Position** : 1.1 • Suit : — • Précède : Migrer le schéma BDD M4
|
||||||
|
**Tag** : Backend • **Effort** : S
|
||||||
|
**Contexte** : `TransportModule::permissions()` renvoie aujourd'hui `[]`. Ce ticket pose le socle RBAC du module et son entrée de menu, prérequis de toute opération sécurisée.
|
||||||
|
**Spec liée** : [`spec-back.md § 5`](./spec-back.md) · [`spec-front.md § Accès`](./spec-front.md)
|
||||||
|
**Critères d'acceptation** :
|
||||||
|
- [ ] `TransportModule::permissions()` déclare `transport.carriers.view`, `transport.carriers.manage`, `transport.carriers.archive` ; `app:sync-permissions` les enregistre.
|
||||||
|
- [ ] **Matrice § 5.2** : Admin (view+manage+archive), Bureau (view+manage), Commerciale (view), Compta + Usine (aucune).
|
||||||
|
- [ ] **3 sources RBAC alignées dans le même commit** (règle ABSOLUE n°8) : `config/sidebar.php` (section Transport + item `/carriers` + permission), `personas.ts`, `SeedE2ECommand.php`.
|
||||||
|
- [ ] Item sidebar masqué pour Compta/Usine ; visible Admin/Bureau/Commerciale.
|
||||||
|
**Tests à prévoir** : permissions sync OK ; personas e2e cohérents (pas de drift).
|
||||||
|
**Tips** : DoR — ERP-150 mergé (module Transport présent). Section sidebar « Transport » (ou « Logistique » — à trancher, cosmétique).
|
||||||
|
|
||||||
|
### 1.2 — Créer l'infra d'upload générique `Shared`
|
||||||
|
**Position** : 1.2 • Suit : permissions • Précède : Migration M4
|
||||||
|
**Tag** : Backend • **Effort** : M
|
||||||
|
**Contexte** : la « Décharge » (RG-4.02) est le 1er d'une série d'uploads à venir. On pose une infra réutilisable, pas un upload ad hoc.
|
||||||
|
**Spec liée** : [`spec-back.md § 2.7`](./spec-back.md)
|
||||||
|
**Critères d'acceptation** :
|
||||||
|
- [ ] Table `uploaded_document` (`original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum`, `created_at`, `created_by`) + COMMENT ON COLUMN.
|
||||||
|
- [ ] Service `Shared\Infrastructure\Upload\FileUploader` : validation MIME **server-side via `$file->getMimeType()`** (jamais `getClientMimeType()`), bornage taille, checksum sha256, écriture disque (`var/uploads/{yyyy}/{mm}/`).
|
||||||
|
- [ ] Endpoint `POST /api/uploaded_documents` (multipart) → renvoie l'IRI ; whitelist MIME explicite (PDF + images) ; hors whitelist → 422.
|
||||||
|
**Tests à prévoir** : PHPUnit — MIME hors whitelist → 422 ; MIME valide → IRI + ligne persistée ; checksum calculé.
|
||||||
|
**Tips** : générique et réutilisable (autres modules la consommeront). Antivirus / S3 / purge = HP (§ 9).
|
||||||
|
|
||||||
|
### 1.3 — Migrer le schéma BDD M4 (carrier + sous-tables)
|
||||||
|
**Position** : 1.3 • Suit : infra upload • Précède : QualimatCarrier
|
||||||
|
**Tag** : Backend • **Effort** : M
|
||||||
|
**Contexte** : créer le schéma du répertoire (entité éditable distincte du référentiel `qualimat_carrier`).
|
||||||
|
**Spec liée** : [`spec-back.md § 3.2`](./spec-back.md)
|
||||||
|
**Critères d'acceptation** :
|
||||||
|
- [ ] Migration namespace racine `DoctrineMigrations`, **postérieure** à `Version20260612160000`.
|
||||||
|
- [ ] Tables `carrier`, `carrier_address`, `carrier_contact`, `carrier_price` + FK (`qualimat_carrier`, `uploaded_document`, `client`, `client_address`, `supplier`, `supplier_address`, `site`, `user`).
|
||||||
|
- [ ] `certification_type` **nullable** (null seulement en cas LIOT) + CHECK enum ; CHECK `container_type`, `direction`, `pricing_unit`, `price_state`, branches Prix client/fournisseur.
|
||||||
|
- [ ] Index partiel `uq_carrier_name_active` (LOWER(name), WHERE non archivé & non supprimé).
|
||||||
|
- [ ] **`COMMENT ON COLUMN` sur TOUTES les colonnes** (règle n°12) + helper Timestampable/Blamable. `ColumnsHaveSqlCommentTest` vert.
|
||||||
|
- [ ] `make db-reset` passe ; schéma conforme.
|
||||||
|
**Tests à prévoir** : `make db-reset` OK ; `ColumnsHaveSqlCommentTest` vert ; index partiel présent.
|
||||||
|
**Tips** : PK `BIGINT` (cohérence module Transport) — à confirmer vs `INT`.
|
||||||
|
|
||||||
|
### 1.4 — Exposer `QualimatCarrier` (lecture seule) + endpoint recherche
|
||||||
|
**Position** : 1.4 • Suit : migration • Précède : entités Carrier*
|
||||||
|
**Tag** : Backend • **Effort** : S
|
||||||
|
**Contexte** : la saisie assistée du nom (RG-4.01) a besoin d'un endpoint de recherche sur le référentiel QUALIMAT, aujourd'hui alimenté en console mais non exposé.
|
||||||
|
**Spec liée** : [`spec-back.md § 4.7`](./spec-back.md) · RG-4.01
|
||||||
|
**Critères d'acceptation** :
|
||||||
|
- [ ] Entité `QualimatCarrier` (lecture seule) mappée sur la table existante `qualimat_carrier` (aucune écriture exposée).
|
||||||
|
- [ ] `GET /api/qualimat_carriers?search=` : fuzzy sur `name` (+ `siret`), **seulement `is_active = true`**, tri `name`, paginé (règle n°13).
|
||||||
|
- [ ] **Security** `is_granted('transport.carriers.view')`. Champs exposés : `id, siret, name, address, postalCode, city, phone, department, status, validityDate, isActive`.
|
||||||
|
**Tests à prévoir** : PHPUnit — recherche ne renvoie que les actifs ; pagination Hydra ; 403 sans permission.
|
||||||
|
**Tips** : DoR — ERP-39 mergé. Ne pas toucher la commande de sync.
|
||||||
|
|
||||||
|
### 1.5 — Créer entités `Carrier*` + repos + `ApiResource` + `CarrierProvider`
|
||||||
|
**Position** : 1.5 • Suit : QualimatCarrier • Précède : CarrierProcessor
|
||||||
|
**Tag** : Backend • **Effort** : M
|
||||||
|
**Contexte** : poser les entités, le contrat de sérialisation (groupes) et la lecture (liste + détail).
|
||||||
|
**Spec liée** : [`spec-back.md § 3.3 / 3.4 / 4.0 / 4.1 / 4.2`](./spec-back.md)
|
||||||
|
**Critères d'acceptation** :
|
||||||
|
- [ ] Entités `Carrier`, `CarrierAddress`, `CarrierContact`, `CarrierPrice` (`#[Auditable]`, `TimestampableBlamableTrait`), repos Doctrine.
|
||||||
|
- [ ] `ApiResource` Carrier : `GetCollection` + `Get` + `Post` + `Patch` avec `security` (§ 3.3) ; **pas de Delete**.
|
||||||
|
- [ ] Groupes de sérialisation : `carrier:read`, `carrier:item:read`, `qualimat:read`, embed `client:read`/`client_address:read`/`supplier:read`/`supplier_address:read`/`site:read` au détail (3 maillons § 4.0 — ⚠ les adresses de l'onglet Prix sont des entités `ClientAddress`/`SupplierAddress` distinctes).
|
||||||
|
- [ ] `CarrierProvider` paginé (`ApiPlatform\Doctrine\Orm\Paginator`) ; liste **sans cloisonnement site** (§ 2.3) ; anti-N+1 (§ 2.11).
|
||||||
|
- [ ] Piège booléen `isArchived` : `#[SerializedName('isArchived')]` sur le getter.
|
||||||
|
**Tests à prévoir** : liste exclut archivés par défaut ; `?includeArchived=true` ; enveloppe Hydra ; `isArchived` présent dans le JSON.
|
||||||
|
**Tips** : miroir `Supplier`/`Provider`. Pas d'onglet Comptabilité (≠ M2/M3).
|
||||||
|
|
||||||
|
### 1.6 — Implémenter `CarrierProcessor`
|
||||||
|
**Position** : 1.6 • Suit : entités • Précède : sous-ressource Adresses
|
||||||
|
**Tag** : Backend • **Effort** : M
|
||||||
|
**Contexte** : logique d'écriture du formulaire principal (POST/PATCH) : normalisation, champs conditionnels, archivage.
|
||||||
|
**Spec liée** : [`spec-back.md § 4.3 / 4.4 / 7`](./spec-back.md)
|
||||||
|
**Critères d'acceptation** :
|
||||||
|
- [ ] **RG-4.01** : POST avec `qualimatCarrier` → `certificationType=QUALIMAT` + FK persistée ; cas LIOT : `name='LIOT'` ⇒ `certificationType` non requis, `liotPlates` accepté.
|
||||||
|
- [ ] **RG-4.02** : `certificationType='AUTRE'` sans `dischargeDocument` → **422** (`#[Assert\Callback]`).
|
||||||
|
- [ ] **RG-4.03** : `isChartered=true` sans `indexationRate`/`containerType`/`volumeM3` → **422**.
|
||||||
|
- [ ] **RG-4.13** : normalisation (`name` UPPER, contacts Capitalize, phones digits, email lower, `liotPlates`).
|
||||||
|
- [ ] **RG-4.12** : doublon `name` (actifs) → **409**.
|
||||||
|
- [ ] **RG-4.14** : PATCH `isArchived` exige `transport.carriers.archive` (Admin) ; mode strict (403 sinon).
|
||||||
|
**Tests à prévoir** : PHPUnit sur chaque RG ci-dessus (cf. § 8.1).
|
||||||
|
**Tips** : `CarrierFieldNormalizer` miroir `SupplierFieldNormalizer`.
|
||||||
|
|
||||||
|
### 1.7 — Sous-ressource Adresses (`carrier_address`)
|
||||||
|
**Position** : 1.7 • Suit : CarrierProcessor • Précède : Contacts
|
||||||
|
**Tag** : Backend • **Effort** : S
|
||||||
|
**Spec liée** : [`spec-back.md § 4.5`](./spec-back.md) · RG-4.05→4.07
|
||||||
|
**Critères d'acceptation** :
|
||||||
|
- [ ] `POST /api/carriers/{id}/addresses`, `PATCH`/`DELETE /api/carrier_addresses/{id}` (security `manage`).
|
||||||
|
- [ ] **RG-4.06** : `postalCode` matche `^[0-9]{4,5}$` (autocomplete ville = front).
|
||||||
|
- [ ] **RG-4.05** : si affrété, adresse obligatoire (Pays/CP/Ville/Adresse) — validation conditionnelle.
|
||||||
|
**Tests à prévoir** : PHPUnit — CP invalide → 422 ; adresse affrété incomplète → 422.
|
||||||
|
**Tips** : RG-4.07 (bouton Valider masqué si QUALIMAT) = front, back accepte le PATCH.
|
||||||
|
|
||||||
|
### 1.8 — Sous-ressource Contacts (`carrier_contact`)
|
||||||
|
**Position** : 1.8 • Suit : Adresses • Précède : Prix
|
||||||
|
**Tag** : Backend • **Effort** : S
|
||||||
|
**Spec liée** : [`spec-back.md § 4.5`](./spec-back.md) · RG-4.08
|
||||||
|
**Critères d'acceptation** :
|
||||||
|
- [ ] `POST /api/carriers/{id}/contacts`, `PATCH`/`DELETE /api/carrier_contacts/{id}` (security `manage`).
|
||||||
|
- [ ] **RG-4.08** : bloc valide si ≥ 1 champ rempli (CHECK `chk_carrier_contact_filled` + Processor) ; **max 2 téléphones**.
|
||||||
|
**Tests à prévoir** : PHPUnit — contact vide → 422 ; 1 champ → 200.
|
||||||
|
**Tips** : miroir contacts M2/M3.
|
||||||
|
|
||||||
|
### 1.9 — Sous-ressource Prix (`carrier_price`) + RG branches
|
||||||
|
**Position** : 1.9 • Suit : Contacts • Précède : Export
|
||||||
|
**Tag** : Backend • **Effort** : M
|
||||||
|
**Spec liée** : [`spec-back.md § 4.5 / 7`](./spec-back.md) · RG-4.09→4.11
|
||||||
|
**Critères d'acceptation** :
|
||||||
|
- [ ] `POST /api/carriers/{id}/prices`, `PATCH`/`DELETE /api/carrier_prices/{id}` (security `manage`).
|
||||||
|
- [ ] **RG-4.10** (CLIENT) : `client`, `clientDeliveryAddress`, `departureSite` requis ; `clientDeliveryAddress` doit appartenir au `client` → sinon 422.
|
||||||
|
- [ ] **RG-4.11** (FOURNISSEUR) : `supplier`, `supplierSupplyAddress`, `deliverySite` requis ; `supplierSupplyAddress` appartient au `supplier` → sinon 422.
|
||||||
|
- [ ] Communs obligatoires : `containerType`, `pricingUnit`, `price`, `priceState` ; CHECK branches respectées.
|
||||||
|
**Tests à prévoir** : PHPUnit — branche CLIENT/FOURNISSEUR incomplète → 422 ; adresse étrangère → 422.
|
||||||
|
**Tips** : « Adresse départ/livraison 86/17/82 » = `Site` (FK) ; livraison client = `ClientAddress`, appro = `SupplierAddress` (relations ORM partagées).
|
||||||
|
|
||||||
|
### 1.10 — Export XLSX (répertoire + onglet Prix regroupé)
|
||||||
|
**Position** : 1.10 • Suit : Prix • Précède : Tests PHPUnit
|
||||||
|
**Tag** : Backend • **Effort** : M
|
||||||
|
**Spec liée** : [`spec-back.md § 4.6`](./spec-back.md)
|
||||||
|
**Critères d'acceptation** :
|
||||||
|
- [ ] `GET /api/carriers/export.xlsx` : transporteurs affichés (mêmes filtres) ; colonnes § 4.6.
|
||||||
|
- [ ] `GET /api/carriers/{id}/prices/export.xlsx` : tableau Prix regroupé Benne / Fond Mouvant (colonnes docx p.10).
|
||||||
|
- [ ] Controllers custom `#[Route(priority: 1)]` (conflit API Platform `{id}`) ; `Content-Disposition`.
|
||||||
|
**Tests à prévoir** : PHPUnit — 200 + en-tête fichier ; respect des filtres.
|
||||||
|
**Tips** : PhpSpreadsheet déjà présent.
|
||||||
|
|
||||||
|
### 1.11 — Tests PHPUnit RG-4.01→4.14 + capture contrat JSON (DoD)
|
||||||
|
**Position** : 1.11 • Suit : Export • Précède : Page Répertoire
|
||||||
|
**Tag** : Backend • **Effort** : M
|
||||||
|
**Spec liée** : [`spec-back.md § 4.0.bis / 8.1`](./spec-back.md)
|
||||||
|
**Critères d'acceptation** :
|
||||||
|
- [ ] Matrice RG-4.01→4.14 couverte (§ 8.1) + RBAC par rôle (Compta/Usine → 403).
|
||||||
|
- [ ] `CarrierSerializationContractTest` : capture JSON réel **liste + détail** ; `prices[].client`/`.supplier`/sites **embarqués** (pas IRI) ; `qualimatCarrier` embarqué ; `isArchived` présent.
|
||||||
|
- [ ] Anti-N+1 liste ; pagination Hydra ; audit (`entity_type='Carrier'`) ; `AuditableEntitiesHaveI18nLabelTest` vert.
|
||||||
|
- [ ] `CarrierFixtures` idempotent (§ 8.4) : transporteur QUALIMAT (validité passée), AUTRE+décharge, affrété, LIOT, complet (contacts/adresses/prix CLIENT+FOURNISSEUR), 1 archivé.
|
||||||
|
**Tests à prévoir** : suite complète `make test` verte.
|
||||||
|
**Tips** : coller les JSON capturés dans § 4.0.bis (DoD avant front).
|
||||||
|
|
||||||
|
### 1.12 — Page Répertoire `/carriers` (datatable, filtres, export)
|
||||||
|
**Position** : 1.12 • Suit : Tests back • Précède : Page Ajouter
|
||||||
|
**Tag** : Frontend • **Effort** : M
|
||||||
|
**Spec liée** : [`spec-front.md § Datatable / Filtres`](./spec-front.md) · Figma `1132-45377`
|
||||||
|
**Critères d'acceptation** :
|
||||||
|
- [ ] `<MalioDataTable>` + `usePaginatedList<Carrier>({url:'/carriers'})` ; colonnes Nom / Certification / Date de validité / Dernière activité.
|
||||||
|
- [ ] **RG-4.04** : date de validité QUALIMAT < aujourd'hui → **fond rouge**.
|
||||||
|
- [ ] Filtres (`search`, `certificationType`, `includeArchived`) → `setFilters` (page 1) ; **état 100 % local** (règle n°6).
|
||||||
|
- [ ] Boutons « + Ajouter » (si `manage`) / « Filtrer » / « Exporter » (XLSX) ; clic ligne → Consultation.
|
||||||
|
**Tests à prévoir** : Vitest — `usePaginatedList` (Hydra, exclusion archivés).
|
||||||
|
**Tips** : `useApi()` obligatoire ; pas de persistance URL.
|
||||||
|
|
||||||
|
### 1.13 — Page Ajouter `/carriers/new` (layout, onglets, formulaire principal POST)
|
||||||
|
**Position** : 1.13 • Suit : Répertoire • Précède : Saisie assistée QUALIMAT
|
||||||
|
**Tag** : Frontend • **Effort** : M
|
||||||
|
**Spec liée** : [`spec-front.md § Écran Ajouter / Formulaire principal`](./spec-front.md) · Figma node `1132-45382` (Ajouter – Qualimat)
|
||||||
|
**Critères d'acceptation** :
|
||||||
|
- [ ] Layout + barre d'onglets `Qualimat · Adresses · Contacts · Prix` ; validation incrémentale (onglet suivant accessible après validation).
|
||||||
|
- [ ] Formulaire principal (Nom, Liste certification, Affréter, …) → `POST /api/carriers` ; succès → bascule onglet + champs readonly.
|
||||||
|
- [ ] `useFormErrors` : mapping 422 inline par champ ; `{ toast:false }`.
|
||||||
|
**Tests à prévoir** : Vitest — `useCarrierForm` (workflow par onglet, POST principal).
|
||||||
|
**Tips** : miroir `useSupplierForm`/`useProviderForm`.
|
||||||
|
|
||||||
|
### 1.14 — Saisie assistée QUALIMAT + champs conditionnels
|
||||||
|
**Position** : 1.14 • Suit : Page Ajouter • Précède : Onglet Adresses
|
||||||
|
**Tag** : Frontend • **Effort** : M
|
||||||
|
**Spec liée** : [`spec-front.md § Formulaire principal / Onglet Qualimat`](./spec-front.md) · RG-4.01→4.03 · Figma nodes `1132-50717` (Affréter), `1132-50982` (AUTRE→Décharge), `1132-45593` (LIOT)
|
||||||
|
**Critères d'acceptation** :
|
||||||
|
- [ ] **RG-4.01** : saisie du nom → `GET /api/qualimat_carriers?search=` → modal « Êtes-vous sûr… » → copie Nom + certification (`QUALIMAT`, readonly) + adresse + FK conservée.
|
||||||
|
- [ ] **Cas LIOT** : nom `LIOT` → champ immatriculations seul, autres masqués.
|
||||||
|
- [ ] **RG-4.02** : certification `AUTRE` → champ Décharge visible **et obligatoire** (upload).
|
||||||
|
- [ ] **RG-4.03** : « Affréter » coché → indexation / benne-fond mouvant / volume visibles et obligatoires.
|
||||||
|
**Tests à prévoir** : Vitest — affichage conditionnel (Affréter, AUTRE, LIOT) ; copie QUALIMAT.
|
||||||
|
**Tips** : `useQualimatSearch()` ; `useUpload()` (ticket 1.19) pour la décharge.
|
||||||
|
|
||||||
|
### 1.15 — Onglet Adresses (autocomplete BAN)
|
||||||
|
**Position** : 1.15 • Suit : Saisie QUALIMAT • Précède : Onglet Contacts
|
||||||
|
**Tag** : Frontend • **Effort** : M
|
||||||
|
**Spec liée** : [`spec-front.md § Onglet Adresses`](./spec-front.md) · RG-4.05→4.07 · Figma node `1132-45670`
|
||||||
|
**Critères d'acceptation** :
|
||||||
|
- [ ] Bloc adresse (Pays/CP/Ville/Adresse/complément) → `PATCH /api/carriers/{id}/addresses`.
|
||||||
|
- [ ] **RG-4.06** : `useAddressAutocomplete()` (BAN) — ville auto depuis CP, dégradé texte libre.
|
||||||
|
- [ ] **RG-4.05** : champs préremplis si QUALIMAT ; obligatoires si affrété. **RG-4.07** : pas de bouton Valider si QUALIMAT.
|
||||||
|
**Tests à prévoir** : Vitest — autocomplete nominal + dégradé (réutilisation M1/M2/M3).
|
||||||
|
**Tips** : ne pas réécrire `useAddressAutocomplete()`.
|
||||||
|
|
||||||
|
### 1.16 — Onglet Contacts
|
||||||
|
**Position** : 1.16 • Suit : Adresses • Précède : Onglet Prix
|
||||||
|
**Tag** : Frontend • **Effort** : S
|
||||||
|
**Spec liée** : [`spec-front.md § Onglet Contacts`](./spec-front.md) · RG-4.08 · Figma node `1132-45756`
|
||||||
|
**Critères d'acceptation** :
|
||||||
|
- [ ] Blocs contact (Nom/Prénom/Fonction/Téléphone x1-2/Email) → `PATCH /api/carriers/{id}/contacts`.
|
||||||
|
- [ ] **RG-4.08** : « + Nouveau contact » bloqué tant que le bloc courant est vide ; suppression avec modal.
|
||||||
|
**Tests à prévoir** : Vitest — règle « ≥ 1 champ », max 2 téléphones.
|
||||||
|
**Tips** : `mapViolationsToRecord` par ligne (pattern collections M1/M2/M3).
|
||||||
|
|
||||||
|
### 1.17 — Onglet Prix (Client/Fournisseur, sites)
|
||||||
|
**Position** : 1.17 • Suit : Contacts • Précède : Consultation/Modification
|
||||||
|
**Tag** : Frontend • **Effort** : M
|
||||||
|
**Spec liée** : [`spec-front.md § Onglet Prix`](./spec-front.md) · RG-4.09→4.11 · Figma node `1132-45859`
|
||||||
|
**Critères d'acceptation** :
|
||||||
|
- [ ] Radio `direction` (Client/Fournisseur) → bascule des champs (**RG-4.09**).
|
||||||
|
- [ ] **RG-4.10** (Client) : Client + Adresse de livraison (du client) + Adresse de départ (86/17/82).
|
||||||
|
- [ ] **RG-4.11** (Fournisseur) : Fournisseur + Adresse d'approvisionnement + Adresse de livraison (86/17/82).
|
||||||
|
- [ ] Communs : Benne/FM, Forfait/Tonne, Prix (`MalioInputAmount`), État du prix → `PATCH /api/carriers/{id}/prices`.
|
||||||
|
**Tests à prévoir** : Vitest — bascule Client/Fournisseur, champs requis.
|
||||||
|
**Tips** : selects clients/fournisseurs/sites via endpoints existants (security élargie § 4.8).
|
||||||
|
|
||||||
|
### 1.18 — Pages Consultation + Modification
|
||||||
|
**Position** : 1.18 • Suit : Onglet Prix • Précède : Upload/i18n
|
||||||
|
**Tag** : Frontend • **Effort** : M
|
||||||
|
**Spec liée** : [`spec-front.md § Consultation / Modification`](./spec-front.md)
|
||||||
|
**Critères d'acceptation** :
|
||||||
|
- [ ] Consultation readonly (ouvre sur Adresses) ; flèche retour ; « Modifier » (si `manage`) ; « Archiver » (Admin) → PATCH `isArchived`.
|
||||||
|
- [ ] Onglet Prix consultation = tableau regroupé Benne/FM + bouton Exporter (XLSX).
|
||||||
|
- [ ] Modification = mêmes formulaires, champs pré-remplis, PATCH partiel par onglet.
|
||||||
|
**Tests à prévoir** : Vitest — `useCarrier(id)` peuple les écrans depuis une seule réponse ; visibilité boutons par permission.
|
||||||
|
**Tips** : « Restaurer » remplace « Archiver » sur un archivé.
|
||||||
|
|
||||||
|
### 1.19 — Upload front (`useUpload`) + i18n + libellés audit
|
||||||
|
**Position** : 1.19 • Suit : Consultation/Modification • Précède : —
|
||||||
|
**Tag** : Frontend • **Effort** : S
|
||||||
|
**Spec liée** : [`spec-back.md § 2.7 / 2.8`](./spec-back.md) · [`spec-front.md § Composables`](./spec-front.md)
|
||||||
|
**Critères d'acceptation** :
|
||||||
|
- [ ] Composable `useUpload()` : `POST /api/uploaded_documents` (multipart) → IRI posée sur `carrier.dischargeDocument` (RG-4.02).
|
||||||
|
- [ ] Clés i18n : libellés certification, sidebar (`sidebar.transport.*`), **libellés audit** `audit.entity.transport_carrier/carrieraddress/carriercontact/carrierprice`.
|
||||||
|
- [ ] `<MalioInputUpload>` (exception documentée si type non couvert).
|
||||||
|
**Tests à prévoir** : Vitest — `useUpload` (succès + erreur MIME).
|
||||||
|
**Tips** : `AuditableEntitiesHaveI18nLabelTest` exige les clés audit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Actions Lesstime (à exécuter au feu vert de Matthieu)
|
||||||
|
|
||||||
|
1. `create-group` projectId 6, title « M4 — Répertoire transporteurs » → récupérer l'`id`.
|
||||||
|
2. `create-task` ×19 (statut `Prêt à dev` = 6, priorité Moyen=2, effort dans la description), dans l'ordre 1.1 → 1.19 :
|
||||||
|
- Tickets **1.1 → 1.11** (Backend, tag `3`) → **assigné à Matthieu**.
|
||||||
|
- Tickets **1.12 → 1.19** (Frontend, tag `2`) → **assigné à Tristan**.
|
||||||
|
3. Mettre à jour le frontmatter des specs (`lesstime_taskgroup_id`) + lien du groupe.
|
||||||
|
|
||||||
|
> Au push : récupérer les `userId` via `list-users` (Matthieu = `5` selon le référentiel ; Tristan à confirmer) pour renseigner l'assignation à la création.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Prompt d'implémentation — M5 · ERP-181 (1.1) — Scaffolder le module Logistique + RBAC
|
||||||
|
|
||||||
|
Projet **Starseed** (modular monolith DDD). Tâche **back**. Lis d'abord `CLAUDE.md` + `.claude/rules/architecture.md` + `.claude/rules/backend.md`, puis la spec : `docs/specs/M5-tickets-pesee/spec-back.md` (§ 2.1, § 5).
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Créer le **nouveau module `Logistique`** et poser son socle RBAC, **avant toute entité**. Aucun écran fonctionnel ici, juste le squelette + permissions + sidebar + 3 miroirs RBAC.
|
||||||
|
|
||||||
|
## Étapes
|
||||||
|
1. Scaffolder via le skill projet **`create-module`** : `src/Module/Logistique/` avec `Domain/ Application/ Infrastructure/` et `LogistiqueModule.php` :
|
||||||
|
- `const string ID = 'logistique'` ; `const string LABEL = 'Logistique'` ; `const bool REQUIRED = false`.
|
||||||
|
- `permissions()` retourne :
|
||||||
|
- `['code' => 'logistique.weighing_tickets.view', 'label' => 'Voir les tickets de pesée']`
|
||||||
|
- `['code' => 'logistique.weighing_tickets.manage', 'label' => 'Créer / modifier les tickets de pesée']`
|
||||||
|
2. Enregistrer `LogistiqueModule::class` dans `config/modules.php`.
|
||||||
|
3. Créer le layer front minimal `frontend/modules/logistique/nuxt.config.ts` (kebab-case, auto-détecté).
|
||||||
|
4. Ajouter à `config/sidebar.php` une section/item « Logistique » :
|
||||||
|
```php
|
||||||
|
['label' => 'sidebar.logistique.weighing_tickets', 'to' => '/weighing-tickets',
|
||||||
|
'icon' => 'mdi-scale', 'module' => 'logistique', 'permission' => 'logistique.weighing_tickets.view'],
|
||||||
|
```
|
||||||
|
+ la clé i18n `sidebar.logistique.*` dans `frontend/i18n/locales/fr.json`.
|
||||||
|
5. **Règle ABSOLUE n°8 — 3 miroirs RBAC alignés ensemble** :
|
||||||
|
- `config/sidebar.php` (item + permission ci-dessus),
|
||||||
|
- `frontend/tests/e2e/_fixtures/personas.ts` (persona **Usine** gagne `weighing_tickets.view` + `manage` et `expectedAdminLinks` ; **Compta/Commerciale** : aucun accès),
|
||||||
|
- `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` (miroir back du même persona).
|
||||||
|
6. `make shell` → `php bin/console app:sync-permissions`.
|
||||||
|
|
||||||
|
## Garde-fous (règles ABSOLUES)
|
||||||
|
- `declare(strict_types=1);` partout ; commentaires **en français**, code en anglais.
|
||||||
|
- Permission au format `module.resource.action` snake_case.
|
||||||
|
- Ne PAS créer d'entité ni de migration ici (ticket 1.2).
|
||||||
|
- Pas de hardcode sidebar côté front : elle vient de `/api/sidebar`.
|
||||||
|
|
||||||
|
## Vérification
|
||||||
|
- `make test` (les tests Architecture ne cassent pas).
|
||||||
|
- `make php-cs-fixer-allow-risky`.
|
||||||
|
- `GET /api/modules` retourne `logistique` ; `GET /api/sidebar` : item présent pour Admin/Bureau/Usine, **absent** pour Compta/Commerciale.
|
||||||
|
- Les 3 miroirs RBAC sont cohérents (sinon test E2E faux positif).
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Prompt d'implémentation — M5 · ERP-182 (1.2) — Migrer le schéma M5
|
||||||
|
|
||||||
|
Projet **Starseed**. Tâche **back / migration**. Lis `CLAUDE.md` (règles n°11 et n°12), `.claude/rules/backend.md` (§ Migrations) et la spec : `docs/specs/M5-tickets-pesee/spec-back.md` (§ 3.2, § 3.2.bis, § 2.5, § 2.7).
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Écrire **une** migration Doctrine au namespace racine `DoctrineMigrations` (`migrations/VersionYYYYMMDDHHMMSS.php`, postérieure aux existantes) qui crée tout le schéma M5.
|
||||||
|
|
||||||
|
## Étapes
|
||||||
|
1. **`site.code`** : `ALTER TABLE site ADD COLUMN code VARCHAR(8)` **NULLABLE** → backfill `UPDATE site SET code = LEFT(postal_code, 2) WHERE code IS NULL` → index unique `uq_site_code` (tolère les NULL multiples Postgres).
|
||||||
|
- ⚠ **NE PAS poser `SET NOT NULL` ici.** Sur `make db-reset`, les fixtures `SitesFixtures` insèrent des sites via l'ORM, qui ne connaît `code` que si la propriété est mappée sur l'entité `Site.php` (fait en ERP-183) → sinon `INSERT` sans `code` → violation `NOT NULL` → db-reset plante. Le `NOT NULL` est posé en **ERP-183** (2ᵉ migration) une fois `Site::code` mappé + peuplé. Cf. spec § 2.5.
|
||||||
|
2. Table **`weighing_ticket_counter (site_id PK → site, last_value INT NOT NULL DEFAULT 0)`** (séquence numéro par site, RG-5.02).
|
||||||
|
3. Table **`weighbridge_dsd_counter (site_id PK → site, last_value INT NOT NULL DEFAULT 0)`** (compteur DSD par site, RG-5.04).
|
||||||
|
4. Table **`weighing_ticket`** : copier le DDL de la spec § 3.2 (colonnes `site_id`, `number`, contrepartie `counterparty_type`/`client_id`/`supplier_id`/`other_label`, `immatriculation`/`plate_free_format`, `empty_*`, `full_*`, `net_weight`, `deleted_at` + 4 colonnes Timestampable/Blamable).
|
||||||
|
- Convention `INT GENERATED BY DEFAULT AS IDENTITY`, `TIMESTAMP(0) WITHOUT TIME ZONE` (§ 2.2).
|
||||||
|
- CHECK : `counterparty_type`, `empty_mode`/`full_mode`, et les **3 branches contrepartie** (RG-5.03).
|
||||||
|
- Index unique `(site_id, number)` + index FK (`site`, `client`, `supplier`, `deleted_at`, `created_by`, `updated_by`).
|
||||||
|
5. **Règle ABSOLUE n°12** : `COMMENT ON COLUMN` (FR, ≤ 200 car., sémantique + RG) sur **chaque** colonne créée — cf. échantillon § 3.2.bis. Les 4 colonnes Timestampable/Blamable via `addStandardTimestampableBlamableComments($schema, 'weighing_ticket')`. Bonus `COMMENT ON TABLE`.
|
||||||
|
6. Écrire `down()` symétrique (drop tables + drop colonne `site.code`).
|
||||||
|
|
||||||
|
## Garde-fous
|
||||||
|
- Noms de colonnes **en minuscules** (Postgres).
|
||||||
|
- FK cross-module (`user`, `client`, `supplier`, `site`) → la migration **doit** vivre au namespace racine (règle n°11), sinon `make db-reset` casse l'ordre.
|
||||||
|
- `ON DELETE` : `site` = RESTRICT, `client`/`supplier` = RESTRICT, `created_by`/`updated_by` = SET NULL, compteurs = CASCADE.
|
||||||
|
|
||||||
|
## Vérification
|
||||||
|
- `make db-reset` puis `make migration-migrate` (BDD fraîche) → OK.
|
||||||
|
- `make test` : `ColumnsHaveSqlCommentTest` **vert** (aucune colonne `public` sans `col_description`).
|
||||||
|
- `make php-cs-fixer-allow-risky`.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Prompt d'implémentation — M5 · ERP-183 (1.3) — Entité WeighingTicket + repository + contrat sérialisation
|
||||||
|
|
||||||
|
Projet **Starseed**. Tâche **back**. Lis `CLAUDE.md`, `.claude/rules/backend.md` (Audit, Timestampable/Blamable, Serialization) et la spec : `docs/specs/M5-tickets-pesee/spec-back.md` (§ 3.3, § 4.0, § 2.11). Prérequis : ERP-182 mergé.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Créer l'entité Doctrine **`WeighingTicket`** + son `#[ApiResource]` (Get / GetCollection / Post / Patch) + le repository, avec le **contrat de sérialisation posé une seule fois** (read-groups sur chaque propriété affichée — RETEX M1→M4).
|
||||||
|
|
||||||
|
## ⚠ Finaliser `site.code` (dette laissée par ERP-182 — à faire EN PREMIER)
|
||||||
|
ERP-182 a créé `site.code` **nullable** (sinon `db-reset` cassait). Ici on le rend obligatoire, maintenant que l'ORM peut le remplir :
|
||||||
|
1. Mapper la propriété `code` sur l'entité `src/Module/Sites/Domain/Entity/Site.php` (colonne + getter/setter + groupes de sérialisation cohérents avec les autres champs `site:*`).
|
||||||
|
2. Peupler `code` (86 / 17 / 82) dans `SitesFixtures` **et** dans `SeedE2ECommand.php` (sites seedés).
|
||||||
|
3. Ajuster les tests Sites en collision d'unicité : ex. `SiteApiTest` qui crée un site CP `86000` → `code` 86 entre en collision avec la fixture Châtellerault (86) → adapter le CP/code du test.
|
||||||
|
4. **2ᵉ petite migration** (namespace racine) : `ALTER TABLE site ALTER COLUMN code SET NOT NULL;` (+ `COMMENT ON COLUMN` si pas déjà posé).
|
||||||
|
5. `make db-reset` + `make test` doivent rester verts.
|
||||||
|
|
||||||
|
## Étapes — WeighingTicket
|
||||||
|
1. `src/Module/Logistique/Domain/Entity/WeighingTicket.php` (squelette spec § 3.3) :
|
||||||
|
- `#[Auditable]`, `use TimestampableBlamableTrait`, `implements TimestampableInterface, BlamableInterface`.
|
||||||
|
- Relations ORM **partagées** (PAS d'import de logique) : `Client` (M1), `Supplier` (M2), `Site` (Sites) en ManyToOne.
|
||||||
|
- Propriétés : `number`, `site`, `counterpartyType`, `client`, `supplier`, `otherLabel`, `immatriculation`, `plateFreeFormat`, `empty*` (date/weight/dsd/mode/manualNumber), `full*` (idem), `netWeight`.
|
||||||
|
2. **Read-groups (3 maillons § 4.0)** :
|
||||||
|
- `weighing_ticket:read` = champs liste (`number`, `counterpartyType`, `client`, `supplier`, `otherLabel`, `displayDate`, `netWeight`, `plateFreeFormat`, timestamps).
|
||||||
|
- `weighing_ticket:item:read` = détail (`empty*`, `full*`, `site`, `immatriculation`).
|
||||||
|
- Contextes des opérations exactement comme § 3.3 (inclure `client:read`, `supplier:read`, `site:read`, `default:read`).
|
||||||
|
3. Getter calculé **`displayDate`** (= `fullDate ?? emptyDate`) annoté `weighing_ticket:read`.
|
||||||
|
4. Booléen **`plateFreeFormat`** : exposer via getter + `#[SerializedName('plateFreeFormat')]` (piège #3 M1 — la clé doit sortir dans le JSON).
|
||||||
|
5. Sécurité opérations : GET = `is_granted('logistique.weighing_tickets.view')` ; POST/PATCH = `...manage`. **Pas de Delete, pas d'archive.** Provider/Processor référencés (implémentés en ERP-184/185).
|
||||||
|
6. Contraintes `#[Assert\*]` avec **messages FR** ; `Assert\Length.max` aligné sur les colonnes ORM.
|
||||||
|
7. **Libellé i18n audit** : `audit.entity.logistique_weighingticket` dans `frontend/i18n/locales/fr.json`.
|
||||||
|
8. `src/Module/Logistique/Infrastructure/Doctrine/DoctrineWeighingTicketRepository.php`.
|
||||||
|
|
||||||
|
## Garde-fous
|
||||||
|
- `declare(strict_types=1);` ; commentaires FR.
|
||||||
|
- Ne JAMAIS importer une classe d'un autre **module** pour de la logique — seules les entités de référence (Client/Supplier/Site) sont consommées en relation ORM (toléré M1→M4).
|
||||||
|
- Pagination gérée par le Provider (ERP-185) — ne pas désactiver la pagination.
|
||||||
|
|
||||||
|
## Vérification
|
||||||
|
- `make test` : `EntitiesAreTimestampableBlamableTest`, `AuditableEntitiesHaveI18nLabelTest`, `EntityConstraintsHaveFrenchMessageTest` **verts**.
|
||||||
|
- `make php-cs-fixer-allow-risky`.
|
||||||
|
- (La capture JSON réelle du contrat est faite en ERP-187.)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Prompt d'implémentation — M5 · ERP-184 (1.4) — Pesée pont bascule (stub + DSD + endpoint)
|
||||||
|
|
||||||
|
Projet **Starseed**. Tâche **back**. Lis `CLAUDE.md`, `.claude/rules/backend.md` et la spec : `docs/specs/M5-tickets-pesee/spec-back.md` (§ 2.6, § 2.7, § 4.2). Prérequis : ERP-182.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Implémenter la pesée déclenchée par les boutons « Pesée bascule » / « Pesée manuelle » : **stub** (pas de liaison matérielle au M5) + allocateur DSD + endpoint API.
|
||||||
|
|
||||||
|
## Étapes
|
||||||
|
1. Contrat `Logistique\Domain\Contract\WeighbridgeReaderInterface` :
|
||||||
|
```php
|
||||||
|
public function read(SiteInterface $site): WeighbridgeReading; // {weight:int kg, dsd:int}
|
||||||
|
```
|
||||||
|
+ `WeighbridgeUnavailableException`.
|
||||||
|
2. Impl `Logistique\Infrastructure\Weighbridge\RandomWeighbridgeReader` : `weight = random_int(10000, 50000)` (RG-5.06), `dsd = DsdAllocator::next($site)`.
|
||||||
|
3. `DsdAllocator` (service) : compteur DSD **par site** sur `weighbridge_dsd_counter`, incrément avec **verrou ligne `SELECT ... FOR UPDATE`** dans une transaction.
|
||||||
|
- AUTO : incrémente et renvoie la nouvelle valeur.
|
||||||
|
- MANUAL : `dsd = dernier dsd du site + 1` (RG-5.04).
|
||||||
|
4. Endpoint **`POST /api/weighbridge_readings`** — ressource virtuelle (DTO `WeighbridgeReadingInput`/`Output`) + Processor dédié, **pas de controller Symfony** :
|
||||||
|
- `{ "mode": "AUTO" }` → `{ weight, dsd, mode }` (site courant via `CurrentSiteProviderInterface`).
|
||||||
|
- `{ "mode": "MANUAL", "weight": <int>, "manualNumber": "<str>" }` → `{ weight, dsd, manualNumber, mode }`.
|
||||||
|
- Erreur `WeighbridgeUnavailableException` → **HTTP 503** explicite « Pont bascule indisponible — passez en pesée manuelle » (RG-5.06).
|
||||||
|
- Sécurité `is_granted('logistique.weighing_tickets.manage')`.
|
||||||
|
5. Le `dsd` renvoyé est **prévisionnel** : noter en commentaire que l'attribution autoritaire est refaite à la création du ticket (ERP-185).
|
||||||
|
|
||||||
|
## Garde-fous
|
||||||
|
- `declare(strict_types=1);` ; commentaires FR.
|
||||||
|
- Consommer `CurrentSiteProviderInterface` (contrat Sites) — pas d'import de logique d'un autre module.
|
||||||
|
- Pas de controller sous `/api` (API Platform).
|
||||||
|
|
||||||
|
## Vérification
|
||||||
|
- `make test` : `WeighbridgeReaderStubTest` (poids ∈ [10000,50000] + chemin erreur → 503), `DsdAllocatorTest` (AUTO incrémente / MANUAL = dernier+1 / par site).
|
||||||
|
- `make php-cs-fixer-allow-risky`.
|
||||||
|
- Appel manuel `POST /api/weighbridge_readings {AUTO}` (token Usine) → poids + dsd cohérents.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Prompt d'implémentation — M5 · ERP-185 (1.5) — Provider + Processor WeighingTicket
|
||||||
|
|
||||||
|
Projet **Starseed**. Tâche **back**. Lis `CLAUDE.md`, `.claude/rules/backend.md` (Pagination, RBAC, Validation) et la spec : `docs/specs/M5-tickets-pesee/spec-back.md` (§ 4.3, § 4.4, § 2.5, § 2.9, § 2.8, § 6, § 2.3). Prérequis : ERP-183, ERP-184.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Implémenter la logique métier d'écriture (Processor) et de lecture (Provider) du ticket de pesée.
|
||||||
|
|
||||||
|
## Étapes — `WeighingTicketProcessor` (POST/PATCH)
|
||||||
|
1. **Site courant** : résoudre via `CurrentSiteProviderInterface` → `site_id` (à la création).
|
||||||
|
2. **Numéro `{siteCode}-TP-{NNNN}`** (RG-5.02) : à la création, incrémenter `weighing_ticket_counter` du site avec **`SELECT ... FOR UPDATE`**, formater `%04d`. Numéro **immuable** au PATCH (RG-5.09).
|
||||||
|
3. **DSD autoritaire** : (ré)attribuer `empty_dsd`/`full_dsd` via `DsdAllocator` (verrou) si pesée AUTO (RG-5.04).
|
||||||
|
4. **RG-5.03** (contrepartie) : `#[Assert\Callback]` sur l'entité → selon `counterpartyType`, exiger `client` / `supplier` / `otherLabel` et forcer les autres à `null` (messages FR, `->atPath()` sur le bon champ).
|
||||||
|
5. **RG-5.05** : `net_weight = full_weight - empty_weight` (plein − vide) si les 2 poids présents, sinon `null`.
|
||||||
|
6. **RG-5.01 / RG-5.10** : `WeighingTicketFieldNormalizer` (service appelé avant validation) — `immatriculation` trim+UPPER ; si `!plateFreeFormat` reformate `XX-000-XX` et **rejette en 422** si invalide ; `otherLabel` trim.
|
||||||
|
7. `site` immuable au PATCH (RG-5.09).
|
||||||
|
|
||||||
|
## Étapes — `WeighingTicketProvider` (GET)
|
||||||
|
8. Liste **paginée** via `ApiPlatform\Doctrine\Orm\Paginator` (jamais d'array brut — règle n°13).
|
||||||
|
9. **Cloisonnement par site courant** (§ 2.3) : appliquer le `SiteScopedQueryExtension` existant (ou filtrer sur le site courant).
|
||||||
|
10. Query params : `?search=` (sur `number`, nom client/fournisseur, `other_label`, `immatriculation`), tri `displayDate` (défaut `number DESC`).
|
||||||
|
11. Anti-N+1 : fetch-join `client`/`supplier`/`site` (ManyToOne sûrs).
|
||||||
|
|
||||||
|
## Garde-fous
|
||||||
|
- `declare(strict_types=1);` ; commentaires FR ; messages de validation **FR**.
|
||||||
|
- Toutes les violations 422 portent un `propertyPath` aligné sur les noms de champs (consommé par le front `useFormErrors`).
|
||||||
|
- Pas de controller ; pas de `paginationEnabled: false`.
|
||||||
|
|
||||||
|
## Vérification
|
||||||
|
- `make test` (les tests dédiés sont écrits en ERP-187) : au minimum `CollectionsArePaginatedTest` **vert**.
|
||||||
|
- `make php-cs-fixer-allow-risky`.
|
||||||
|
- Smoke manuel : `POST /api/weighing_tickets` (Usine) → numéro `86-TP-0001` attribué, `net_weight` calculé ; second POST même site → `86-TP-0002`.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Prompt d'implémentation — M5 · ERP-186 (1.6) — Export XLSX des tickets de pesée
|
||||||
|
|
||||||
|
Projet **Starseed**. Tâche **back**. Lis `CLAUDE.md`, `.claude/rules/backend.md` et la spec : `docs/specs/M5-tickets-pesee/spec-back.md` (§ 4.5). Prérequis : ERP-185.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Endpoint d'export XLSX de **toute la liste** des tickets de pesée (bouton « Exporter »).
|
||||||
|
|
||||||
|
## Étapes
|
||||||
|
1. Endpoint **`GET /api/weighing_tickets/export.xlsx`** : opération API Platform dédiée avec provider renvoyant un binaire (`Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`, `Content-Disposition: attachment`).
|
||||||
|
2. Respecter le **site courant** + les filtres actifs (mêmes critères que la liste, mais **sans pagination** → export complet).
|
||||||
|
3. Colonnes : Numéro, Contrepartie (type + nom Client/Fournisseur/Autre), Date, Immatriculation, Poids vide, Poids plein, **Poids net**, DSD vide, DSD plein.
|
||||||
|
4. Sécurité `is_granted('logistique.weighing_tickets.view')`.
|
||||||
|
5. Whitelister cette opération dans `CollectionsArePaginatedTest::EXCLUDED` (export complet légitime).
|
||||||
|
|
||||||
|
## Garde-fous
|
||||||
|
- `declare(strict_types=1);` ; commentaires FR.
|
||||||
|
- Utiliser le helper XLSX standard du projet (cf. exports M1→M4) — ne pas réinventer.
|
||||||
|
- Pas de controller custom sous `/api` sans `priority: 1` (préférer une opération API Platform).
|
||||||
|
|
||||||
|
## Vérification
|
||||||
|
- `make test` : test de l'export (colonnes + filtrage site) + `CollectionsArePaginatedTest` vert.
|
||||||
|
- `make php-cs-fixer-allow-risky`.
|
||||||
|
- Téléchargement manuel → fichier ouvrable, colonnes correctes, poids net = plein − vide.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Prompt d'implémentation — M5 · ERP-187 (1.7) — Tests PHPUnit RG-5.01→5.10 + capture contrat JSON
|
||||||
|
|
||||||
|
Projet **Starseed**. Tâche **back / tests**. Lis `CLAUDE.md`, `.claude/rules/testing.md` et la spec : `docs/specs/M5-tickets-pesee/spec-back.md` (§ 8, § 4.0.bis). Prérequis : ERP-183 → ERP-186 mergés.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Couvrir les RG du M5 par des tests PHPUnit et **capturer la réponse JSON réelle** (DoD) à coller dans la spec avant le démarrage front.
|
||||||
|
|
||||||
|
## Étapes
|
||||||
|
1. **`WeighingTicketSerializationContractTest`** : seeder un ticket complet (contrepartie Client, pesée vide + plein), capturer le JSON **liste** + **détail** (via une variable d'env de dump, cf. pattern M4 `CARRIER_DOD_DUMP`). Vérifier les **4 pièges** :
|
||||||
|
- `client` / `supplier` sortent en **objet embarqué**, pas en IRI nu ;
|
||||||
|
- `plateFreeFormat` présent dans le JSON ;
|
||||||
|
- `number` présent et formaté `{siteCode}-TP-{NNNN}` ;
|
||||||
|
- `netWeight` = `full - empty` (plein − vide).
|
||||||
|
→ **Coller le JSON capturé dans `spec-back.md § 4.0.bis`** (feu vert front).
|
||||||
|
2. `WeighingTicketNumberingTest` : numéro par site, unicité, concurrence (`FOR UPDATE`), immuabilité au PATCH.
|
||||||
|
3. `DsdAllocatorTest` : AUTO incrémente / MANUAL = dernier+1 / compteur par site.
|
||||||
|
4. `WeighbridgeReaderStubTest` : poids ∈ [10000,50000] ; `WeighbridgeUnavailableException` → 503 (RG-5.06).
|
||||||
|
5. `NetWeightTest` : plein − vide ; `null` si une pesée manque (RG-5.05).
|
||||||
|
6. `CounterpartyValidationTest` : RG-5.03 (chaque branche valide + rejets des incohérences).
|
||||||
|
7. `ImmatriculationNormalizationTest` : masque `XX-000-XX`, `plateFreeFormat`, 422 si invalide (RG-5.01).
|
||||||
|
8. **RBAC** : Admin/Bureau/Usine OK ; Compta/Commerciale → 403 ; anonyme → 401.
|
||||||
|
|
||||||
|
## Garde-fous
|
||||||
|
- `declare(strict_types=1);` ; fixtures dédiées sous `tests/Fixtures/`.
|
||||||
|
- **Pas de test E2E** (règle d'or) — PHPUnit uniquement.
|
||||||
|
- Ne pas casser les tests Architecture existants.
|
||||||
|
|
||||||
|
## Vérification
|
||||||
|
- `make test` **vert** (suite complète, dont Architecture).
|
||||||
|
- `spec-back.md § 4.0.bis` contient le JSON RÉEL avec les 4 pièges marqués verts.
|
||||||
|
- `make php-cs-fixer-allow-risky`.
|
||||||
@@ -0,0 +1,790 @@
|
|||||||
|
---
|
||||||
|
# === IDENTITÉ ===
|
||||||
|
module: M5
|
||||||
|
nom: "Tickets de pesée"
|
||||||
|
ecran: tickets-pesee
|
||||||
|
owner_spec: Matthieu
|
||||||
|
backup_spec: Tristan
|
||||||
|
version: V0.1
|
||||||
|
date_redaction: 2026-06-17
|
||||||
|
# Historique :
|
||||||
|
# V0.1 (2026-06-17) — Spec back initiale. Restitution + précisions back du docx fonctionnel
|
||||||
|
# « M5-ticket-de-pesee-V02 » (V0.2, 15/06/2026, validation client en attente).
|
||||||
|
# Décisions Matthieu (17/06) :
|
||||||
|
# (1) NOUVEAU module `Logistique` (pas une greffe sur Transport).
|
||||||
|
# (2) Pont bascule = PAS de liaison matérielle au M5 → stub renvoyant un poids
|
||||||
|
# aléatoire ∈ [10000, 50000] kg. Driver réel = hors périmètre (ticket dédié).
|
||||||
|
# (3) DSD = compteur de pesée du pont (en manuel : dernier dsd + 1).
|
||||||
|
# (4) Poids net (non précisé par le docx) = poids plein − poids vide, calculé serveur
|
||||||
|
# (CONFIRMÉ Matthieu 17/06 — § 2.8 / RG-5.05).
|
||||||
|
# Maquette Figma (node 1322-16774, board « Module 5 : Ticket de pesée ») intégrée le 17/06 :
|
||||||
|
# les DEUX blocs (vide + plein) portent « Pesée bascule » + « Pesée manuelle » ;
|
||||||
|
# DSD séquentiel +1 par pesée (16619 → 16620) ; contrepartie portée par le bloc vide.
|
||||||
|
|
||||||
|
# === LIENS ===
|
||||||
|
spec_front: ./spec-front.md
|
||||||
|
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1322-16774&p=f&m=dev"
|
||||||
|
trace_fonctionnelle: "uploads/M5-ticket-de-pesee-V02.pdf (V0.2, 15/06/2026, validation client en attente)"
|
||||||
|
|
||||||
|
# === LIEN LESSTIME ===
|
||||||
|
lesstime_project_id: 6
|
||||||
|
lesstime_taskgroup_id: 33 # M5 — Tickets de pesée (ERP-181 → ERP-192)
|
||||||
|
statut_global: pret_a_dev
|
||||||
|
|
||||||
|
# === DÉPENDANCES AMONT ===
|
||||||
|
depend_de:
|
||||||
|
- Sites # SitesModule + sélecteur de site (CurrentSiteProviderInterface) + SiteScopedQueryExtension → numérotation + cloisonnement
|
||||||
|
- Commercial # Client (M1) + Supplier (M2) → contrepartie du ticket (Client / Fournisseur)
|
||||||
|
- Core # User, Role, Permission, Audit, JWT
|
||||||
|
- Shared # TimestampableBlamableTrait + Subscriber (ERP-52)
|
||||||
|
---
|
||||||
|
|
||||||
|
# Spec back — Module 5 : Tickets de pesée
|
||||||
|
|
||||||
|
## 1. Contexte
|
||||||
|
|
||||||
|
Cette spec **complète et précise** la [spec front V0.1](./spec-front.md) (docx `M5-ticket-de-pesee-V02`, V0.2 du 15/06/2026) avec tout ce qui touche au back : décisions d'archi, modèle de données, migration, API REST, RBAC, règles de gestion (RG-5.01 + précisions back RG-5.02 → RG-5.10), intégration pont bascule (stub), tests, hors-périmètre.
|
||||||
|
|
||||||
|
**Module cible** : **NOUVEAU module `Logistique`** (`src/Module/Logistique/`) — DÉCISION Matthieu (17/06). Le docx parle de « page d'entrée du Module *Logistique* » : on en fait un module à part entière (scaffolding via le skill `create-module`), distinct de `Transport` (M4). Son premier périmètre fonctionnel exposé est le **ticket de pesée** (entité `WeighingTicket`).
|
||||||
|
|
||||||
|
> **Distinction Transport (M4) vs Logistique (M5)** : `Transport` = référentiel des transporteurs (qui transporte). `Logistique` = opérations physiques sur site, à commencer par la **pesée au pont bascule**. Les deux peuvent à terme cohabiter dans une même section sidebar « Logistique » (cf. § 5.3), mais restent **deux modules** (activables/désactivables séparément).
|
||||||
|
|
||||||
|
> **RETEX obligatoire (M1→M4)** : ~80 % des frictions venaient du **contrat de sérialisation** (groupes / sous-ressources / embed), pas du métier. La section § 4.0 applique ce RETEX au M5. On réutilise aussi le pattern Provider/Processor + normalisation serveur + Timestampable/Blamable + audit i18n posé aux modules précédents.
|
||||||
|
|
||||||
|
**Dépendances déjà en place sur `develop`** :
|
||||||
|
- `Sites` → 3 sites Châtellerault (86) / Saint-Jean (17) / Pommevic (82) ; **sélecteur de site** exposé via `Sites\Application\Service\CurrentSiteProviderInterface` ; `SiteScopedQueryExtension` (filtrage par site courant) ; `SiteInterface` (contrat partagé).
|
||||||
|
- `Commercial` → `Client` (M1) + `Supplier` (M2).
|
||||||
|
- `Shared` → `TimestampableBlamableTrait` + `Subscriber` (ERP-52).
|
||||||
|
- `Core` → User, Role, Permission, Audit, JWT.
|
||||||
|
|
||||||
|
## 2. Décisions d'archi
|
||||||
|
|
||||||
|
### 2.1 Nouveau module `Logistique` + entité `WeighingTicket`
|
||||||
|
|
||||||
|
Création du module **`Logistique`** :
|
||||||
|
- `src/Module/Logistique/LogistiqueModule.php` — `ID = 'logistique'`, `LABEL = 'Logistique'`, `REQUIRED = false`, `permissions()` (§ 5.1).
|
||||||
|
- Ajout dans `config/modules.php` : `LogistiqueModule::class`.
|
||||||
|
- `Domain/`, `Application/`, `Infrastructure/` (arborescence DDD standard).
|
||||||
|
- Layer front `frontend/modules/logistique/` (kebab-case — règle naming).
|
||||||
|
|
||||||
|
Entité racine : **`WeighingTicket`** (ticket de pesée) sous `src/Module/Logistique/Domain/Entity/`, avec ses **deux pesées** (vide + plein) modélisées en colonnes plates (§ 2.4).
|
||||||
|
|
||||||
|
**Référentiels cross-module consommés en relation ORM partagée (PAS d'import de logique)** — exactement comme M2/M3/M4 : le ticket référence `Client` (M1), `Supplier` (M2) et `Site` (Sites) via des **relations ORM** (ManyToOne). Ce sont des **données de référence partagées**, pas de la logique inter-module (aucun service/repository d'un autre module appelé). La seule logique cross-module consommée est `CurrentSiteProviderInterface` (déjà un **contrat** exposé par Sites — autorisé par la règle ABSOLUE n°1).
|
||||||
|
|
||||||
|
### 2.2 IDs — convention `INT` (alignée Core/Commercial/Sites)
|
||||||
|
|
||||||
|
Le module `Logistique` est un **nouveau** module métier hors périmètre Transport : on s'aligne sur la convention **`INT GENERATED BY DEFAULT AS IDENTITY`** des modules historiques (Core / Commercial / Sites), et **non** sur le `BIGINT` du module Transport. Horodatages en `TIMESTAMP(0) WITHOUT TIME ZONE` (le `TimestampableBlamableTrait` mappe `datetime_immutable`).
|
||||||
|
|
||||||
|
### 2.3 Cloisonnement par site courant (DÉCISION par défaut — à confirmer)
|
||||||
|
|
||||||
|
> **Décision par défaut** : les tickets de pesée sont des **données opérationnelles rattachées à un site physique** (le pont bascule est sur site). On **cloisonne la liste par le site courant** (sélecteur de site en haut de l'app) via le `SiteScopedQueryExtension` **déjà existant** (Sites). Un utilisateur voit les tickets du site actif.
|
||||||
|
|
||||||
|
- Colonne `site_id` NOT NULL sur `weighing_ticket` (renseignée à la création depuis `CurrentSiteProviderInterface`).
|
||||||
|
- `GET /api/weighing_tickets` filtré sur le site courant (extension automatique).
|
||||||
|
- Le **numéro** du ticket encode déjà le site (RG-5.02) → cohérent avec le cloisonnement.
|
||||||
|
|
||||||
|
> **À confirmer client** : si le métier veut une **vue multi-sites** (tous sites confondus), retirer le cloisonnement et ajouter un filtre `?siteId=`. Tracé HP-M5-01 (§ 9).
|
||||||
|
|
||||||
|
### 2.4 Modélisation des deux pesées — colonnes plates (pas de sous-entité)
|
||||||
|
|
||||||
|
Un ticket porte **exactement deux pesées** : une **à vide** (tare) et une **à plein** (brut). Plutôt qu'une sous-collection `Weighing` (1:n), on modélise **deux jeux de colonnes plates** sur `weighing_ticket` :
|
||||||
|
|
||||||
|
| Groupe | Colonnes |
|
||||||
|
|---|---|
|
||||||
|
| Pesée à vide | `empty_date`, `empty_weight`, `empty_dsd`, `empty_mode` (AUTO/MANUAL), `empty_manual_number` |
|
||||||
|
| Pesée à plein | `full_date`, `full_weight`, `full_dsd`, `full_mode` (AUTO/MANUAL), `full_manual_number` |
|
||||||
|
|
||||||
|
Justification : cardinalité **fixe** (toujours 1 vide + 1 plein), pas de tri/ajout dynamique, requêtes/exports plus simples, audit lisible. (Alternative sous-entité `Weighing` documentée mais non retenue — over-engineering pour 2 lignes figées.)
|
||||||
|
|
||||||
|
> **Champs `*_manual_number`** : « numéro de pesée » saisi en **pesée manuelle** (référence d'un ticket papier / autre bascule — distinct du DSD, cf. RG-5.04). Nullable (rempli seulement si `mode = MANUAL`).
|
||||||
|
> **Maquette (17/06)** : les **deux** blocs (vide ET plein) portent les boutons « Pesée bascule » + « Pesée manuelle » — le modèle symétrique (`empty_*` ET `full_*` avec mode AUTO/MANUAL) est donc bien utilisé des deux côtés. (Le texte du docx V0.2 ne mentionnait la manuelle que sur le bloc vide ; la maquette fait foi.)
|
||||||
|
|
||||||
|
### 2.5 Numérotation `{siteCode}-TP-{NNNN}` (RG-5.02)
|
||||||
|
|
||||||
|
> **Décision** : chaque ticket reçoit un **numéro unique par site** au format `{siteCode}-TP-{NNNN}` (ex. `86-TP-0001`). La séquence est **propre à chaque site** → `86-TP-0001` et `17-TP-0001` coexistent (cf. docx).
|
||||||
|
|
||||||
|
- **`siteCode`** : le `Site` actuel n'a **pas** de colonne `code`. On **ajoute** `site.code` (VARCHAR court, ex. `86`/`17`/`82`) — backfill par défaut = 2 premiers chiffres du `postal_code`, valeur éditable ensuite côté admin Sites. Justification : un code explicite est plus robuste qu'une dérivation implicite du CP (collisions de département possibles). Petit débordement assumé sur le module Sites (1 colonne).
|
||||||
|
- ⚠ **Cadencement en 2 temps (RETEX dev ERP-182, 17/06)** : `NOT NULL` ne peut PAS être posé dans la migration M5 seule. Sur base fraîche (`make db-reset`), les fixtures `SitesFixtures` font `new Site(...)` via l'ORM, qui ne connaît `code` que si la **propriété est mappée sur l'entité** `Site.php` (pas le cas avant ERP-183) → `INSERT` sans `code` → violation `NOT NULL`. Décision :
|
||||||
|
- **ERP-182 (migration)** : créer `site.code` **NULLABLE** + backfill + index unique (les `NULL` multiples sont tolérés par l'index unique Postgres). `make db-reset` passe, aucun test cassé.
|
||||||
|
- **ERP-183 (entité)** : mapper `Site::code` (propriété + getter/setter), le peupler dans `SitesFixtures` (86/17/82) + `SeedE2ECommand`, ajuster les tests Sites en collision d'unicité (ex. `SiteApiTest` créant un site CP `86000` → `code` 86 = collision avec Châtellerault), **puis** poser `NOT NULL` via une **2ᵉ petite migration**.
|
||||||
|
- **Séquence par site** : table dédiée `weighing_ticket_counter (site_id PK, last_value INT)`. À la création : `SELECT ... FOR UPDATE` sur la ligne du site (verrou ligne) → `last_value + 1`, formaté `%04d` (zéro-padding 4 chiffres, débordement naturel au-delà de 9999). Garantit l'unicité même en concurrence.
|
||||||
|
- Le numéro est **immuable** après création (pas modifiable à l'édition).
|
||||||
|
- Index unique `uq_weighing_ticket_number (site_id, number)`.
|
||||||
|
|
||||||
|
> **Alternative écartée** : séquence Postgres par site (création dynamique de séquences) — moins portable, plus lourde à seeder. La table compteur + `FOR UPDATE` est le pattern retenu.
|
||||||
|
|
||||||
|
### 2.6 Intégration pont bascule — stub au M5 (RG-5.06)
|
||||||
|
|
||||||
|
> **Décision Matthieu (17/06)** : **aucune liaison matérielle** au M5. Le « pont bascule » est **simulé** : il renvoie un **poids aléatoire ∈ [10000, 50000] kg**.
|
||||||
|
|
||||||
|
- Contrat : `Logistique\Domain\Contract\WeighbridgeReaderInterface`
|
||||||
|
```php
|
||||||
|
interface WeighbridgeReaderInterface
|
||||||
|
{
|
||||||
|
/** @throws WeighbridgeUnavailableException si la bascule ne répond pas (→ bascule manuelle). */
|
||||||
|
public function read(SiteInterface $site): WeighbridgeReading; // {weight: int (kg), dsd: int}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Implémentation livrée au M5 : `Infrastructure\Weighbridge\RandomWeighbridgeReader` → `weight = random_int(10000, 50000)`, `dsd = nextDsd(site)` (RG-5.04).
|
||||||
|
- **Driver matériel réel** (protocole série/TCP de l'indicateur de pesage, parsing trame, reconnexion) = **hors périmètre M5**, tracé HP-M5-02 (§ 9). Le jour venu, on substitue l'implémentation derrière l'interface — **zéro impact** sur les écrans / l'API.
|
||||||
|
- **Gestion d'erreur** (RG-5.06) : si `read()` lève `WeighbridgeUnavailableException`, l'API renvoie un **422/503 explicite** « Pont bascule indisponible — passez en pesée manuelle ». Le front affiche le message dans la modal et propose la pesée manuelle (le stub ne lève jamais l'exception au M5, mais le chemin d'erreur est implémenté et testé).
|
||||||
|
|
||||||
|
### 2.7 DSD — compteur de pesée du pont (RG-5.04)
|
||||||
|
|
||||||
|
> **Décision Matthieu (17/06)** : le **DSD** est un **compteur de pesée** (index séquentiel des pesées du pont). Chaque pesée (vide OU plein) consomme **une** valeur DSD.
|
||||||
|
|
||||||
|
- Compteur **par site** (un pont par site) : table `weighbridge_dsd_counter (site_id PK, last_value INT)` (verrou ligne `FOR UPDATE`, même pattern que le compteur de numéro).
|
||||||
|
- **Pesée bascule (AUTO)** : la lecture incrémente le compteur du site et renvoie la nouvelle valeur (le stub fait pareil ; un vrai pont renverrait son propre index, qu'on persisterait).
|
||||||
|
- **Pesée manuelle** : `dsd = dernier dsd du site + 1` (le docx : « le dsd est automatiquement calculé en fonction du dernier dsd en base de données »).
|
||||||
|
- Un ticket complet (vide + plein en AUTO) consomme **2 incréments DSD** (`empty_dsd`, `full_dsd`).
|
||||||
|
|
||||||
|
### 2.8 Poids net — `plein − vide`, calculé serveur (RG-5.05)
|
||||||
|
|
||||||
|
> **Le docx ne définit pas** le calcul du poids affiché en liste (colonne « Poids »). **CONFIRMÉ Matthieu (17/06)** : **poids net = poids plein − poids vide**.
|
||||||
|
|
||||||
|
- Stocké en colonne dérivée `net_weight` (INT, kg), **recalculé serveur** par le `WeighingTicketProcessor` à chaque POST/PATCH dès que `empty_weight` ET `full_weight` sont renseignés (sinon `null`).
|
||||||
|
- La colonne **liste « Poids » = `net_weight`** (cf. § 4.0). Le détail/ticket affiche vide + plein + net.
|
||||||
|
- Exemple maquette : plein `14 300` − vide `7 150` = **net `7 150` kg**.
|
||||||
|
|
||||||
|
### 2.9 Contrepartie CLIENT / FOURNISSEUR / AUTRE (RG-5.03)
|
||||||
|
|
||||||
|
Le formulaire principal porte un sélecteur **« Fournisseur / Client / Autre »** qui pilote des champs conditionnels (docx p.4). Le back **ne maintient pas de state machine** : il stocke et **valide la cohérence** au POST/PATCH.
|
||||||
|
|
||||||
|
| `counterparty_type` | Champs requis | Champs forcés nuls |
|
||||||
|
|---|---|---|
|
||||||
|
| `CLIENT` | `client_id` (FK Client) | `supplier_id`, `other_label` |
|
||||||
|
| `FOURNISSEUR` | `supplier_id` (FK Supplier) | `client_id`, `other_label` |
|
||||||
|
| `AUTRE` | `other_label` (texte libre) | `client_id`, `supplier_id` |
|
||||||
|
|
||||||
|
Validation via `#[Assert\Callback]` + CHECK Postgres (garde-fous miroir M4 § 3.2).
|
||||||
|
|
||||||
|
### 2.10 Masque immatriculation & « Tout format » (RG-5.01)
|
||||||
|
|
||||||
|
- `immatriculation` : par défaut **masque `XX-000-XX`** (plaque FR SIV). Si **`plate_free_format = true`** (« Tout format » coché), le masque est désactivé (saisie libre — anciennes plaques, étranger, engins).
|
||||||
|
- **Champs connectés entre les deux formulaires** (vide ⇄ plein) : `immatriculation` et `plate_free_format` sont **portés par le ticket** (une seule valeur, partagée par les 2 formulaires) — c'est le même véhicule. Pas de duplication.
|
||||||
|
- Normalisation serveur : `immatriculation` → trim + UPPER + (si masque) re-formatage `XX-000-XX` ; rejet 422 si format invalide et `plate_free_format = false`.
|
||||||
|
|
||||||
|
### 2.11 Audit & traces temporelles
|
||||||
|
|
||||||
|
Pattern Starseed standard (miroir M1→M4) :
|
||||||
|
- `#[Auditable]` sur `WeighingTicket`. Pas de champ sensible (password/token) → pas d'`#[AuditIgnore]`.
|
||||||
|
- Audit des FK (`client`, `supplier`, `site`) tracé automatiquement.
|
||||||
|
- `WeighingTicket implements TimestampableInterface, BlamableInterface` + `use TimestampableBlamableTrait` (4 colonnes standard).
|
||||||
|
- **Libellé i18n** (règle ABSOLUE backend — `AuditableEntitiesHaveI18nLabelTest`) : ajouter `audit.entity.logistique_weighingticket` dans `frontend/i18n/locales/fr.json` (clé = `strtolower(module)` + `_` + `strtolower(Entity)`).
|
||||||
|
|
||||||
|
### 2.12 Bon de pesée — PDF généré côté serveur via template Twig (RG-5.08)
|
||||||
|
|
||||||
|
> **DÉCISION Matthieu (17/06)** : le **bon de pesée est généré côté back** par un **template Twig → PDF** (et non un gabarit imprimé par le navigateur). **OWNER : Tristan** (ticket back dédié, cf. § 10). Cette spec en pose le contrat (endpoint, contenu, données).
|
||||||
|
|
||||||
|
Contrat attendu :
|
||||||
|
- **Endpoint** : `GET /api/weighing_tickets/{id}/print.pdf` (opération API Platform dédiée, **pas de controller** — provider renvoyant un binaire). Sécurité `is_granted('logistique.weighing_tickets.view')`. Réponse `Content-Type: application/pdf` (inline).
|
||||||
|
- **Rendu** : un template **Twig** (`templates/logistique/weighing_ticket_print.html.twig`) hydraté avec le ticket → converti en PDF via le générateur PDF du projet (ex. Dompdf / wkhtmltopdf / Gotenberg — s'aligner sur l'existant ; sinon proposer une lib et la cadrer avec Matthieu).
|
||||||
|
- **Contenu du bon** : numéro (`{siteCode}-TP-{NNNN}`), site, contrepartie (Client / Fournisseur / Autre + libellé), immatriculation, **pesée à vide** (date/poids/DSD), **pesée à plein** (date/poids/DSD), **poids net** (= plein − vide), date d'édition. (En-tête / logo / mentions = à caler par Tristan.)
|
||||||
|
- **Données** : toutes déjà disponibles sur le ticket (mêmes champs que `GET /api/weighing_tickets/{id}` § 4.0) — aucun champ API supplémentaire requis.
|
||||||
|
- **Déclencheurs front** (RG-5.08) : à la **validation** (création), le front ouvre l'aperçu/PDF servi par cet endpoint ; en **modification**, le bouton **« Imprimer »** ouvre le même PDF (absent à l'ajout).
|
||||||
|
|
||||||
|
### 2.13 Pas d'archive ; soft delete préparé non exposé
|
||||||
|
|
||||||
|
Le docx M5 **ne prévoit pas** d'archivage (contrairement au M4). On **n'expose pas** d'archive. On prépare néanmoins une colonne `deleted_at` (soft delete technique) **non exposée** au M5 (`DELETE` non exposé → 404). Cohérent avec le pattern projet.
|
||||||
|
|
||||||
|
## 3. Modèle de données
|
||||||
|
|
||||||
|
### 3.1 Diagramme
|
||||||
|
|
||||||
|
```
|
||||||
|
+------------------+
|
||||||
|
| site (Sites) | + NOUVELLE colonne `code` (86/17/82)
|
||||||
|
+------------------+
|
||||||
|
^ ^ ^
|
||||||
|
site_id | | site_id| site_id
|
||||||
|
+---------------+ | +------------------------+
|
||||||
|
| | |
|
||||||
|
+-----------------------+ +--------------------------+ +--------------------------+
|
||||||
|
| weighing_ticket_counter| | weighbridge_dsd_counter | | weighing_ticket |
|
||||||
|
| site_id PK | | site_id PK | | id (PK) |
|
||||||
|
| last_value INT | | last_value INT | | number (UNIQUE / site) |
|
||||||
|
+-----------------------+ +--------------------------+ | site_id (FK) |
|
||||||
|
(séquence n° ticket) (compteur DSD pont) | counterparty_type |
|
||||||
|
| client_id (FK M1, null) |--> client (M1)
|
||||||
|
| supplier_id (FK M2, null)|--> supplier (M2)
|
||||||
|
| other_label (null) |
|
||||||
|
| immatriculation |
|
||||||
|
| plate_free_format |
|
||||||
|
| empty_* (date/weight/dsd/mode/manual_number) |
|
||||||
|
| full_* (date/weight/dsd/mode/manual_number) |
|
||||||
|
| net_weight (dérivé) |
|
||||||
|
| deleted_at (soft, non exposé) |
|
||||||
|
+--------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Migration Doctrine — SQL Postgres
|
||||||
|
|
||||||
|
Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrations/VersionYYYYMMDDHHMMSS.php` (à dater, postérieur aux migrations existantes).
|
||||||
|
|
||||||
|
> **Même justification qu'aux M1→M4** : la migration crée un schéma avec **FK cross-module** (`user`, `client`, `supplier`, `site`). Le namespace modulaire casserait l'ordre (`make db-reset`) — exception racine de la règle ABSOLUE n°11.
|
||||||
|
|
||||||
|
> **Rappel règle ABSOLUE n°12** : chaque colonne créée DOIT recevoir son `COMMENT ON COLUMN` (FR, ≤ 200 car., sémantique + contrainte/RG). Les 4 colonnes Timestampable/Blamable passent par le helper `addStandardTimestampableBlamableComments`. SQL ci-dessous *illustratif* (convention `INT GENERATED BY DEFAULT AS IDENTITY`, `TIMESTAMP(0)`).
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- =====================================================================
|
||||||
|
-- Ajout d'un code de site (préfixe de numérotation TP) — § 2.5
|
||||||
|
-- =====================================================================
|
||||||
|
-- ⚠ NULLABLE au M5 (ERP-182). Le SET NOT NULL est posé en ERP-183, une fois Site::code
|
||||||
|
-- mappé sur l'entité et peuplé dans les fixtures (sinon db-reset casse — cf. § 2.5).
|
||||||
|
ALTER TABLE site ADD COLUMN code VARCHAR(8);
|
||||||
|
-- Backfill : 2 premiers chiffres du code postal (dépt) par défaut, éditable ensuite.
|
||||||
|
UPDATE site SET code = LEFT(postal_code, 2) WHERE code IS NULL;
|
||||||
|
-- Index unique tolérant les NULL (Postgres : plusieurs NULL autorisés) — OK tant que code nullable.
|
||||||
|
CREATE UNIQUE INDEX uq_site_code ON site (code);
|
||||||
|
-- ERP-183 (2ᵉ migration) : ALTER TABLE site ALTER COLUMN code SET NOT NULL;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Compteur de numéro de ticket (séquence par site) — RG-5.02
|
||||||
|
-- =====================================================================
|
||||||
|
CREATE TABLE weighing_ticket_counter (
|
||||||
|
site_id INT PRIMARY KEY REFERENCES site(id) ON DELETE CASCADE,
|
||||||
|
last_value INT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Compteur DSD (pesée du pont, par site) — RG-5.04
|
||||||
|
-- =====================================================================
|
||||||
|
CREATE TABLE weighbridge_dsd_counter (
|
||||||
|
site_id INT PRIMARY KEY REFERENCES site(id) ON DELETE CASCADE,
|
||||||
|
last_value INT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Table principale `weighing_ticket`
|
||||||
|
-- =====================================================================
|
||||||
|
CREATE TABLE weighing_ticket (
|
||||||
|
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT,
|
||||||
|
number VARCHAR(20) NOT NULL, -- {siteCode}-TP-{NNNN} (RG-5.02)
|
||||||
|
-- Contrepartie (RG-5.03)
|
||||||
|
counterparty_type VARCHAR(12) NOT NULL, -- CLIENT|FOURNISSEUR|AUTRE
|
||||||
|
client_id INT REFERENCES client(id) ON DELETE RESTRICT,
|
||||||
|
supplier_id INT REFERENCES supplier(id) ON DELETE RESTRICT,
|
||||||
|
other_label VARCHAR(255),
|
||||||
|
-- Véhicule (RG-5.01, partagé entre les 2 formulaires)
|
||||||
|
immatriculation VARCHAR(20) NOT NULL,
|
||||||
|
plate_free_format BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
-- Pesée à vide (§ 2.4)
|
||||||
|
empty_date TIMESTAMP(0) WITHOUT TIME ZONE,
|
||||||
|
empty_weight INT, -- kg
|
||||||
|
empty_dsd INT,
|
||||||
|
empty_mode VARCHAR(8), -- AUTO|MANUAL
|
||||||
|
empty_manual_number VARCHAR(50), -- numéro de pesée manuelle (RG-5.04)
|
||||||
|
-- Pesée à plein (§ 2.4)
|
||||||
|
full_date TIMESTAMP(0) WITHOUT TIME ZONE,
|
||||||
|
full_weight INT, -- kg
|
||||||
|
full_dsd INT,
|
||||||
|
full_mode VARCHAR(8), -- AUTO|MANUAL
|
||||||
|
full_manual_number VARCHAR(50),
|
||||||
|
-- Dérivé (RG-5.05)
|
||||||
|
net_weight INT, -- full_weight - empty_weight (RG-5.05)
|
||||||
|
-- Soft delete (préparé, non exposé au M5)
|
||||||
|
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE,
|
||||||
|
-- Timestampable + Blamable
|
||||||
|
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
|
||||||
|
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT chk_wt_counterparty_type
|
||||||
|
CHECK (counterparty_type IN ('CLIENT','FOURNISSEUR','AUTRE')),
|
||||||
|
CONSTRAINT chk_wt_empty_mode CHECK (empty_mode IS NULL OR empty_mode IN ('AUTO','MANUAL')),
|
||||||
|
CONSTRAINT chk_wt_full_mode CHECK (full_mode IS NULL OR full_mode IN ('AUTO','MANUAL')),
|
||||||
|
-- RG-5.03 : cohérence contrepartie
|
||||||
|
CONSTRAINT chk_wt_client_branch CHECK (
|
||||||
|
counterparty_type <> 'CLIENT' OR (client_id IS NOT NULL AND supplier_id IS NULL AND other_label IS NULL)
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_wt_supplier_branch CHECK (
|
||||||
|
counterparty_type <> 'FOURNISSEUR' OR (supplier_id IS NOT NULL AND client_id IS NULL AND other_label IS NULL)
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_wt_other_branch CHECK (
|
||||||
|
counterparty_type <> 'AUTRE' OR (other_label IS NOT NULL AND client_id IS NULL AND supplier_id IS NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX uq_weighing_ticket_number ON weighing_ticket (site_id, number);
|
||||||
|
CREATE INDEX idx_wt_site ON weighing_ticket (site_id);
|
||||||
|
CREATE INDEX idx_wt_client ON weighing_ticket (client_id);
|
||||||
|
CREATE INDEX idx_wt_supplier ON weighing_ticket (supplier_id);
|
||||||
|
CREATE INDEX idx_wt_deleted_at ON weighing_ticket (deleted_at);
|
||||||
|
CREATE INDEX idx_wt_created_by ON weighing_ticket (created_by);
|
||||||
|
CREATE INDEX idx_wt_updated_by ON weighing_ticket (updated_by);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2.bis Commentaires SQL obligatoires (échantillon)
|
||||||
|
|
||||||
|
```php
|
||||||
|
$this->addSql("COMMENT ON TABLE weighing_ticket IS 'Tickets de pesée (M5 Logistique) — pesée à vide + à plein au pont bascule, contrepartie Client/Fournisseur/Autre.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN site.code IS 'Code court du site (ex. 86/17/82) — préfixe de numérotation des tickets de pesée (RG-5.02). Unique.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN weighing_ticket.number IS 'Numéro {siteCode}-TP-{NNNN}, unique par site, immuable. Séquence weighing_ticket_counter (RG-5.02).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN weighing_ticket.counterparty_type IS 'Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (RG-5.03). Pilote l''obligation client_id / supplier_id / other_label.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN weighing_ticket.immatriculation IS 'Plaque du véhicule, partagée entre pesée vide et plein. Masque XX-000-XX sauf si plate_free_format (RG-5.01).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN weighing_ticket.plate_free_format IS '« Tout format » : désactive le masque XX-000-XX de l''immatriculation (RG-5.01). Partagé entre les 2 formulaires.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN weighing_ticket.empty_dsd IS 'Compteur DSD du pont à la pesée à vide. AUTO=valeur du pont ; MANUAL=dernier dsd du site +1 (RG-5.04).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN weighing_ticket.empty_manual_number IS 'Numéro de pesée saisi en pesée manuelle (distinct du DSD) — formulaire à vide (RG-5.04).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN weighing_ticket.net_weight IS 'Poids net = full_weight - empty_weight (kg), calculé serveur (RG-5.05). Colonne Poids de la liste.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN weighbridge_dsd_counter.last_value IS 'Dernière valeur DSD attribuée pour le site (pont bascule). Incrément verrouillé FOR UPDATE (RG-5.04).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN weighing_ticket_counter.last_value IS 'Dernier numéro de ticket attribué pour le site. Incrément verrouillé FOR UPDATE (RG-5.02).'");
|
||||||
|
// + COMMENT ON COLUMN sur TOUTES les autres colonnes métier (règle n°12)
|
||||||
|
$this->addStandardTimestampableBlamableComments($schema, 'weighing_ticket');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Entité `WeighingTicket` — squelette (extrait)
|
||||||
|
|
||||||
|
Pattern jumeau de `Carrier`/`Supplier` (`#[Auditable]`, `TimestampableBlamableTrait`). **Chaque propriété affichée porte un read-group** (RETEX M1).
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Logistique\Domain\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Module\Commercial\Domain\Entity\Client; // relation ORM partagée (§ 2.1)
|
||||||
|
use App\Module\Commercial\Domain\Entity\Supplier; // relation ORM partagée (§ 2.1)
|
||||||
|
use App\Module\Sites\Domain\Entity\Site; // relation ORM partagée (§ 2.1)
|
||||||
|
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighingTicketProcessor;
|
||||||
|
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider\WeighingTicketProvider;
|
||||||
|
use App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository;
|
||||||
|
use App\Shared\Domain\Attribute\Auditable;
|
||||||
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
|
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||||
|
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(
|
||||||
|
security: "is_granted('logistique.weighing_tickets.view')",
|
||||||
|
normalizationContext: ['groups' => ['weighing_ticket:read', 'client:read', 'supplier:read', 'site:read', 'default:read']],
|
||||||
|
provider: WeighingTicketProvider::class,
|
||||||
|
),
|
||||||
|
new Get(
|
||||||
|
security: "is_granted('logistique.weighing_tickets.view')",
|
||||||
|
normalizationContext: ['groups' => ['weighing_ticket:read', 'weighing_ticket:item:read', 'client:read', 'supplier:read', 'site:read', 'default:read']],
|
||||||
|
provider: WeighingTicketProvider::class,
|
||||||
|
),
|
||||||
|
new Post(
|
||||||
|
security: "is_granted('logistique.weighing_tickets.manage')",
|
||||||
|
normalizationContext: ['groups' => ['weighing_ticket:read', 'weighing_ticket:item:read', 'client:read', 'supplier:read', 'site:read', 'default:read']],
|
||||||
|
denormalizationContext: ['groups' => ['weighing_ticket:write']],
|
||||||
|
processor: WeighingTicketProcessor::class,
|
||||||
|
),
|
||||||
|
new Patch(
|
||||||
|
security: "is_granted('logistique.weighing_tickets.manage')",
|
||||||
|
normalizationContext: ['groups' => ['weighing_ticket:read', 'weighing_ticket:item:read', 'client:read', 'supplier:read', 'site:read', 'default:read']],
|
||||||
|
denormalizationContext: ['groups' => ['weighing_ticket:write']],
|
||||||
|
provider: WeighingTicketProvider::class,
|
||||||
|
processor: WeighingTicketProcessor::class,
|
||||||
|
),
|
||||||
|
// Pas de Delete au M5 (HP). Pas d'archive (hors docx).
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ORM\Entity(repositoryClass: DoctrineWeighingTicketRepository::class)]
|
||||||
|
#[ORM\Table(name: 'weighing_ticket')]
|
||||||
|
#[Auditable]
|
||||||
|
class WeighingTicket implements TimestampableInterface, BlamableInterface
|
||||||
|
{
|
||||||
|
use TimestampableBlamableTrait;
|
||||||
|
|
||||||
|
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||||
|
#[Groups(['weighing_ticket:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
/** Numéro {siteCode}-TP-{NNNN} — attribué serveur, lecture seule (RG-5.02). */
|
||||||
|
#[ORM\Column(length: 20)]
|
||||||
|
#[Groups(['weighing_ticket:read'])]
|
||||||
|
private ?string $number = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Site::class)]
|
||||||
|
#[ORM\JoinColumn(name: 'site_id', nullable: false, onDelete: 'RESTRICT')]
|
||||||
|
#[Groups(['weighing_ticket:read'])] // renseigné serveur depuis le site courant (§ 2.3)
|
||||||
|
private ?Site $site = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 12)]
|
||||||
|
#[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR', 'AUTRE'], message: 'Type de contrepartie invalide.')]
|
||||||
|
#[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.')]
|
||||||
|
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||||
|
private ?string $counterpartyType = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Client::class)]
|
||||||
|
#[ORM\JoinColumn(name: 'client_id', nullable: true, onDelete: 'RESTRICT')]
|
||||||
|
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||||
|
private ?Client $client = null; // requis si counterpartyType=CLIENT (Callback RG-5.03)
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Supplier::class)]
|
||||||
|
#[ORM\JoinColumn(name: 'supplier_id', nullable: true, onDelete: 'RESTRICT')]
|
||||||
|
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||||
|
private ?Supplier $supplier = null; // requis si counterpartyType=FOURNISSEUR
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||||
|
private ?string $otherLabel = null; // requis si counterpartyType=AUTRE
|
||||||
|
|
||||||
|
#[ORM\Column(length: 20)]
|
||||||
|
#[Assert\NotBlank(message: 'L''immatriculation est obligatoire.')]
|
||||||
|
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||||
|
private ?string $immatriculation = null; // masque XX-000-XX sauf plateFreeFormat (RG-5.01)
|
||||||
|
|
||||||
|
#[ORM\Column(options: ['default' => false])]
|
||||||
|
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||||
|
private bool $plateFreeFormat = false;
|
||||||
|
|
||||||
|
// === Pesée à vide ===
|
||||||
|
#[ORM\Column(name: 'empty_date', type: 'datetime_immutable', nullable: true)]
|
||||||
|
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||||
|
private ?\DateTimeImmutable $emptyDate = null;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'empty_weight', nullable: true)]
|
||||||
|
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||||
|
private ?int $emptyWeight = null; // kg — readonly UI, rempli par la pesée (RG-5.07)
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'empty_dsd', nullable: true)]
|
||||||
|
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||||
|
private ?int $emptyDsd = null;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'empty_mode', length: 8, nullable: true)]
|
||||||
|
#[Assert\Choice(choices: ['AUTO', 'MANUAL'], message: 'Mode de pesée invalide.')]
|
||||||
|
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||||
|
private ?string $emptyMode = null;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'empty_manual_number', length: 50, nullable: true)]
|
||||||
|
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||||
|
private ?string $emptyManualNumber = null;
|
||||||
|
|
||||||
|
// === Pesée à plein (mêmes colonnes, préfixe full*) ===
|
||||||
|
// fullDate / fullWeight / fullDsd / fullMode / fullManualNumber ...
|
||||||
|
|
||||||
|
/** Poids net dérivé — calculé serveur (RG-5.05). */
|
||||||
|
#[ORM\Column(name: 'net_weight', nullable: true)]
|
||||||
|
#[Groups(['weighing_ticket:read'])]
|
||||||
|
private ?int $netWeight = null;
|
||||||
|
|
||||||
|
// RG-5.03 (contrepartie) + RG-5.01 (immat) : cohérence via #[Assert\Callback] (§ 7).
|
||||||
|
// ... getters/setters ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠ `Client` / `Supplier` / `Site` appartiennent à d'autres modules — on consomme leurs read-groups (`client:read`, `supplier:read`, `site:read`), **pas de logique inter-module** (§ 2.1).
|
||||||
|
|
||||||
|
## 4. API REST (API Platform)
|
||||||
|
|
||||||
|
### 4.0 Contrat de sérialisation (RETEX M1 — section critique)
|
||||||
|
|
||||||
|
> **Leçon M1→M4** : pour **chaque champ affiché** (liste OU détail), les **3 maillons** doivent être prouvés : (a) groupe sur la propriété, (b) groupe dans le `normalizationContext` de l'opération, (c) read-group de l'entité imbriquée présent dans le contexte parent.
|
||||||
|
|
||||||
|
**Contexte par opération** :
|
||||||
|
|
||||||
|
| Opération | `normalizationContext` (groupes) |
|
||||||
|
|---|---|
|
||||||
|
| `GetCollection` (liste) | `weighing_ticket:read` + `client:read` + `supplier:read` + `site:read` + `default:read` |
|
||||||
|
| `Get` / `Post` / `Patch` (détail) | + `weighing_ticket:item:read` |
|
||||||
|
|
||||||
|
**LISTE — colonne datatable → maillons** (docx p.3 : Numéro, Client, Fournisseur, Autre, Date, Poids) :
|
||||||
|
|
||||||
|
| Colonne affichée | Propriété (a) | Dans contexte liste (b) | Imbriqué (c) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Numéro | `number` ∈ `weighing_ticket:read` | ✅ | — |
|
||||||
|
| Client | `client` ∈ `weighing_ticket:read` (embed) | ✅ | `client:read` ✅ (RG-5.03) |
|
||||||
|
| Fournisseur | `supplier` ∈ `weighing_ticket:read` (embed) | ✅ | `supplier:read` ✅ |
|
||||||
|
| Autre | `otherLabel` ∈ `weighing_ticket:read` | ✅ | — |
|
||||||
|
| Date | `fullDate` ?? `emptyDate` (date du ticket) ∈ `weighing_ticket:read` | ✅ | — |
|
||||||
|
| Poids | `netWeight` ∈ `weighing_ticket:read` | ✅ | — |
|
||||||
|
|
||||||
|
> **Note « Date » liste** : on expose une propriété calculée `displayDate` (getter) = `fullDate ?? emptyDate`, dans `weighing_ticket:read` (les `empty/full*` détaillées restent en `:item:read`).
|
||||||
|
|
||||||
|
**DÉTAIL — maillons** : scalaires + `emptyDate/emptyWeight/emptyDsd/...` + `full*` ∈ `weighing_ticket:item:read` ; `client`/`supplier`/`site` embarqués (`client:read`/`supplier:read`/`site:read`).
|
||||||
|
|
||||||
|
### 4.0.bis Réponse JSON de référence (DoD — CAPTURÉE sur l'API réelle ✅)
|
||||||
|
|
||||||
|
> **Definition of Done** (miroir M2/M3/M4) : ✅ **FAIT (ERP-187)**. Le JSON ci-dessous est la réponse **RÉELLE** capturée par le test `WeighingTicketSerializationContractTest::testListAndDetailSerializationContract` (ticket créé via `POST /api/weighing_tickets` — numérotation serveur réelle — contrepartie Client, pesée vide + plein AUTO). Re-capturable : `WEIGHING_TICKET_DOD_DUMP=1` → `/tmp/weighing-ticket-dod-{list,detail}.json`. **Feu vert front.** Toute donnée affichée par le front DOIT apparaître dans ce JSON.
|
||||||
|
>
|
||||||
|
> **Pièges re-testés — tous VERTS** (assertions dans le test) :
|
||||||
|
> 1. ✅ `client` sort en **objet embarqué** (`client:read`), pas en IRI nu ; `supplier` **omis car null** (`skip_null_values` — jamais un IRI nu). Sur une contrepartie Fournisseur, `supplier` sortirait symétriquement en objet (`supplier:read`).
|
||||||
|
> 2. ✅ Booléen `plateFreeFormat` : **clé présente** (getter `isPlateFreeFormat()` + `SerializedName('plateFreeFormat')`).
|
||||||
|
> 3. ✅ `number` présent et formaté `{siteCode}-TP-{NNNN}` (ici `86-TP-0001`).
|
||||||
|
> 4. ✅ `netWeight` cohérent = `full - empty` = `14300 - 7150` = **`7150`** (RG-5.05).
|
||||||
|
>
|
||||||
|
> **Note `skip_null_values`** : les champs null sont **omis** du JSON (ex. `supplier`, `otherLabel`, `emptyManualNumber`, `fullManualNumber` absents quand null). Le front ne doit pas présumer leur présence — lire avec un défaut (`?? null`).
|
||||||
|
|
||||||
|
**`GET /api/weighing_tickets?search=86-TP-0001` (LISTE)** — enveloppe Hydra AP4 (`member`/`totalItems`/`view`), filtrée site courant (§ 2.3). Capture réelle :
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"@context": "/api/contexts/WeighingTicket",
|
||||||
|
"@id": "/api/weighing_tickets",
|
||||||
|
"@type": "Collection",
|
||||||
|
"totalItems": 1,
|
||||||
|
"member": [
|
||||||
|
{
|
||||||
|
"@id": "/api/weighing_tickets/9",
|
||||||
|
"@type": "WeighingTicket",
|
||||||
|
"id": 9,
|
||||||
|
"number": "86-TP-0001",
|
||||||
|
"counterpartyType": "CLIENT",
|
||||||
|
"client": {
|
||||||
|
"@id": "/api/clients/629",
|
||||||
|
"@type": "Client",
|
||||||
|
"id": 629,
|
||||||
|
"companyName": "NÉGOCE MÉTAUX ATLANTIQUE",
|
||||||
|
"triageService": false,
|
||||||
|
"categories": [],
|
||||||
|
"createdAt": "2026-06-18T11:50:47+02:00",
|
||||||
|
"updatedAt": "2026-06-18T11:50:47+02:00",
|
||||||
|
"createdBy": "/api/me",
|
||||||
|
"updatedBy": "/api/me",
|
||||||
|
"sites": [],
|
||||||
|
"isArchived": false
|
||||||
|
},
|
||||||
|
"plateFreeFormat": false,
|
||||||
|
"netWeight": 7150,
|
||||||
|
"createdAt": "2026-06-18T11:50:48+02:00",
|
||||||
|
"updatedAt": "2026-06-18T11:50:48+02:00",
|
||||||
|
"createdBy": "/api/me",
|
||||||
|
"updatedBy": "/api/me",
|
||||||
|
"displayDate": "2026-06-17T09:12:00+02:00"
|
||||||
|
// supplier / otherLabel omis (null → skip_null_values)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"view": { "@id": "/api/weighing_tickets?search=86-TP-0001", "@type": "PartialCollectionView" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`GET /api/weighing_tickets/9` (DÉTAIL)** — ajoute le site embarqué (avec `code`), l'immatriculation et les deux pesées. Capture réelle :
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"@context": "/api/contexts/WeighingTicket",
|
||||||
|
"@id": "/api/weighing_tickets/9",
|
||||||
|
"@type": "WeighingTicket",
|
||||||
|
"id": 9,
|
||||||
|
"number": "86-TP-0001",
|
||||||
|
"site": {
|
||||||
|
"@id": "/api/sites/1",
|
||||||
|
"@type": "Site",
|
||||||
|
"id": 1,
|
||||||
|
"name": "Chatellerault",
|
||||||
|
"code": "86",
|
||||||
|
"street": "14 All. d'Argenson",
|
||||||
|
"postalCode": "86100",
|
||||||
|
"city": "Châtellerault",
|
||||||
|
"color": "#056CF2",
|
||||||
|
"createdAt": "2026-06-17T17:07:47+02:00",
|
||||||
|
"updatedAt": "2026-06-17T17:07:47+02:00",
|
||||||
|
"fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
|
||||||
|
},
|
||||||
|
"counterpartyType": "CLIENT",
|
||||||
|
"client": {
|
||||||
|
"@id": "/api/clients/629",
|
||||||
|
"@type": "Client",
|
||||||
|
"id": 629,
|
||||||
|
"companyName": "NÉGOCE MÉTAUX ATLANTIQUE",
|
||||||
|
"triageService": false,
|
||||||
|
"categories": [],
|
||||||
|
"createdAt": "2026-06-18T11:50:47+02:00",
|
||||||
|
"updatedAt": "2026-06-18T11:50:47+02:00",
|
||||||
|
"createdBy": "/api/me",
|
||||||
|
"updatedBy": "/api/me",
|
||||||
|
"sites": [],
|
||||||
|
"isArchived": false
|
||||||
|
},
|
||||||
|
"immatriculation": "AB-123-CD",
|
||||||
|
"plateFreeFormat": false,
|
||||||
|
"emptyDate": "2026-06-17T09:00:00+02:00",
|
||||||
|
"emptyWeight": 7150,
|
||||||
|
"emptyDsd": 1,
|
||||||
|
"emptyMode": "AUTO",
|
||||||
|
"fullDate": "2026-06-17T09:12:00+02:00",
|
||||||
|
"fullWeight": 14300,
|
||||||
|
"fullDsd": 2,
|
||||||
|
"fullMode": "AUTO",
|
||||||
|
"netWeight": 7150,
|
||||||
|
"createdAt": "2026-06-18T11:50:48+02:00",
|
||||||
|
"updatedAt": "2026-06-18T11:50:48+02:00",
|
||||||
|
"createdBy": "/api/me",
|
||||||
|
"updatedBy": "/api/me",
|
||||||
|
"displayDate": "2026-06-17T09:12:00+02:00"
|
||||||
|
// emptyManualNumber / fullManualNumber omis (null → skip_null_values)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 Query params (LISTE)
|
||||||
|
|
||||||
|
| Param | Effet |
|
||||||
|
|---|---|
|
||||||
|
| `?page` / `?itemsPerPage` | pagination standard (10 / 25 / 50, défaut 10) |
|
||||||
|
| `?search=` | recherche sur `number`, nom client/fournisseur, `other_label`, `immatriculation` |
|
||||||
|
| `?order[displayDate]=desc` | tri par date (défaut : `number DESC` = plus récents en tête) |
|
||||||
|
| *(site courant)* | filtré automatiquement par `SiteScopedQueryExtension` (§ 2.3) |
|
||||||
|
|
||||||
|
Pagination obligatoire (règle ABSOLUE n°13) — provider ORM via `ApiPlatform\Doctrine\Orm\Paginator`, jamais d'array brut.
|
||||||
|
|
||||||
|
### 4.2 Endpoint pesée (pont bascule) — `POST /api/weighbridge_readings`
|
||||||
|
|
||||||
|
Action **autonome** (le ticket n'est pas encore créé quand on déclenche la pesée du formulaire principal).
|
||||||
|
|
||||||
|
- **Sécurité** : `is_granted('logistique.weighing_tickets.manage')`.
|
||||||
|
- **AUTO (pesée bascule)** — body `{ "mode": "AUTO" }` → le site courant est résolu serveur (`CurrentSiteProviderInterface`).
|
||||||
|
- Réponse `200` : `{ "weight": 23187, "dsd": 42, "mode": "AUTO" }` (stub : `weight = random_int(10000,50000)`, `dsd = nextDsd(site)`).
|
||||||
|
- Réponse `503` (RG-5.06) si `WeighbridgeUnavailableException` : `{ "title": "Pont bascule indisponible", "detail": "Passez en pesée manuelle." }`.
|
||||||
|
- **MANUAL (pesée manuelle)** — body `{ "mode": "MANUAL", "weight": 23187, "manualNumber": "PAP-555" }`.
|
||||||
|
- Réponse `200` : `{ "weight": 23187, "dsd": 43, "manualNumber": "PAP-555", "mode": "MANUAL" }` (`dsd = dernier dsd du site + 1`, RG-5.04).
|
||||||
|
|
||||||
|
> **Implémentation** : `#[ApiResource]` non-Doctrine (DTO `WeighbridgeReadingInput`/`Output`) + Processor dédié, OU une ressource `WeighbridgeReading` virtuelle. **Pas de controller** Symfony (règle backend). Le Processor appelle `WeighbridgeReaderInterface` + le `DsdAllocator` (verrou `FOR UPDATE`).
|
||||||
|
>
|
||||||
|
> **Concurrence DSD** : le `dsd` renvoyé ici est **prévisionnel**. L'attribution **autoritaire** du `dsd` (et du `number`) est refaite/verrouillée à la **création du ticket** (`POST /api/weighing_tickets`) pour éviter les collisions si deux postes pèsent en parallèle. Front : afficher le dsd renvoyé, mais c'est le ticket persisté qui fait foi.
|
||||||
|
|
||||||
|
### 4.3 `POST /api/weighing_tickets` (création)
|
||||||
|
|
||||||
|
- Le client envoie : `counterpartyType` (+ `client`/`supplier`/`otherLabel`), `immatriculation`, `plateFreeFormat`, et les pesées (`emptyDate/Weight/Dsd/Mode/ManualNumber`, `full*`).
|
||||||
|
- Le **Processor** :
|
||||||
|
1. Résout le **site courant** (`CurrentSiteProviderInterface`) → `site_id`.
|
||||||
|
2. Attribue le **numéro** `{siteCode}-TP-{NNNN}` (compteur verrouillé — RG-5.02).
|
||||||
|
3. (Re)attribue les `dsd` autoritaires si nécessaire (verrou — RG-5.04).
|
||||||
|
4. Normalise `immatriculation` (RG-5.01) ; valide la cohérence contrepartie (RG-5.03) et pesées.
|
||||||
|
5. Calcule `net_weight = full_weight - empty_weight` si les deux poids sont présents (RG-5.05).
|
||||||
|
- Réponse `201` avec le ticket complet → le front ouvre la **modal d'impression** (RG-5.08).
|
||||||
|
|
||||||
|
### 4.4 `PATCH /api/weighing_tickets/{id}` (modification)
|
||||||
|
|
||||||
|
- Mise à jour partielle (mêmes règles). Le **numéro et le site sont immuables** (ignorés s'ils sont envoyés). `net_weight` recalculé. Le bouton d'impression est disponible (RG-5.08).
|
||||||
|
|
||||||
|
### 4.5 Export — `GET /api/weighing_tickets/export.xlsx`
|
||||||
|
|
||||||
|
- Exporte **toute la liste** des tickets (docx : bouton « Exporter » → « Exporte toute la liste des tickets de pesée »), filtrée par le site courant + filtres actifs.
|
||||||
|
- Colonnes : Numéro, Contrepartie (Client/Fournisseur/Autre + nom), Date, Immatriculation, Poids vide, Poids plein, **Poids net**, DSD vide/plein.
|
||||||
|
- Génération via le helper XLSX standard projet (skill `xlsx`). Endpoint : provider dédié renvoyant un binaire (`Content-Type` xlsx) — whitelisté pagination (`EXCLUDED`) car export complet.
|
||||||
|
|
||||||
|
### 4.6 Impression — `GET /api/weighing_tickets/{id}/print.pdf` (bon de pesée, OWNER Tristan)
|
||||||
|
|
||||||
|
- Opération API Platform dédiée (provider renvoyant un binaire PDF, **pas de controller**). Sécurité `is_granted('logistique.weighing_tickets.view')`.
|
||||||
|
- Rendu d'un **template Twig** (`templates/logistique/weighing_ticket_print.html.twig`) → PDF (cf. § 2.12). `Content-Type: application/pdf`, inline.
|
||||||
|
- Contenu : cf. § 2.12. Données déjà portées par le ticket — aucun champ API supplémentaire.
|
||||||
|
|
||||||
|
## 5. RBAC, module & sidebar
|
||||||
|
|
||||||
|
### 5.1 `LogistiqueModule::permissions()`
|
||||||
|
|
||||||
|
```php
|
||||||
|
public static function permissions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['code' => 'logistique.weighing_tickets.view', 'label' => 'Voir les tickets de pesée'],
|
||||||
|
['code' => 'logistique.weighing_tickets.manage', 'label' => 'Créer / modifier les tickets de pesée'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Synchronisation : `app:sync-permissions`.
|
||||||
|
|
||||||
|
### 5.2 Matrice rôle → permissions (docx p.3)
|
||||||
|
|
||||||
|
| Rôle | `…view` | `…manage` |
|
||||||
|
|---|:--:|:--:|
|
||||||
|
| **Admin** | ✅ | ✅ |
|
||||||
|
| **Bureau** | ✅ | ✅ |
|
||||||
|
| **Usine** | ✅ | ✅ |
|
||||||
|
| **Compta** | ❌ | ❌ |
|
||||||
|
| **Commerciale** | ❌ | ❌ |
|
||||||
|
|
||||||
|
> ⚠ **Changement vs M5 V0.1** : en V0.2 **Usine = Tout / Tout** (consultation + ajout/modif), alors que la V0.1 disait « Oui ». Compta et Commerciale = **aucun** accès (item sidebar masqué).
|
||||||
|
|
||||||
|
### 5.3 Sidebar (`config/sidebar.php`)
|
||||||
|
|
||||||
|
Nouvelle section **« Logistique »** (ou item rattaché à une section logistique mutualisée avec Transport — à confirmer). Item :
|
||||||
|
|
||||||
|
```php
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.logistique.weighing_tickets',
|
||||||
|
'to' => '/weighing-tickets',
|
||||||
|
'icon' => 'mdi-scale',
|
||||||
|
'module' => 'logistique',
|
||||||
|
'permission' => 'logistique.weighing_tickets.view',
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Règle ABSOLUE n°8 — 3 miroirs RBAC
|
||||||
|
|
||||||
|
Toute permission `logistique.*` doit être posée **simultanément** dans :
|
||||||
|
1. `config/sidebar.php` (item + permission ci-dessus),
|
||||||
|
2. `frontend/tests/e2e/_fixtures/personas.ts` (ajuster un persona existant : Usine gagne `weighing_tickets.view/manage` + `expectedAdminLinks`),
|
||||||
|
3. `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` (miroir back du même persona).
|
||||||
|
|
||||||
|
## 6. Normalisation serveur (RG-5.01 / RG-5.10)
|
||||||
|
|
||||||
|
`WeighingTicketFieldNormalizer` (miroir `CarrierFieldNormalizer`), appelé par le Processor avant validation :
|
||||||
|
|
||||||
|
```php
|
||||||
|
final class WeighingTicketFieldNormalizer
|
||||||
|
{
|
||||||
|
// RG-5.01 : trim + UPPER ; si !plateFreeFormat → reformate XX-000-XX (rejet 422 si invalide).
|
||||||
|
public function normalizeImmatriculation(?string $v, bool $freeFormat): ?string
|
||||||
|
public function normalizeOtherLabel(?string $v): ?string // trim
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Règles de gestion (RG)
|
||||||
|
|
||||||
|
| RG | Source | Énoncé |
|
||||||
|
|---|---|---|
|
||||||
|
| **RG-5.01** | docx | Immatriculation : masque par défaut `XX-000-XX` ; « Tout format » coché → masque désactivé (saisie libre). Les champs `immatriculation` et `plateFreeFormat` sont **connectés entre les 2 formulaires** (une seule valeur portée par le ticket — § 2.10). |
|
||||||
|
| **RG-5.02** | back | Numéro `{siteCode}-TP-{NNNN}`, **unique par site**, attribué serveur à la création, immuable. Séquence verrouillée par site (§ 2.5). |
|
||||||
|
| **RG-5.03** | docx+back | Contrepartie `CLIENT`/`FOURNISSEUR`/`AUTRE` → champ associé obligatoire, les autres forcés nuls (§ 2.9). |
|
||||||
|
| **RG-5.04** | docx+back | DSD = compteur de pesée du pont, par site. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1. « Numéro de pesée » manuel = champ distinct (§ 2.7). |
|
||||||
|
| **RG-5.05** | back | Poids net = `poids plein − poids vide`, calculé serveur, exposé en liste/détail (§ 2.8 — confirmé Matthieu 17/06). |
|
||||||
|
| **RG-5.06** | docx+back | Pesée bascule indisponible → erreur explicite + bascule en pesée manuelle. Au M5, le pont est un **stub** (poids aléatoire ∈ [10000,50000] kg, § 2.6). |
|
||||||
|
| **RG-5.07** | docx | Formulaire à vide : `Date` = date du jour par défaut ; `Poids` et `DSD` **readonly** (remplis par la pesée, pas saisis). |
|
||||||
|
| **RG-5.08** | docx | « Valider » (création) → enregistre + ouvre le **bon de pesée (PDF servi par le back)**. En modification : bouton « Valider » → « Enregistrer », bouton « Imprimer » disponible (absent à l'ajout) → ouvre le même PDF. Le bouton « Enregistré » du bloc pesée à vide disparaît en modification. **Bon de pesée = PDF généré back via template Twig, OWNER Tristan** (§ 2.12 / § 4.6). |
|
||||||
|
| **RG-5.09** | back | Site & numéro immuables après création ; liste cloisonnée par site courant (§ 2.3, à confirmer). |
|
||||||
|
| **RG-5.10** | back | Normalisation immatriculation (trim/UPPER/format) côté serveur (§ 6). |
|
||||||
|
|
||||||
|
Cohérence inter-champs (RG-5.03, RG-5.01) implémentée via `#[Assert\Callback]` portant des messages FR + CHECK Postgres en garde-fou (§ 3.2).
|
||||||
|
|
||||||
|
## 8. Tests (PHPUnit) — `make test`
|
||||||
|
|
||||||
|
- **`WeighingTicketSerializationContractTest`** : capture JSON liste + détail (DoD § 4.0.bis), 4 pièges verts.
|
||||||
|
- **`WeighingTicketNumberingTest`** : `{siteCode}-TP-{NNNN}`, séquence par site, unicité, concurrence (FOR UPDATE).
|
||||||
|
- **`DsdAllocatorTest`** : AUTO incrémente ; MANUAL = dernier + 1 ; par site.
|
||||||
|
- **`WeighbridgeReaderStubTest`** : poids ∈ [10000,50000] ; chemin d'erreur `WeighbridgeUnavailableException` → 503 (RG-5.06).
|
||||||
|
- **`NetWeightTest`** : `plein − vide` ; null si une pesée manque (RG-5.05).
|
||||||
|
- **`CounterpartyValidationTest`** : RG-5.03 (chaque branche + rejets).
|
||||||
|
- **`ImmatriculationNormalizationTest`** : masque XX-000-XX, free format, 422 (RG-5.01).
|
||||||
|
- **RBAC** : Usine/Bureau/Admin OK ; Compta/Commerciale 403.
|
||||||
|
- **Architecture** (déjà en place, ne pas casser) : `ColumnsHaveSqlCommentTest`, `EntitiesAreTimestampableBlamableTest`, `AuditableEntitiesHaveI18nLabelTest`, `CollectionsArePaginatedTest`, `EntityConstraintsHaveFrenchMessageTest`.
|
||||||
|
|
||||||
|
## 9. Hors périmètre (HP)
|
||||||
|
|
||||||
|
| Réf | Sujet |
|
||||||
|
|---|---|
|
||||||
|
| HP-M5-01 | Vue multi-sites des tickets (retirer le cloisonnement + filtre `?siteId=`) si demandé (§ 2.3). |
|
||||||
|
| HP-M5-02 | Driver matériel réel du pont bascule (protocole série/TCP, parsing trame, reconnexion) derrière `WeighbridgeReaderInterface` (§ 2.6). |
|
||||||
|
| HP-M5-03 | Sens réception-expédition explicite + contrôle de signe du net (le net reste `plein − vide`, § 2.8). |
|
||||||
|
| ~~HP-M5-04~~ | **Passé en périmètre** : bon de pesée = PDF serveur via template Twig → ticket back dédié (OWNER Tristan, § 2.12 / § 4.6). |
|
||||||
|
| HP-M5-05 | Archivage fonctionnel des tickets (non prévu au docx — § 2.13). |
|
||||||
|
|
||||||
|
## 10. Tickets Lesstime (à découper — back en tête)
|
||||||
|
|
||||||
|
| Ordre | Sujet | Tag |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | Scaffolding module `Logistique` (create-module) + `config/modules.php` + sidebar + 3 miroirs RBAC | Backend |
|
||||||
|
| 1 | Migration : `site.code` + compteurs + `weighing_ticket` (+ index + COMMENT) | Backend |
|
||||||
|
| 2 | Entité `WeighingTicket` + Repository + contrat sérialisation | Backend |
|
||||||
|
| 3 | `WeighbridgeReaderInterface` + `RandomWeighbridgeReader` + `DsdAllocator` + endpoint `weighbridge_readings` | Backend |
|
||||||
|
| 4 | `WeighingTicketProvider` + `WeighingTicketProcessor` (numérotation, RG-5.03/5.05, normalisation) | Backend |
|
||||||
|
| 5 | Export XLSX | Backend |
|
||||||
|
| 6 | Tests PHPUnit RG-5.01→5.10 + capture contrat JSON | Backend |
|
||||||
|
| 6.bis (ERP-192) | **Bon de pesée — PDF via template Twig** (`/print.pdf`, § 2.12 / § 4.6) | **Backend (OWNER Tristan)** |
|
||||||
|
| 7 | Page liste `/weighing-tickets` (usePaginatedList) + export | Frontend |
|
||||||
|
| 8 | Écran Ajouter (formulaires vide + plein, pesée bascule/manuelle, masque immat) + ouverture PDF à la validation | Frontend |
|
||||||
|
| 9 | Écran Modification + bouton « Imprimer » (ouvre le PDF back) | Frontend |
|
||||||
|
| 10 | i18n + libellé audit + branchement site courant | Frontend |
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
---
|
||||||
|
# === IDENTITÉ ===
|
||||||
|
module: M5
|
||||||
|
nom: "Tickets de pesée"
|
||||||
|
ecran: tickets-pesee
|
||||||
|
owner_spec: Matthieu
|
||||||
|
backup_spec: Tristan
|
||||||
|
version: V0.1
|
||||||
|
date_redaction: 2026-06-17
|
||||||
|
# Historique :
|
||||||
|
# V0.1 (2026-06-17) — Restitution Markdown du docx « M5-ticket-de-pesee-V02 » (V0.2, 15/06/2026,
|
||||||
|
# validation client en attente) + maquette Figma (node 1322-16774). Précisions techniques (back)
|
||||||
|
# dans spec-back.md. Réutilise le pattern et les composants M1/M2/M3/M4.
|
||||||
|
# Maquette : les 2 blocs (vide + plein) portent « Pesée bascule » + « Pesée manuelle » ;
|
||||||
|
# contrepartie portée par le bloc « Poids à vide » ; net = plein − vide (confirmé Matthieu).
|
||||||
|
|
||||||
|
# === LIENS ===
|
||||||
|
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1322-16774&p=f&m=dev"
|
||||||
|
regles_metier: [RG-5.01, RG-5.02, RG-5.03, RG-5.04, RG-5.05, RG-5.06, RG-5.07, RG-5.08, RG-5.09, RG-5.10]
|
||||||
|
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||||
|
lien_spec_back: ./spec-back.md
|
||||||
|
|
||||||
|
# === VALIDATION CLIENT ===
|
||||||
|
client_validation_1:
|
||||||
|
statut: validee
|
||||||
|
version: V0.2
|
||||||
|
date_doc: 2026-06-15
|
||||||
|
date_validation: 2026-06-17
|
||||||
|
valide_par: "Matthieu (CP MALIO)"
|
||||||
|
|
||||||
|
# === LIEN LESSTIME ===
|
||||||
|
lesstime_project_id: 6
|
||||||
|
lesstime_taskgroup_id: 33 # M5 — Tickets de pesée (ERP-181 → ERP-192)
|
||||||
|
statut_global: pret_a_dev
|
||||||
|
---
|
||||||
|
|
||||||
|
# Module 5 — Tickets de pesée (V0.1 front)
|
||||||
|
|
||||||
|
> **Origine** : spec fonctionnelle `M5-ticket-de-pesee-V02` (V0.2, 15/06/2026, **validation client en attente**) + maquette Figma (node 1322-16774). Restitution Markdown pour intégration au workflow MALIO. Toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M5 réutilise le pattern et les composants posés aux [M1 clients](../M1-clients/spec-front.md) → [M4 transporteurs](../M4-transporteurs/spec-front.md).
|
||||||
|
|
||||||
|
> **Nouveau module `Logistique`** (DÉCISION Matthieu 17/06). La maquette montre une section sidebar **Logistique** plus large (Réception, Expédition, Validations, Triage, **Ticket de pesée**, Bons…) ; **le M5 ne livre que l'écran « Ticket de pesée »**. Les autres items sont hors périmètre (modules/écrans ultérieurs).
|
||||||
|
|
||||||
|
> **Décisions (17/06)** : (1) **pont bascule = stub** renvoyant un poids aléatoire ∈ [10000, 50000] kg (pas de liaison matérielle — [`spec-back.md § 2.6`](./spec-back.md)) ; (2) **DSD = compteur de pesée** par site, +1 par pesée ([`§ 2.7`](./spec-back.md)) ; (3) **net = plein − vide** ([`§ 2.8`](./spec-back.md)) ; (4) numéro **`{siteCode}-TP-{NNNN}` par site** ([`§ 2.5`](./spec-back.md)).
|
||||||
|
|
||||||
|
## But
|
||||||
|
|
||||||
|
Lister les tickets de pesée et accéder à leur fiche : consultation, création (pesée à vide + pesée à plein au pont bascule), modification, impression. Chaque ticket porte un **numéro unique par site** (ex. `86-TP-0001`) et une **contrepartie** Client / Fournisseur / Autre.
|
||||||
|
|
||||||
|
## Accès
|
||||||
|
|
||||||
|
- **Depuis** : menu principal → section **Logistique** → item **« Ticket de pesée »** (route `/weighing-tickets`).
|
||||||
|
- **Site** : l'écran dépend du **site courant** (sélecteur de site en haut de l'app — onglets `CHÂTELLERAULT` / `SAINT-JEAN` / `POMMEVIC`). Le site pilote la numérotation et (par défaut) le cloisonnement de la liste ([`spec-back.md § 2.3 / § 2.5`](./spec-back.md)).
|
||||||
|
- **Rôles autorisés** (tableau « Rôles & permissions » du docx p.3, V0.2) :
|
||||||
|
|
||||||
|
| Rôle | Consultation | Ajout / Modification |
|
||||||
|
|---|---|---|
|
||||||
|
| **Admin** | ✅ Tout | ✅ Tout |
|
||||||
|
| **Bureau** | ✅ Tout | ✅ Tout |
|
||||||
|
| **Usine** | ✅ Tout | ✅ Tout |
|
||||||
|
| **Compta** | ❌ | ❌ |
|
||||||
|
| **Commerciale** | ❌ | ❌ |
|
||||||
|
|
||||||
|
> **Notes** :
|
||||||
|
> - RBAC transposée sur `logistique.weighing_tickets.view` / `.manage` ([`spec-back.md § 5`](./spec-back.md)).
|
||||||
|
> - ⚠ **Changement vs M5 V0.1** : en **V0.2, Usine = Tout / Tout**. **Compta** et **Commerciale** n'ont **aucun** accès (item sidebar masqué).
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
Page d'entrée de l'écran : **datatable** « Tickets de pesées ».
|
||||||
|
|
||||||
|
- **Clic sur une ligne** → écran **Modification d'un ticket de pesée** (le docx ne prévoit pas d'écran de consultation séparé — clic = édition).
|
||||||
|
- **Bouton « + Ajouter »** (haut droite, si `manage`) → écran **Ajouter un ticket de pesée**.
|
||||||
|
- **Bouton « Exporter »** (bas de liste, maquette) → télécharge un **XLSX** de **toute la liste** (filtres + site courant appliqués). Format dans [`spec-back.md § 4.5`](./spec-back.md).
|
||||||
|
|
||||||
|
## Datatable des tickets
|
||||||
|
|
||||||
|
Composant : `<MalioDataTable>` branché sur `usePaginatedList<WeighingTicket>({ url: '/weighing_tickets' })` *(URL API en `snake_case` ; la route Nuxt reste `/weighing-tickets`)* (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes (docx p.3 + maquette) :
|
||||||
|
|
||||||
|
| Colonne | Source | Tri |
|
||||||
|
|---|---|---|
|
||||||
|
| **Numéro** | `ticket.number` (`{siteCode}-TP-{NNNN}`) | DESC par défaut (plus récents en tête) |
|
||||||
|
| **Client** | `ticket.client.companyName` (vide si contrepartie ≠ Client) | Non |
|
||||||
|
| **Fournisseur** | `ticket.supplier.companyName` (vide si ≠ Fournisseur) | Non |
|
||||||
|
| **Autre** | `ticket.otherLabel` (vide si ≠ Autre) | Non |
|
||||||
|
| **Date** | `ticket.displayDate` (`fullDate ?? emptyDate`, format `JJ-MM-AAAA`) | Oui |
|
||||||
|
| **Poids** | `ticket.netWeight` (kg, = plein − vide — RG-5.05) | Oui |
|
||||||
|
|
||||||
|
> **Clic ligne** → écran Modification. **Pagination** : standard Starseed 10 / 25 / 50 (défaut 10). Liste **cloisonnée par site courant** par défaut ([`spec-back.md § 2.3`](./spec-back.md)).
|
||||||
|
|
||||||
|
## Écran « Ajouter un ticket de pesée »
|
||||||
|
|
||||||
|
**Accès** : bouton « + Ajouter ». **Rôles** : Admin, Bureau, Usine.
|
||||||
|
**Titre** : « ← Ticket de pesée » (flèche retour vers la liste).
|
||||||
|
|
||||||
|
L'écran (maquette) est composé de **deux blocs empilés** — **« Poids à vide »** puis **« Poids à plein »** — et d'un bouton **« Valider »** en bas.
|
||||||
|
|
||||||
|
### Bloc « Poids à vide »
|
||||||
|
|
||||||
|
Boutons en haut à droite du bloc : **« Pesée bascule »** (`<MalioButton>` secondaire) + **« Pesée manuelle »** (`<MalioButton>` primaire).
|
||||||
|
|
||||||
|
**Champs** :
|
||||||
|
|
||||||
|
| Champ | Type composant | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Fournisseur / Client / Autre** | `<MalioSelect>` (3 valeurs) | Oui | RG-5.03 — pilote le champ suivant |
|
||||||
|
| **Nom du fournisseur** | `<MalioSelect>` (liste fournisseurs M2) | Conditionnel | RG-5.03 — visible + obligatoire si « Fournisseur » |
|
||||||
|
| **Nom du client** | `<MalioSelect>` (liste clients M1) | Conditionnel | RG-5.03 — visible + obligatoire si « Client » |
|
||||||
|
| **Autre** | `<MalioInputText>` | Conditionnel | RG-5.03 — visible + obligatoire si « Autre » |
|
||||||
|
| **Date** | `<MalioInputText>` type `date` *(cf. note)* | Oui | RG-5.07 — **date du jour par défaut** |
|
||||||
|
| **Poids** | `<MalioInputNumber>` (suffixe « Kg ») | Oui | RG-5.07 — **readonly**, rempli par la pesée |
|
||||||
|
| **DSD** | `<MalioInputNumber>` | Oui | RG-5.04 / RG-5.07 — **readonly**, rempli par la pesée |
|
||||||
|
| **Immatriculation** | `<MalioInputText>` (masque `XX-000-XX`) | Oui | RG-5.01 |
|
||||||
|
| **Tout format** | `<MalioCheckbox>` | Non | RG-5.01 — désactive le masque |
|
||||||
|
|
||||||
|
> **La contrepartie (Fournisseur/Client/Autre) + son champ associé est portée par le bloc « Poids à vide » uniquement** (maquette) — c'est une donnée du ticket, pas répétée sur le bloc plein. Côté back : champs `counterpartyType` / `client` / `supplier` / `otherLabel` du ticket ([`spec-back.md § 2.9`](./spec-back.md)).
|
||||||
|
|
||||||
|
**Action « Enregistrer »** (sous le bloc, maquette) : POST `/api/weighing_tickets` (création initiale du ticket avec la pesée à vide) — [`spec-back.md § 4.3`](./spec-back.md). Le numéro `{siteCode}-TP-{NNNN}` est attribué serveur.
|
||||||
|
|
||||||
|
### Bloc « Poids à plein »
|
||||||
|
|
||||||
|
Mêmes boutons **« Pesée bascule »** + **« Pesée manuelle »**. **Champs** : Date (date du jour par défaut), Poids (readonly, Kg), DSD (readonly), Immatriculation (`XX-000-XX`), « Tout format ».
|
||||||
|
|
||||||
|
> **Immatriculation + « Tout format » connectés entre les 2 blocs** (RG-5.01) : une seule valeur partagée — modifier l'un met à jour l'autre (même véhicule). Géré dans `useWeighingTicketForm()` (état partagé).
|
||||||
|
|
||||||
|
### Boutons de pesée — comportement
|
||||||
|
|
||||||
|
| Bouton | Déclencheur | Comportement |
|
||||||
|
|---|---|---|
|
||||||
|
| **Pesée bascule** | clic | Ouvre une **modal de confirmation** « Êtes-vous sûr de vouloir déclencher une pesée ? » (`<MalioButton>` « Valider »). Si confirmé → `POST /api/weighbridge_readings { mode: 'AUTO' }` ([`spec-back.md § 4.2`](./spec-back.md)) → remplit **Poids** et **DSD** du bloc, ferme la modal. **En cas d'erreur** (RG-5.06) : le message d'erreur s'affiche **dans la modal** et invite à passer en **pesée manuelle**. *(Au M5, le stub renvoie toujours un poids ∈ [10000,50000] — le chemin d'erreur est néanmoins géré.)* |
|
||||||
|
| **Pesée manuelle** | clic | Ouvre une **modal « Pesée manuelle »** avec **Poids** et **Numéro de pesée** à saisir (`<MalioInputNumber>` + `<MalioInputText>`), bouton « Enregistrer ». Une fois validé → le **Poids** du bloc est rempli ; le **DSD** est **calculé automatiquement** = dernier dsd du site + 1 (`POST /api/weighbridge_readings { mode: 'MANUAL', weight, manualNumber }` — RG-5.04). |
|
||||||
|
|
||||||
|
### Action « Valider » (bas d'écran)
|
||||||
|
|
||||||
|
`<MalioButton>` « Valider » → finalise le ticket (PATCH `/api/weighing_tickets/{id}` avec la pesée à plein + recalcul du net — [`spec-back.md § 4.4`](./spec-back.md)) puis **ouvre la modal d'impression** du ticket (RG-5.08 — **bon d'impression réalisé par Tristan**, cf. § Modales).
|
||||||
|
|
||||||
|
## Écran « Modification d'un ticket de pesée »
|
||||||
|
|
||||||
|
**But** : modifier un ticket existant et/ou **imprimer** le ticket.
|
||||||
|
**Accès** : clic sur une ligne de la liste. **Rôles** : Admin, Bureau, Usine.
|
||||||
|
|
||||||
|
**Identique à l'écran d'ajout** — mêmes 2 blocs, mêmes règles (RG-5.01 → RG-5.10) — **sauf** (docx + maquette) :
|
||||||
|
- Les champs sont **pré-remplis** avec les valeurs actuelles.
|
||||||
|
- Le **bouton « Enregistrer » du bloc « Poids à vide » disparaît** (RG-5.08) — on enregistre via le bas d'écran.
|
||||||
|
- En bas : **« Enregistrer »** (remplace « Valider ») + **« Imprimer »** (bouton d'impression **absent à l'ajout**, RG-5.08).
|
||||||
|
- Le numéro et le site sont **immuables** (lecture seule).
|
||||||
|
|
||||||
|
## Modales
|
||||||
|
|
||||||
|
| Modale | Contenu | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| **Confirmation pesée bascule** | « Êtes-vous sûr de vouloir déclencher une pesée ? » + bouton « Valider ». Erreur affichée inline → invite pesée manuelle (RG-5.06). | docx p.5 + maquette |
|
||||||
|
| **Pesée manuelle** | Champs « Poids » + « Numéro de pesée » + bouton « Enregistrer ». DSD auto = dernier +1 (RG-5.04). | docx p.5 + maquette |
|
||||||
|
| **Impression du ticket / bon de pesée** | Aperçu imprimable du ticket (numéro, contrepartie, immat, pesée vide/plein, net, DSD, date). **Réalisé par Tristan** (voir encadré ci-dessous). | docx p.5 / RG-5.08 ; [`spec-back.md § 2.12`](./spec-back.md) |
|
||||||
|
|
||||||
|
> **⚠ Bon d'impression = Tristan.** La conception et la réalisation du **bon d'impression** (gabarit du ticket de pesée, mise en page, déclenchement) sont **prises en charge par Tristan lui-même**, hors de la découpe front standard du M5. Le reste de l'écran (modale de confirmation, modale pesée manuelle, formulaires) reste dans la découpe M5.
|
||||||
|
> - **Déclencheur attendu** : modale d'impression à la **validation** (création) ; bouton **« Imprimer »** en **modification** (absent à l'ajout — RG-5.08).
|
||||||
|
> - **Données disponibles** : toute la réponse `GET /api/weighing_tickets/{id}` (numéro, site, contrepartie, immat, pesées vide/plein, net, DSD, dates) — [`spec-back.md § 2.12 / § 4.0`](./spec-back.md).
|
||||||
|
> - **Modales** : réutiliser le wrapper de modal partagé `frontend/shared/` (comme M1→M4).
|
||||||
|
|
||||||
|
## Composants UI à utiliser (`@malio/layer-ui`)
|
||||||
|
|
||||||
|
- **Datatable** : `<MalioDataTable>` (+ `usePaginatedList`)
|
||||||
|
- **Select** : `<MalioSelect>` (contrepartie, nom client, nom fournisseur)
|
||||||
|
- **Input texte** : `<MalioInputText>` (Autre, Immatriculation, Numéro de pesée)
|
||||||
|
- **Input nombre** : `<MalioInputNumber>` (Poids, DSD)
|
||||||
|
- **Checkbox** : `<MalioCheckbox>` (« Tout format »)
|
||||||
|
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>` (Pesée bascule, Pesée manuelle, Valider, Enregistrer, Imprimer, + Ajouter, Exporter)
|
||||||
|
- **Validation par champ** : `useFormErrors` (mapping 422 inline — règle frontend obligatoire)
|
||||||
|
- **Toasts** : standards via `useApi()`
|
||||||
|
|
||||||
|
**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) :
|
||||||
|
- **Date** : `<MalioInput>` ne couvrant pas `date` nativement, utiliser un `<input type="date">` encapsulé OU `MalioDate` si dispo (cf. exceptions @.claude/rules/frontend.md — type `date` explicitement listé comme exception tolérée).
|
||||||
|
- **Masque immatriculation `XX-000-XX`** : si non couvert par `<MalioInputText>`, masque local (directive) + `// TODO`. La validation de format reste **autoritaire côté serveur** (RG-5.01 / RG-5.10).
|
||||||
|
- **Modales** : wrapper partagé `frontend/shared/`.
|
||||||
|
|
||||||
|
## Composables & appels API
|
||||||
|
|
||||||
|
- `usePaginatedList<WeighingTicket>({ url: '/weighing_tickets' })` — liste paginée (obligatoire). Consomme `number`, `client`/`supplier`/`otherLabel`, `displayDate`, `netWeight` ([`spec-back.md § 4.0`](./spec-back.md)).
|
||||||
|
- `useWeighingTicket(id)` — charge le détail via `GET /api/weighing_tickets/{id}` (pesées vide + plein embarquées, client/supplier/site imbriqués). **DoD avant intégration** : vérifier le JSON réel ([`spec-back.md § 4.0.bis`](./spec-back.md)).
|
||||||
|
- `useWeighingTicketForm()` — workflow 2 blocs (POST à l'« Enregistrer » du bloc vide, PATCH au « Valider ») + **état partagé** immatriculation/« Tout format » entre les 2 blocs (RG-5.01) + gestion des champs conditionnels de contrepartie (RG-5.03).
|
||||||
|
- `useWeighbridge()` — déclenche la pesée : `POST /api/weighbridge_readings` (AUTO ou MANUAL), gère la modal de confirmation et le chemin d'erreur → pesée manuelle (RG-5.06).
|
||||||
|
- `useClientOptions()` / `useSupplierOptions()` — alimentent les selects (référentiels M1/M2 via `?pagination=false` — échappatoire selects).
|
||||||
|
- `useCurrentSite()` — site courant (sélecteur) — déjà exposé côté front (Sites). Le back lit le site courant pour la numérotation ; le front n'a pas à l'envoyer.
|
||||||
|
- `usePermissions()` — masque l'item sidebar et les boutons selon `logistique.weighing_tickets.view/manage`.
|
||||||
|
- Tous les appels passent par `useApi()` (jamais `$fetch` direct — règle ABSOLUE n°4).
|
||||||
|
|
||||||
|
## Règles de formatage et normalisation
|
||||||
|
|
||||||
|
Le serveur normalise systématiquement ([`spec-back.md § 6`](./spec-back.md)) :
|
||||||
|
|
||||||
|
| Champ | Normalisation serveur | Affichage front |
|
||||||
|
|---|---|---|
|
||||||
|
| Immatriculation | trim + UPPER ; format `XX-000-XX` sauf « Tout format » (RG-5.01) | UPPER, masqué |
|
||||||
|
| Autre (`otherLabel`) | trim | identique |
|
||||||
|
| Poids / DSD | entiers (kg) | « 7 150 Kg », DSD brut |
|
||||||
|
| Numéro de ticket | `{siteCode}-TP-{NNNN}` (serveur) | affiché tel quel |
|
||||||
|
|
||||||
|
> Le front **ne normalise pas** : il envoie la valeur saisie, le serveur normalise et renvoie la valeur que l'UI affiche.
|
||||||
|
|
||||||
|
## Différences notables avec les modules précédents
|
||||||
|
|
||||||
|
| Zone | M1→M4 | M5 tickets de pesée |
|
||||||
|
|---|---|---|
|
||||||
|
| Module | Commercial / Transport… | **Logistique** (nouveau, ERP à venir) |
|
||||||
|
| Saisie poids | — | **Pesée au pont bascule** (stub random) + pesée manuelle |
|
||||||
|
| Cloisonnement par site | M3 oui / M4 non | **Oui** (site courant) + numéro par site |
|
||||||
|
| Numérotation métier | id technique | **`{siteCode}-TP-{NNNN}`** par site (RG-5.02) |
|
||||||
|
| Onglets | présents | **Aucun onglet** : 2 blocs empilés (vide + plein) |
|
||||||
|
| Impression | aucune | **Modal d'impression** du ticket (RG-5.08) |
|
||||||
|
| Contrepartie | — | **Client / Fournisseur / Autre** (conditionnel, RG-5.03) |
|
||||||
|
|
||||||
|
## Points résolus côté back
|
||||||
|
|
||||||
|
| # | Zone d'ombre | Résolution (cf. `spec-back.md`) |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Module | **Nouveau module `Logistique`** (§ 2.1) |
|
||||||
|
| 2 | Pont bascule | **Stub** poids aléatoire ∈ [10000,50000], interface réutilisable, driver réel HP (§ 2.6) |
|
||||||
|
| 3 | DSD | **Compteur de pesée par site**, +1 par pesée ; manuel = dernier +1 (§ 2.7) |
|
||||||
|
| 4 | Poids net | **plein − vide**, calculé serveur (§ 2.8) |
|
||||||
|
| 5 | Numérotation | **`{siteCode}-TP-{NNNN}`** par site, séquence verrouillée (§ 2.5) ; ajout `site.code` |
|
||||||
|
| 6 | Contrepartie | `counterpartyType` + FK Client/Supplier ou `otherLabel` (RG-5.03, § 2.9) |
|
||||||
|
| 7 | Deux pesées | Colonnes plates `empty_*` / `full_*` ; les 2 blocs supportent bascule + manuelle (§ 2.4) |
|
||||||
|
| 8 | Impression | Modal d'impression front ; bouton dispo en modif seulement (RG-5.08, § 2.12) |
|
||||||
|
| 9 | Masque immat | `XX-000-XX` + « Tout format », connectés entre blocs (RG-5.01, § 2.10) |
|
||||||
|
| 10 | RBAC | `logistique.weighing_tickets.view/manage` ; Usine = Tout ; Compta + Commerciale sans accès (§ 5.2) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Tickets Lesstime générés
|
||||||
|
|
||||||
|
**TaskGroup Lesstime** : **#33 — M5 — Tickets de pesée** (projet `ERP / Starseed`, projectId=6) — créé le 17/06/2026, 12 tickets au statut « Prêt à dev ».
|
||||||
|
|
||||||
|
| # | ERP | Ticket | Effort | Tag |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1.1 | ERP-181 | Scaffolder le module Logistique + RBAC | M | Backend |
|
||||||
|
| 1.2 | ERP-182 | Migrer le schéma M5 (site.code, compteurs, weighing_ticket) | M | Backend |
|
||||||
|
| 1.3 | ERP-183 | Créer l'entité WeighingTicket + repository + contrat sérialisation | M | Backend |
|
||||||
|
| 1.4 | ERP-184 | Implémenter la pesée pont bascule (stub + DSD + endpoint) | M | Backend |
|
||||||
|
| 1.5 | ERP-185 | Créer Provider + Processor (numérotation, RG, normalisation) | L | Backend |
|
||||||
|
| 1.6 | ERP-186 | Implémenter l'export XLSX | S | Backend |
|
||||||
|
| 1.7 | ERP-187 | Tests PHPUnit RG-5.01→5.10 + capture contrat JSON | M | Backend |
|
||||||
|
| 1.8 | ERP-188 | Créer la page liste `/weighing-tickets` + export | M | Frontend |
|
||||||
|
| 1.9 | ERP-189 | Implémenter l'écran Ajouter (blocs vide+plein, pesée, masque immat) | L | Frontend |
|
||||||
|
| 1.10 | ERP-190 | Implémenter l'écran Modification + déclenchement impression | M | Frontend |
|
||||||
|
| 1.11 | ERP-191 | i18n + libellés + branchement site courant | S | Frontend |
|
||||||
|
| 1.12 | ERP-192 | **Bon d'impression du ticket de pesée — OWNER Tristan** | — | Frontend |
|
||||||
+380
-21
@@ -2,6 +2,7 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
|
"validate": "Valider",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"edit": "Modifier",
|
"edit": "Modifier",
|
||||||
@@ -34,6 +35,14 @@
|
|||||||
"section": "Technique",
|
"section": "Technique",
|
||||||
"providers": "Répertoire prestataires"
|
"providers": "Répertoire prestataires"
|
||||||
},
|
},
|
||||||
|
"transport": {
|
||||||
|
"section": "Transport",
|
||||||
|
"carriers": "Répertoire transporteurs"
|
||||||
|
},
|
||||||
|
"logistique": {
|
||||||
|
"section": "Logistique",
|
||||||
|
"weighing_tickets": "Tickets de pesée"
|
||||||
|
},
|
||||||
"core": {
|
"core": {
|
||||||
"roles": "Gestion des rôles",
|
"roles": "Gestion des rôles",
|
||||||
"users": "Utilisateurs",
|
"users": "Utilisateurs",
|
||||||
@@ -62,7 +71,7 @@
|
|||||||
"companyName": "Nom",
|
"companyName": "Nom",
|
||||||
"categories": "Catégories",
|
"categories": "Catégories",
|
||||||
"sites": "Site",
|
"sites": "Site",
|
||||||
"lastActivity": "Dernière activité"
|
"lastActivity": "Dernière modification"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"title": "Filtres",
|
"title": "Filtres",
|
||||||
@@ -70,7 +79,7 @@
|
|||||||
"categories": "Catégories",
|
"categories": "Catégories",
|
||||||
"sites": "Sites",
|
"sites": "Sites",
|
||||||
"status": "Statut",
|
"status": "Statut",
|
||||||
"includeArchived": "Inclure les archivés",
|
"archivedOnly": "Voir les archivés",
|
||||||
"apply": "Voir les résultats",
|
"apply": "Voir les résultats",
|
||||||
"reset": "Réinitialiser"
|
"reset": "Réinitialiser"
|
||||||
},
|
},
|
||||||
@@ -116,10 +125,10 @@
|
|||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"title": "Modifier le fournisseur",
|
"title": "Modifier le fournisseur",
|
||||||
"back": "Retour au répertoire",
|
"back": "Retour à la consultation",
|
||||||
"loading": "Chargement du fournisseur…",
|
"loading": "Chargement du fournisseur…",
|
||||||
"notFound": "Fournisseur introuvable.",
|
"notFound": "Fournisseur introuvable.",
|
||||||
"save": "Valider"
|
"save": "Enregistrer"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Ajouter un fournisseur",
|
"title": "Ajouter un fournisseur",
|
||||||
@@ -174,6 +183,7 @@
|
|||||||
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||||
},
|
},
|
||||||
"accounting": {
|
"accounting": {
|
||||||
|
"infoTitle": "Informations",
|
||||||
"siren": "SIREN",
|
"siren": "SIREN",
|
||||||
"accountNumber": "Numéro de compte",
|
"accountNumber": "Numéro de compte",
|
||||||
"tvaMode": "Mode de TVA",
|
"tvaMode": "Mode de TVA",
|
||||||
@@ -181,6 +191,7 @@
|
|||||||
"paymentDelay": "Délai de règlement",
|
"paymentDelay": "Délai de règlement",
|
||||||
"paymentType": "Type de règlement",
|
"paymentType": "Type de règlement",
|
||||||
"bank": "Banque",
|
"bank": "Banque",
|
||||||
|
"ribTitle": "RIB {n}",
|
||||||
"ribLabel": "Libellé",
|
"ribLabel": "Libellé",
|
||||||
"ribBic": "BIC",
|
"ribBic": "BIC",
|
||||||
"ribIban": "IBAN",
|
"ribIban": "IBAN",
|
||||||
@@ -206,7 +217,7 @@
|
|||||||
"companyName": "Nom",
|
"companyName": "Nom",
|
||||||
"categories": "Catégories",
|
"categories": "Catégories",
|
||||||
"sites": "Site",
|
"sites": "Site",
|
||||||
"lastActivity": "Dernière activité"
|
"lastActivity": "Dernière modification"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"title": "Filtres",
|
"title": "Filtres",
|
||||||
@@ -259,10 +270,10 @@
|
|||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"title": "Modifier le client",
|
"title": "Modifier le client",
|
||||||
"back": "Retour au répertoire",
|
"back": "Retour à la consultation",
|
||||||
"loading": "Chargement du client…",
|
"loading": "Chargement du client…",
|
||||||
"notFound": "Client introuvable.",
|
"notFound": "Client introuvable.",
|
||||||
"save": "Valider"
|
"save": "Enregistrer"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.",
|
"informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.",
|
||||||
@@ -341,6 +352,7 @@
|
|||||||
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||||
},
|
},
|
||||||
"accounting": {
|
"accounting": {
|
||||||
|
"infoTitle": "Informations",
|
||||||
"siren": "SIREN",
|
"siren": "SIREN",
|
||||||
"accountNumber": "Numéro de compte",
|
"accountNumber": "Numéro de compte",
|
||||||
"tvaMode": "Mode de TVA",
|
"tvaMode": "Mode de TVA",
|
||||||
@@ -376,7 +388,7 @@
|
|||||||
"companyName": "Nom",
|
"companyName": "Nom",
|
||||||
"categories": "Catégories",
|
"categories": "Catégories",
|
||||||
"sites": "Site",
|
"sites": "Site",
|
||||||
"lastActivity": "Dernière activité"
|
"lastActivity": "Dernière modification"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"title": "Filtres",
|
"title": "Filtres",
|
||||||
@@ -384,15 +396,38 @@
|
|||||||
"categories": "Catégories",
|
"categories": "Catégories",
|
||||||
"sites": "Sites",
|
"sites": "Sites",
|
||||||
"status": "Statut",
|
"status": "Statut",
|
||||||
"includeArchived": "Inclure les archivés",
|
"archivedOnly": "Voir les archivés",
|
||||||
"apply": "Voir les résultats",
|
"apply": "Voir les résultats",
|
||||||
"reset": "Réinitialiser"
|
"reset": "Réinitialiser"
|
||||||
},
|
},
|
||||||
"tab": {
|
"tab": {
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
|
"contacts": "Contacts",
|
||||||
"address": "Adresse",
|
"address": "Adresse",
|
||||||
|
"reports": "Rapports",
|
||||||
|
"exchanges": "Échanges",
|
||||||
"accounting": "Comptabilité"
|
"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 consultation",
|
||||||
|
"loading": "Chargement…",
|
||||||
|
"notFound": "Prestataire introuvable.",
|
||||||
|
"save": "Enregistrer"
|
||||||
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Ajouter un prestataire",
|
"title": "Ajouter un prestataire",
|
||||||
"back": "Précédent",
|
"back": "Précédent",
|
||||||
@@ -404,14 +439,332 @@
|
|||||||
"sites": "Site"
|
"sites": "Site"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"nameRequired": "Le nom du prestataire est obligatoire.",
|
||||||
"siteRequired": "Sélectionnez au moins un site.",
|
"siteRequired": "Sélectionnez au moins un site.",
|
||||||
"categoryRequired": "Sélectionnez au moins une catégorie."
|
"categoryRequired": "Sélectionnez au moins une catégorie."
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"title": "Contact {n}",
|
||||||
|
"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": {
|
||||||
|
"title": "Adresse {n}",
|
||||||
|
"sites": "Sites",
|
||||||
|
"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": {
|
||||||
|
"infoTitle": "Informations",
|
||||||
|
"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",
|
||||||
|
"ribTitle": "RIB {n}",
|
||||||
|
"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": {
|
"toast": {
|
||||||
"error": "Une erreur est survenue. Réessayez.",
|
"error": "Une erreur est survenue. Réessayez.",
|
||||||
"exportError": "L'export du répertoire prestataires a échoué. Réessayez.",
|
"exportError": "L'export du répertoire prestataires a échoué. Réessayez.",
|
||||||
"createSuccess": "Prestataire créé avec succès"
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transport": {
|
||||||
|
"carriers": {
|
||||||
|
"title": "Répertoire transporteurs",
|
||||||
|
"add": "Ajouter",
|
||||||
|
"export": "Exporter",
|
||||||
|
"empty": "Aucun transporteur pour l'instant.",
|
||||||
|
"column": {
|
||||||
|
"name": "Nom",
|
||||||
|
"certification": "Certification",
|
||||||
|
"validityDate": "Date de validité",
|
||||||
|
"lastActivity": "Dernière modification"
|
||||||
|
},
|
||||||
|
"certification": {
|
||||||
|
"QUALIMAT": "QUALIMAT",
|
||||||
|
"GMP_PLUS": "GMP+",
|
||||||
|
"OVOCOM": "OVOCOM",
|
||||||
|
"COMPTE_PROPRE": "Compte-propre",
|
||||||
|
"AUTRE": "Autre"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"title": "Filtres",
|
||||||
|
"search": "Recherche",
|
||||||
|
"certification": "Certification",
|
||||||
|
"status": "Statut",
|
||||||
|
"archivedOnly": "Voir les archivés",
|
||||||
|
"apply": "Voir les résultats",
|
||||||
|
"reset": "Réinitialiser"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"error": "Une erreur est survenue. Réessayez.",
|
||||||
|
"exportError": "L'export du répertoire transporteurs a échoué. Réessayez.",
|
||||||
|
"createSuccess": "Transporteur créé avec succès",
|
||||||
|
"integrateSuccess": "Transporteur QUALIMAT intégré",
|
||||||
|
"addressSaved": "Adresse enregistrée",
|
||||||
|
"contactSaved": "Contact enregistré",
|
||||||
|
"priceSaved": "Prix enregistré",
|
||||||
|
"updateSuccess": "Transporteur mis à jour avec succès",
|
||||||
|
"archiveSuccess": "Transporteur archivé avec succès",
|
||||||
|
"restoreSuccess": "Transporteur restauré avec succès"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"edit": "Modifier",
|
||||||
|
"archive": "Archiver",
|
||||||
|
"restore": "Restaurer"
|
||||||
|
},
|
||||||
|
"consultation": {
|
||||||
|
"title": "Consultation transporteur",
|
||||||
|
"back": "Retour au répertoire",
|
||||||
|
"loading": "Chargement du transporteur…",
|
||||||
|
"notFound": "Transporteur introuvable.",
|
||||||
|
"confirmArchive": {
|
||||||
|
"title": "Archiver le transporteur",
|
||||||
|
"message": "Ce transporteur n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?"
|
||||||
|
},
|
||||||
|
"confirmRestore": {
|
||||||
|
"title": "Restaurer le transporteur",
|
||||||
|
"message": "Ce transporteur réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
|
||||||
|
},
|
||||||
|
"price": {
|
||||||
|
"group": "Transport",
|
||||||
|
"carrier": "Fournisseurs / Clients",
|
||||||
|
"aproOrSite": "Adresse sites",
|
||||||
|
"delivery": "Adresse livraisons",
|
||||||
|
"forfait": "Forfait (€)",
|
||||||
|
"tonne": "Tonne (€)",
|
||||||
|
"indexation": "Indexation",
|
||||||
|
"state": "État du prix",
|
||||||
|
"export": "Exporter",
|
||||||
|
"empty": "Aucun prix pour ce transporteur."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Modifier le transporteur",
|
||||||
|
"back": "Retour à la consultation",
|
||||||
|
"loading": "Chargement du transporteur…",
|
||||||
|
"notFound": "Transporteur introuvable.",
|
||||||
|
"save": "Enregistrer"
|
||||||
|
},
|
||||||
|
"containerType": {
|
||||||
|
"BENNE": "Benne",
|
||||||
|
"FOND_MOUVANT": "Fond mouvant"
|
||||||
|
},
|
||||||
|
"tab": {
|
||||||
|
"qualimat": "Qualimat",
|
||||||
|
"addresses": "Adresses",
|
||||||
|
"contacts": "Contacts",
|
||||||
|
"prices": "Prix"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"title": "Ajouter un transporteur",
|
||||||
|
"back": "Retour au répertoire",
|
||||||
|
"submit": "Valider",
|
||||||
|
"comingSoon": "À venir",
|
||||||
|
"duplicateName": "Un transporteur actif portant ce nom existe déjà.",
|
||||||
|
"main": {
|
||||||
|
"name": "Nom",
|
||||||
|
"certificationType": "Certification transport",
|
||||||
|
"isChartered": "Affréter",
|
||||||
|
"indexationRate": "Indexation %",
|
||||||
|
"containerType": "Benne / Fond mouvant",
|
||||||
|
"volumeM3": "Volume m³",
|
||||||
|
"discharge": "Décharge",
|
||||||
|
"liotPlates": "Immatriculations LIOT",
|
||||||
|
"liotPlatesHint": "Séparées par « ; »"
|
||||||
|
},
|
||||||
|
"qualimat": {
|
||||||
|
"empty": "Aucun transporteur QUALIMAT trouvé.",
|
||||||
|
"searchHint": "Saisissez le nom du transporteur pour lancer la recherche.",
|
||||||
|
"columns": {
|
||||||
|
"name": "Nom",
|
||||||
|
"address": "Adresse",
|
||||||
|
"validityDate": "Date de validité"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"title": "Intégration QUALIMAT",
|
||||||
|
"message": "Êtes-vous sûr de vouloir intégrer ce transporteur ?",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"confirm": "Intégrer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"nameRequired": "Le nom du transporteur est obligatoire.",
|
||||||
|
"certificationRequired": "Le type de certification est obligatoire.",
|
||||||
|
"dischargeRequired": "La décharge est obligatoire pour une certification « Autre ».",
|
||||||
|
"indexationRequired": "Le taux d'indexation est obligatoire pour un transporteur affrété.",
|
||||||
|
"containerTypeRequired": "Le type de contenant est obligatoire pour un transporteur affrété.",
|
||||||
|
"volumeRequired": "Le volume est obligatoire pour un transporteur affrété.",
|
||||||
|
"uploadFailed": "Le téléversement de la décharge a échoué."
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"title": "Adresse",
|
||||||
|
"country": "Pays",
|
||||||
|
"postalCode": "Code postal",
|
||||||
|
"city": "Ville",
|
||||||
|
"street": "Adresse",
|
||||||
|
"streetComplement": "Adresse complémentaire",
|
||||||
|
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
|
||||||
|
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"title": "Contact {n}",
|
||||||
|
"lastName": "Nom",
|
||||||
|
"firstName": "Prénom",
|
||||||
|
"jobTitle": "Fonction",
|
||||||
|
"phonePrimary": "Téléphone",
|
||||||
|
"phoneSecondary": "Téléphone (2)",
|
||||||
|
"addPhone": "Ajouter un numéro",
|
||||||
|
"email": "Email",
|
||||||
|
"add": "Nouveau contact",
|
||||||
|
"remove": "Supprimer le contact"
|
||||||
|
},
|
||||||
|
"confirmDelete": {
|
||||||
|
"title": "Supprimer ce bloc",
|
||||||
|
"message": "Cette suppression est définitive. Confirmer ?",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"confirm": "Supprimer"
|
||||||
|
},
|
||||||
|
"price": {
|
||||||
|
"direction": "Sens",
|
||||||
|
"directionClient": "Client",
|
||||||
|
"directionSupplier": "Fournisseur",
|
||||||
|
"client": "Client",
|
||||||
|
"clientDeliveryAddress": "Adresse de livraison",
|
||||||
|
"departureSite": "Adresse de départ",
|
||||||
|
"supplier": "Fournisseur",
|
||||||
|
"supplierSupplyAddress": "Adresse d'approvisionnement",
|
||||||
|
"deliverySite": "Adresse de livraison",
|
||||||
|
"containerType": "Benne / Fond mouvant",
|
||||||
|
"pricingUnit": "Forfait / Tonne",
|
||||||
|
"pricingForfait": "Forfait",
|
||||||
|
"pricingTonne": "Tonne",
|
||||||
|
"price": "Prix",
|
||||||
|
"priceState": "État du prix",
|
||||||
|
"stateEnCours": "En cours",
|
||||||
|
"stateValide": "Validé",
|
||||||
|
"stateNonValide": "Non validé",
|
||||||
|
"add": "Nouveau prix",
|
||||||
|
"remove": "Supprimer le prix",
|
||||||
|
"errors": {
|
||||||
|
"direction": "Le sens du prix est obligatoire.",
|
||||||
|
"client": "Le client est obligatoire pour un prix client.",
|
||||||
|
"clientDeliveryAddress": "L'adresse de livraison du client est obligatoire pour un prix client.",
|
||||||
|
"departureSite": "Le site de départ est obligatoire pour un prix client.",
|
||||||
|
"supplier": "Le fournisseur est obligatoire pour un prix fournisseur.",
|
||||||
|
"supplierSupplyAddress": "L'adresse d'approvisionnement est obligatoire pour un prix fournisseur.",
|
||||||
|
"deliverySite": "Le site de livraison est obligatoire pour un prix fournisseur.",
|
||||||
|
"containerType": "Le type de contenant est obligatoire.",
|
||||||
|
"pricingUnit": "L'unité de tarification est obligatoire.",
|
||||||
|
"price": "Le prix est obligatoire.",
|
||||||
|
"priceState": "L'état du prix est obligatoire."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"logistique": {
|
||||||
|
"weighingTickets": {
|
||||||
|
"title": "Tickets de pesée",
|
||||||
|
"add": "Ajouter",
|
||||||
|
"export": "Exporter",
|
||||||
|
"empty": "Aucun ticket de pesée pour l'instant.",
|
||||||
|
"column": {
|
||||||
|
"number": "Numéro",
|
||||||
|
"client": "Client",
|
||||||
|
"supplier": "Fournisseur",
|
||||||
|
"other": "Autre",
|
||||||
|
"date": "Date",
|
||||||
|
"weight": "Poids",
|
||||||
|
"status": "Statut"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"draft": "En attente",
|
||||||
|
"validated": "Terminée"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"back": "Retour à la liste",
|
||||||
|
"addTitle": "Ajouter un ticket de pesée",
|
||||||
|
"emptyBlock": "Poids à vide",
|
||||||
|
"fullBlock": "Poids à plein",
|
||||||
|
"date": "Date",
|
||||||
|
"weight": "Poids (Kg)",
|
||||||
|
"dsd": "DSD",
|
||||||
|
"immatriculation": "Immatriculation",
|
||||||
|
"plateFreeFormat": "Tout format",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"validate": "Valider",
|
||||||
|
"print": "Imprimer",
|
||||||
|
"weightRequired": "Le poids est obligatoire : effectuez une pesée.",
|
||||||
|
"dsdRequired": "Le DSD est obligatoire : effectuez une pesée.",
|
||||||
|
"counterparty": {
|
||||||
|
"type": "Fournisseur / Client / Autre",
|
||||||
|
"supplier": "Fournisseur",
|
||||||
|
"client": "Client",
|
||||||
|
"other": "Autre"
|
||||||
|
},
|
||||||
|
"weighbridge": {
|
||||||
|
"auto": "Pesée bascule",
|
||||||
|
"manual": "Pesée manuelle",
|
||||||
|
"confirmTitle": "Êtes-vous sûr de vouloir déclencher une pesée ?",
|
||||||
|
"validate": "Valider",
|
||||||
|
"unavailable": "Pont bascule indisponible — passez en pesée manuelle."
|
||||||
|
},
|
||||||
|
"manual": {
|
||||||
|
"title": "Pesée manuelle",
|
||||||
|
"weight": "Poids (Kg)",
|
||||||
|
"dsd": "DSD",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"weightRequired": "Le poids est obligatoire.",
|
||||||
|
"dsdRequired": "Le DSD est obligatoire."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Ticket de pesée {number}",
|
||||||
|
"titleFallback": "Modifier un ticket de pesée",
|
||||||
|
"loading": "Chargement du ticket…",
|
||||||
|
"notFound": "Ticket de pesée introuvable."
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"error": "Une erreur est survenue. Réessayez.",
|
||||||
|
"exportError": "L'export des tickets de pesée a échoué. Réessayez."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -457,23 +810,28 @@
|
|||||||
"delete": "Suppression"
|
"delete": "Suppression"
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"core_user": "Utilisateur",
|
"core_user": "Utilisateur",
|
||||||
"core_role": "Rôle",
|
"core_role": "Rôle",
|
||||||
"core_permission": "Permission",
|
"core_permission": "Permission",
|
||||||
"sites_site": "Site",
|
"sites_site": "Site",
|
||||||
"catalog_category": "Catégorie",
|
"catalog_category": "Catégorie",
|
||||||
"commercial_client": "Client",
|
"commercial_client": "Client",
|
||||||
"commercial_clientaddress": "Adresse client",
|
"commercial_clientaddress": "Adresse client",
|
||||||
"commercial_clientcontact": "Contact client",
|
"commercial_clientcontact": "Contact client",
|
||||||
"commercial_clientrib": "RIB client",
|
"commercial_clientrib": "RIB client",
|
||||||
"commercial_supplier": "Fournisseur",
|
"commercial_supplier": "Fournisseur",
|
||||||
"commercial_supplieraddress": "Adresse fournisseur",
|
"commercial_supplieraddress": "Adresse fournisseur",
|
||||||
"commercial_suppliercontact": "Contact fournisseur",
|
"commercial_suppliercontact": "Contact fournisseur",
|
||||||
"commercial_supplierrib": "RIB fournisseur",
|
"commercial_supplierrib": "RIB fournisseur",
|
||||||
"technique_provider": "Prestataire",
|
"technique_provider": "Prestataire",
|
||||||
"technique_provideraddress": "Adresse prestataire",
|
"technique_provideraddress": "Adresse prestataire",
|
||||||
"technique_providercontact": "Contact prestataire",
|
"technique_providercontact": "Contact prestataire",
|
||||||
"technique_providerrib": "RIB prestataire"
|
"technique_providerrib": "RIB prestataire",
|
||||||
|
"transport_carrier": "Transporteur",
|
||||||
|
"transport_carrieraddress": "Adresse transporteur",
|
||||||
|
"transport_carriercontact": "Contact transporteur",
|
||||||
|
"transport_carrierprice": "Prix transporteur",
|
||||||
|
"logistique_weighingticket": "Ticket de pesée"
|
||||||
},
|
},
|
||||||
"empty": "Aucune activité enregistrée",
|
"empty": "Aucune activité enregistrée",
|
||||||
"no_results": "Aucun résultat pour ces filtres",
|
"no_results": "Aucun résultat pour ces filtres",
|
||||||
@@ -508,7 +866,8 @@
|
|||||||
"auth": {
|
"auth": {
|
||||||
"logout": "Deconnexion reussie"
|
"logout": "Deconnexion reussie"
|
||||||
},
|
},
|
||||||
"title": "Succès"
|
"title": "Succès",
|
||||||
|
"deleted": "Suppression effectuée"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"roles": {
|
"roles": {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
v-if="canShowSave"
|
v-if="canShowSave"
|
||||||
:label="t('common.save')"
|
:label="isCreateMode ? t('common.validate') : t('common.save')"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
button-class="w-m-btn-action"
|
button-class="w-m-btn-action"
|
||||||
:disabled="form.submitting.value || loadingTypes"
|
:disabled="form.submitting.value || loadingTypes"
|
||||||
|
|||||||
@@ -1,183 +1,211 @@
|
|||||||
<template>
|
<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)]">
|
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
|
||||||
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
(pas de bordure sous le dernier bloc). -->
|
||||||
<MalioButtonIcon
|
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
|
||||||
v-if="removable && !readonly"
|
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
|
||||||
icon="mdi:delete-outline"
|
<div class="flex items-center justify-between">
|
||||||
variant="ghost"
|
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
|
||||||
button-class="absolute top-3 right-3"
|
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||||
v-bind="{ ariaLabel: t('commercial.clients.form.address.remove') }"
|
<MalioButtonIcon
|
||||||
@click="$emit('remove')"
|
v-if="removable && !readonly && !disabled"
|
||||||
/>
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
|
button-class="p-0"
|
||||||
|
v-bind="{ ariaLabel: t('commercial.clients.form.address.remove') }"
|
||||||
|
@click="$emit('remove')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Usage de l'adresse : Select unique (plus simple pour l'utilisateur)
|
<!-- Grille 4 colonnes des champs de l'adresse. -->
|
||||||
remplacant les 3 cases. Les options encodent les combinaisons valides
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
(exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les
|
<!-- Usage de l'adresse : Select unique (plus simple pour l'utilisateur)
|
||||||
drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). -->
|
remplacant les 3 cases. Les options encodent les combinaisons valides
|
||||||
<!-- Erreur portee sur `isProspect` cote back (Callback type obligatoire +
|
(exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les
|
||||||
exclusivite prospect) -> affichee sous le select Type d'adresse. -->
|
drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). -->
|
||||||
<MalioSelect
|
<!-- Erreur portee sur `isProspect` cote back (Callback type obligatoire +
|
||||||
:model-value="addressType"
|
exclusivite prospect) -> affichee sous le select Type d'adresse. -->
|
||||||
:options="addressTypeOptions"
|
<MalioSelect
|
||||||
:label="t('commercial.clients.form.address.addressType')"
|
:model-value="addressType"
|
||||||
:readonly="readonly"
|
:options="addressTypeOptions"
|
||||||
:required="true"
|
:label="t('commercial.clients.form.address.addressType')"
|
||||||
:error="errors?.isProspect"
|
|
||||||
@update:model-value="onAddressTypeChange"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-1.10). -->
|
|
||||||
<MalioSelectCheckbox
|
|
||||||
:model-value="model.siteIris"
|
|
||||||
:options="siteOptions"
|
|
||||||
:label="t('commercial.clients.form.address.sites')"
|
|
||||||
:display-tag="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
:required="true"
|
|
||||||
:error="errors?.sites"
|
|
||||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MalioSelectCheckbox
|
|
||||||
:model-value="model.contactIris"
|
|
||||||
:options="contactOptions"
|
|
||||||
:label="t('commercial.clients.form.address.contacts')"
|
|
||||||
:display-tag="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Email(s) de facturation : visible/obligatoire seulement si Facturation
|
|
||||||
(RG-1.11). Le « + » revele un 2e email optionnel (max 2, pendant du
|
|
||||||
telephone secondaire) qui coule dans la grille. Sinon un filler comble
|
|
||||||
la colonne pour que Categorie reparte au debut de la ligne suivante. -->
|
|
||||||
<MalioInputEmail
|
|
||||||
v-if="isBillingEmailRequired(model)"
|
|
||||||
:model-value="model.billingEmail"
|
|
||||||
:label="t('commercial.clients.form.address.billingEmail')"
|
|
||||||
:required="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
:lowercase="true"
|
|
||||||
:error="errors?.billingEmail"
|
|
||||||
:addable="!model.hasSecondaryBillingEmail && !readonly"
|
|
||||||
:add-button-label="t('commercial.clients.form.address.addBillingEmail')"
|
|
||||||
@update:model-value="(v: string) => update('billingEmail', v)"
|
|
||||||
@add="revealSecondaryBillingEmail"
|
|
||||||
/>
|
|
||||||
<div v-else aria-hidden="true" />
|
|
||||||
|
|
||||||
<MalioInputEmail
|
|
||||||
v-if="isBillingEmailRequired(model) && model.hasSecondaryBillingEmail"
|
|
||||||
:model-value="model.billingEmailSecondary"
|
|
||||||
:label="t('commercial.clients.form.address.billingEmailSecondary')"
|
|
||||||
:readonly="readonly"
|
|
||||||
:lowercase="true"
|
|
||||||
:error="errors?.billingEmailSecondary"
|
|
||||||
@update:model-value="(v: string) => update('billingEmailSecondary', v)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MalioSelectCheckbox
|
|
||||||
:model-value="model.categoryIris"
|
|
||||||
:options="categoryOptions"
|
|
||||||
:label="t('commercial.clients.form.address.categories')"
|
|
||||||
:display-tag="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
:required="true"
|
|
||||||
:error="errors?.categories"
|
|
||||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MalioSelect
|
|
||||||
:model-value="model.country"
|
|
||||||
:options="countryOptions"
|
|
||||||
:label="t('commercial.clients.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('commercial.clients.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). Si la BAN est
|
|
||||||
indisponible, bascule en saisie libre — recuperable : re-saisir le
|
|
||||||
code postal relance la recherche et repasse en select au succes. -->
|
|
||||||
<MalioSelect
|
|
||||||
v-if="!degraded"
|
|
||||||
:model-value="model.city"
|
|
||||||
:options="cityOptions"
|
|
||||||
:label="t('commercial.clients.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('commercial.clients.form.address.city')"
|
|
||||||
:readonly="readonly"
|
|
||||||
:required="true"
|
|
||||||
:error="errors?.city"
|
|
||||||
@update:model-value="(v: string) => update('city', v)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Adresse + Adresse complementaire sur 2 colonnes : on wrappe car
|
|
||||||
MalioInputText/Autocomplete (inheritAttrs:false) renvoient `class`
|
|
||||||
sur l'input interne, pas sur la cellule de grille. Le wrapper porte
|
|
||||||
le col-span-2, le champ le remplit (w-full). -->
|
|
||||||
<div class="col-span-2">
|
|
||||||
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple
|
|
||||||
seulement en lecture seule (MalioInputAutocomplete ne reaffiche pas
|
|
||||||
sa valeur liee, il n'afficherait rien en readonly). allow-create :
|
|
||||||
si la BAN ne propose rien (ou erreur), le texte saisi est CONSERVE au
|
|
||||||
blur/Entree (saisie manuelle) — sinon il serait efface. La ville reste
|
|
||||||
pilotee par le code postal ; choisir une suggestion remplit rue+ville+CP. -->
|
|
||||||
<MalioInputAutocomplete
|
|
||||||
v-if="!readonly"
|
|
||||||
:model-value="model.street"
|
|
||||||
:options="addressOptions"
|
|
||||||
:loading="addressLoading"
|
|
||||||
:min-search-length="3"
|
|
||||||
:label="t('commercial.clients.form.address.street')"
|
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:required="true"
|
:disabled="disabled"
|
||||||
:error="errors?.street"
|
:required="!readonly && !disabled"
|
||||||
:allow-create="true"
|
:error="errors?.isProspect"
|
||||||
:no-results-text="t('commercial.clients.form.address.streetNotFound')"
|
@update:model-value="onAddressTypeChange"
|
||||||
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
/>
|
||||||
@search="onAddressSearch"
|
|
||||||
@select="onAddressSelect"
|
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-1.10). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="model.siteIris"
|
||||||
|
:options="siteOptions"
|
||||||
|
:label="t('commercial.clients.form.address.sites')"
|
||||||
|
:display-tag="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
:error="errors?.sites"
|
||||||
|
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Contacts rattaches (M2M, facultatif). Consultation : masque si aucun (ERP-193). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-if="!hideEmpty || isFilled(model.contactIris)"
|
||||||
|
:model-value="model.contactIris"
|
||||||
|
:options="contactOptions"
|
||||||
|
:label="t('commercial.clients.form.address.contacts')"
|
||||||
|
:display-tag="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Email(s) de facturation : visible/obligatoire seulement si Facturation
|
||||||
|
(RG-1.11). Le « + » revele un 2e email optionnel (max 2, pendant du
|
||||||
|
telephone secondaire) qui coule dans la grille. Sinon un filler comble
|
||||||
|
la colonne pour que Categorie reparte au debut de la ligne suivante. -->
|
||||||
|
<MalioInputEmail
|
||||||
|
v-if="isBillingEmailRequired(model)"
|
||||||
|
:model-value="model.billingEmail"
|
||||||
|
:label="t('commercial.clients.form.address.billingEmail')"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:lowercase="true"
|
||||||
|
:error="errors?.billingEmail"
|
||||||
|
:addable="!model.hasSecondaryBillingEmail && !readonly"
|
||||||
|
:add-button-label="t('commercial.clients.form.address.addBillingEmail')"
|
||||||
|
@update:model-value="(v: string) => update('billingEmail', v)"
|
||||||
|
@add="revealSecondaryBillingEmail"
|
||||||
|
/>
|
||||||
|
<!-- Filler : aligne la suite de la grille (Categorie au debut de ligne).
|
||||||
|
Inutile en consultation masquee (la grille se recompose sans les
|
||||||
|
champs vides, ERP-193). -->
|
||||||
|
<div v-else-if="!hideEmpty" aria-hidden="true" />
|
||||||
|
|
||||||
|
<MalioInputEmail
|
||||||
|
v-if="isBillingEmailRequired(model) && model.hasSecondaryBillingEmail"
|
||||||
|
:model-value="model.billingEmailSecondary"
|
||||||
|
:label="t('commercial.clients.form.address.billingEmailSecondary')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:lowercase="true"
|
||||||
|
:error="errors?.billingEmailSecondary"
|
||||||
|
@update:model-value="(v: string) => update('billingEmailSecondary', v)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="model.categoryIris"
|
||||||
|
:options="categoryOptions"
|
||||||
|
:label="t('commercial.clients.form.address.categories')"
|
||||||
|
:display-tag="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
:error="errors?.categories"
|
||||||
|
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="model.country"
|
||||||
|
:options="countryOptions"
|
||||||
|
:label="t('commercial.clients.form.address.country')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.postalCode"
|
||||||
|
:label="t('commercial.clients.form.address.postalCode')"
|
||||||
|
:mask="POSTAL_CODE_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
:error="errors?.postalCode"
|
||||||
|
@update:model-value="onPostalCodeChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Ville : MalioSelect alimente par le code postal (BAN). Si la BAN est
|
||||||
|
indisponible, bascule en saisie libre — recuperable : re-saisir le
|
||||||
|
code postal relance la recherche et repasse en select au succes. -->
|
||||||
|
<MalioSelect
|
||||||
|
v-if="!degraded"
|
||||||
|
:model-value="model.city"
|
||||||
|
:options="cityOptions"
|
||||||
|
:label="t('commercial.clients.form.address.city')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
:error="errors?.city"
|
||||||
|
@update:model-value="onCityChange"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-else
|
v-else
|
||||||
:model-value="model.street"
|
:model-value="model.city"
|
||||||
:label="t('commercial.clients.form.address.street')"
|
:label="t('commercial.clients.form.address.city')"
|
||||||
|
:mask="ADDRESS_MASK"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:required="true"
|
:disabled="disabled"
|
||||||
:error="errors?.street"
|
:required="!readonly && !disabled"
|
||||||
@update:model-value="(v: string) => update('street', v)"
|
:error="errors?.city"
|
||||||
|
@update:model-value="(v: string) => update('city', v)"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-span-1">
|
<!-- Adresse + Adresse complementaire sur 2 colonnes : on wrappe car
|
||||||
<MalioInputText
|
MalioInputText/Autocomplete (inheritAttrs:false) renvoient `class`
|
||||||
:model-value="model.streetComplement"
|
sur l'input interne, pas sur la cellule de grille. Le wrapper porte
|
||||||
:label="t('commercial.clients.form.address.streetComplement')"
|
le col-span-2, le champ le remplit (w-full). -->
|
||||||
:readonly="readonly"
|
<div class="col-span-2">
|
||||||
:error="errors?.streetComplement"
|
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple
|
||||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
seulement en lecture seule (MalioInputAutocomplete ne reaffiche pas
|
||||||
/>
|
sa valeur liee, il n'afficherait rien en readonly). allow-create :
|
||||||
</div>
|
si la BAN ne propose rien (ou erreur), le texte saisi est CONSERVE au
|
||||||
|
blur/Entree (saisie manuelle) — sinon il serait efface. La ville reste
|
||||||
|
pilotee par le code postal ; choisir une suggestion remplit rue+ville+CP. -->
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-if="!readonly && !disabled"
|
||||||
|
:model-value="model.street"
|
||||||
|
:options="addressOptions"
|
||||||
|
:loading="addressLoading"
|
||||||
|
:min-search-length="3"
|
||||||
|
:label="t('commercial.clients.form.address.street')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
:error="errors?.street"
|
||||||
|
:allow-create="true"
|
||||||
|
:no-results-text="t('commercial.clients.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('commercial.clients.form.address.street')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
:error="errors?.street"
|
||||||
|
@update:model-value="(v: string) => update('street', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.streetComplement"
|
||||||
|
:label="t('commercial.clients.form.address.streetComplement')"
|
||||||
|
:mask="ADDRESS_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors?.streetComplement"
|
||||||
|
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -191,6 +219,8 @@ import {
|
|||||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||||
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
||||||
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
|
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
|
||||||
|
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
|
||||||
|
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||||
|
|
||||||
// Masque code postal FR : 5 chiffres.
|
// Masque code postal FR : 5 chiffres.
|
||||||
const POSTAL_CODE_MASK = '#####'
|
const POSTAL_CODE_MASK = '#####'
|
||||||
@@ -208,7 +238,13 @@ const props = defineProps<{
|
|||||||
/** Pays disponibles (France par defaut). */
|
/** Pays disponibles (France par defaut). */
|
||||||
countryOptions: RefOption[]
|
countryOptions: RefOption[]
|
||||||
removable?: boolean
|
removable?: boolean
|
||||||
|
/** Dernier bloc de la liste : supprime le filet de separation bas. */
|
||||||
|
last?: boolean
|
||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
|
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||||
|
disabled?: boolean
|
||||||
|
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||||
|
hideEmpty?: boolean
|
||||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||||
errors?: Record<string, string>
|
errors?: Record<string, string>
|
||||||
}>()
|
}>()
|
||||||
@@ -284,11 +320,37 @@ const addressLoading = ref(false)
|
|||||||
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
||||||
let lastAddressSuggestions: AddressSuggestion[] = []
|
let lastAddressSuggestions: AddressSuggestion[] = []
|
||||||
|
|
||||||
|
// Filtrage des caracteres parasites : porte par le mask ADDRESS_MASK (maska) sur
|
||||||
|
// les champs texte editables (complement, ville en mode degrade). La voie en
|
||||||
|
// autocomplete (BAN) et la ville en select ne sont pas masquees (le back valide
|
||||||
|
// via Assert\Regex) ; les emails de facturation valident leur format (Assert\Email).
|
||||||
|
|
||||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||||
function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDraft[K]): void {
|
function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDraft[K]): void {
|
||||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus
|
||||||
|
* incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur.
|
||||||
|
* En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset
|
||||||
|
* a chaque frappe).
|
||||||
|
*/
|
||||||
|
function onCityChange(value: string | number | null): void {
|
||||||
|
const next = value === null ? null : String(value)
|
||||||
|
if (next === (props.modelValue.city ?? null)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
banAddressOptions.value = []
|
||||||
|
lastAddressSuggestions = []
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
city: next,
|
||||||
|
street: null,
|
||||||
|
streetComplement: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** Revele le 2e champ email de facturation (clic sur le « + »). */
|
/** Revele le 2e champ email de facturation (clic sur le « + »). */
|
||||||
function revealSecondaryBillingEmail(): void {
|
function revealSecondaryBillingEmail(): void {
|
||||||
emit('update:modelValue', { ...props.modelValue, hasSecondaryBillingEmail: true })
|
emit('update:modelValue', { ...props.modelValue, hasSecondaryBillingEmail: true })
|
||||||
@@ -304,9 +366,27 @@ function notifyUnavailable(): void {
|
|||||||
|
|
||||||
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
|
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
|
||||||
async function onPostalCodeChange(value: string): Promise<void> {
|
async function onPostalCodeChange(value: string): Promise<void> {
|
||||||
update('postalCode', value)
|
|
||||||
|
|
||||||
const digits = (value ?? '').replace(/\D/g, '')
|
const digits = (value ?? '').replace(/\D/g, '')
|
||||||
|
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
|
||||||
|
|
||||||
|
// CP complet (5 chiffres) et reellement modifie → ville, adresse et complement
|
||||||
|
// deviennent incoherents avec le nouveau code postal : on les vide pour forcer
|
||||||
|
// une re-saisie coherente (on n'efface pas pendant une correction partielle).
|
||||||
|
if (digits.length === 5 && digits !== previousDigits) {
|
||||||
|
banAddressOptions.value = []
|
||||||
|
lastAddressSuggestions = []
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
postalCode: value,
|
||||||
|
city: null,
|
||||||
|
street: null,
|
||||||
|
streetComplement: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
update('postalCode', value)
|
||||||
|
}
|
||||||
|
|
||||||
if (digits.length < 5) {
|
if (digits.length < 5) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +1,100 @@
|
|||||||
<template>
|
<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)]">
|
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
|
||||||
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
(pas de bordure sous le dernier bloc). -->
|
||||||
non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule.
|
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
|
||||||
ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
|
||||||
<MalioButtonIcon
|
<div class="flex items-center justify-between">
|
||||||
v-if="removable && !readonly"
|
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
|
||||||
icon="mdi:delete-outline"
|
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||||
variant="ghost"
|
non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule.
|
||||||
button-class="absolute top-3 right-3"
|
ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||||
v-bind="{ ariaLabel: t('commercial.clients.form.contact.remove') }"
|
<MalioButtonIcon
|
||||||
@click="$emit('remove')"
|
v-if="removable && !readonly && !disabled"
|
||||||
/>
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
<MalioInputText
|
button-class="p-0"
|
||||||
:model-value="model.lastName"
|
v-bind="{ ariaLabel: t('commercial.clients.form.contact.remove') }"
|
||||||
:label="t('commercial.clients.form.contact.lastName')"
|
@click="$emit('remove')"
|
||||||
:readonly="readonly"
|
/>
|
||||||
:error="errors?.lastName"
|
</div>
|
||||||
@update:model-value="(v: string) => update('lastName', v)"
|
|
||||||
/>
|
<!-- Grille 4 colonnes des champs du contact. -->
|
||||||
<MalioInputText
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
:model-value="model.firstName"
|
<MalioInputText
|
||||||
:label="t('commercial.clients.form.contact.firstName')"
|
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||||
:readonly="readonly"
|
:model-value="model.lastName"
|
||||||
:error="errors?.firstName"
|
:label="t('commercial.clients.form.contact.lastName')"
|
||||||
@update:model-value="(v: string) => update('firstName', v)"
|
:mask="PERSON_NAME_MASK"
|
||||||
/>
|
:readonly="readonly"
|
||||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
:disabled="disabled"
|
||||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
:error="errors?.lastName"
|
||||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
@update:model-value="(v: string) => update('lastName', v)"
|
||||||
<div class="col-span-2">
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
:model-value="model.jobTitle"
|
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||||
:label="t('commercial.clients.form.contact.jobTitle')"
|
:model-value="model.firstName"
|
||||||
:readonly="readonly"
|
:label="t('commercial.clients.form.contact.firstName')"
|
||||||
:error="errors?.jobTitle"
|
:mask="PERSON_NAME_MASK"
|
||||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
: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 v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.jobTitle"
|
||||||
|
:label="t('commercial.clients.form.contact.jobTitle')"
|
||||||
|
:mask="FREE_TEXT_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors?.jobTitle"
|
||||||
|
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<MalioInputEmail
|
||||||
|
v-if="!hideEmpty || isFilled(model.email)"
|
||||||
|
:model-value="model.email"
|
||||||
|
:label="t('commercial.clients.form.contact.email')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:lowercase="true"
|
||||||
|
:error="errors?.email"
|
||||||
|
@update:model-value="(v: string) => update('email', v)"
|
||||||
|
/>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||||
|
:model-value="model.phonePrimary"
|
||||||
|
:label="t('commercial.clients.form.contact.phonePrimary')"
|
||||||
|
:mask="PHONE_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors?.phonePrimary"
|
||||||
|
:addable="!model.hasSecondaryPhone && !readonly"
|
||||||
|
:add-button-label="t('commercial.clients.form.contact.addPhone')"
|
||||||
|
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||||
|
@add="revealSecondaryPhone"
|
||||||
|
/>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||||
|
:model-value="model.phoneSecondary"
|
||||||
|
:label="t('commercial.clients.form.contact.phoneSecondary')"
|
||||||
|
:mask="PHONE_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors?.phoneSecondary"
|
||||||
|
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<MalioInputEmail
|
|
||||||
:model-value="model.email"
|
|
||||||
:label="t('commercial.clients.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('commercial.clients.form.contact.phonePrimary')"
|
|
||||||
:mask="PHONE_MASK"
|
|
||||||
:readonly="readonly"
|
|
||||||
:error="errors?.phonePrimary"
|
|
||||||
:addable="!model.hasSecondaryPhone && !readonly"
|
|
||||||
:add-button-label="t('commercial.clients.form.contact.addPhone')"
|
|
||||||
@update:model-value="(v: string) => update('phonePrimary', v)"
|
|
||||||
@add="revealSecondaryPhone"
|
|
||||||
/>
|
|
||||||
<MalioInputPhone
|
|
||||||
v-if="model.hasSecondaryPhone"
|
|
||||||
:model-value="model.phoneSecondary"
|
|
||||||
:label="t('commercial.clients.form.contact.phoneSecondary')"
|
|
||||||
:mask="PHONE_MASK"
|
|
||||||
:readonly="readonly"
|
|
||||||
:error="errors?.phoneSecondary"
|
|
||||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ContactFormDraft } from '~/modules/commercial/types/clientForm'
|
import type { ContactFormDraft } from '~/modules/commercial/types/clientForm'
|
||||||
|
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
|
||||||
|
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||||
|
|
||||||
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste
|
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste
|
||||||
// serveur, cf. formatPhoneFR re-applique a la valeur renvoyee).
|
// serveur, cf. formatPhoneFR re-applique a la valeur renvoyee).
|
||||||
@@ -83,8 +107,14 @@ const props = defineProps<{
|
|||||||
title: string
|
title: string
|
||||||
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-1.14). */
|
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-1.14). */
|
||||||
removable?: boolean
|
removable?: boolean
|
||||||
|
/** Dernier bloc de la liste : supprime le filet de separation bas. */
|
||||||
|
last?: boolean
|
||||||
/** Bloc en lecture seule (onglet valide). */
|
/** Bloc en lecture seule (onglet valide). */
|
||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
|
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||||
|
disabled?: boolean
|
||||||
|
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||||
|
hideEmpty?: boolean
|
||||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||||
errors?: Record<string, string>
|
errors?: Record<string, string>
|
||||||
}>()
|
}>()
|
||||||
@@ -99,6 +129,10 @@ const { t } = useI18n()
|
|||||||
// Alias local pour la lisibilite du template.
|
// Alias local pour la lisibilite du template.
|
||||||
const model = computed(() => props.modelValue)
|
const model = computed(() => props.modelValue)
|
||||||
|
|
||||||
|
// Filtrage des caracteres parasites : porte par les masks maska sur les champs
|
||||||
|
// (PERSON_NAME_MASK / FREE_TEXT_MASK), filtrage natif au focus/curseur. L'email n'a
|
||||||
|
// pas de mask (ERP-101 : validation de format via Assert\Email + erreur inline).
|
||||||
|
|
||||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||||
function update<K extends keyof ContactFormDraft>(field: K, value: ContactFormDraft[K]): void {
|
function update<K extends keyof ContactFormDraft>(field: K, value: ContactFormDraft[K]): void {
|
||||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||||
|
|||||||
@@ -1,167 +1,198 @@
|
|||||||
<template>
|
<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)]">
|
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
|
||||||
<!-- Suppression : modal de confirmation cote parent. -->
|
(pas de bordure sous le dernier bloc). -->
|
||||||
<MalioButtonIcon
|
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
|
||||||
v-if="removable && !readonly"
|
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
|
||||||
icon="mdi:delete-outline"
|
<div class="flex items-center justify-between">
|
||||||
variant="ghost"
|
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
|
||||||
button-class="absolute top-3 right-3"
|
<!-- Suppression : modal de confirmation cote parent. -->
|
||||||
v-bind="{ ariaLabel: t('commercial.suppliers.form.address.remove') }"
|
<MalioButtonIcon
|
||||||
@click="$emit('remove')"
|
v-if="removable && !readonly && !disabled"
|
||||||
/>
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
|
button-class="p-0"
|
||||||
|
v-bind="{ ariaLabel: t('commercial.suppliers.form.address.remove') }"
|
||||||
|
@click="$emit('remove')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Type d'adresse : Prospect / Depart / Rendu (RG-2.09). Select en attendant
|
<!-- Grille 4 colonnes des champs de l'adresse. -->
|
||||||
l'arbitrage metier (radio vs select) ; l'erreur 422 (propertyPath
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
`addressType`) s'affiche via la prop native :error de MalioSelect. -->
|
<!-- Type d'adresse : Prospect / Depart / Rendu (RG-2.09). Select en attendant
|
||||||
<MalioSelect
|
l'arbitrage metier (radio vs select) ; l'erreur 422 (propertyPath
|
||||||
:model-value="model.addressType"
|
`addressType`) s'affiche via la prop native :error de MalioSelect. -->
|
||||||
:options="addressTypeOptions"
|
<MalioSelect
|
||||||
:label="t('commercial.suppliers.form.address.addressType')"
|
:model-value="model.addressType"
|
||||||
:readonly="readonly"
|
:options="addressTypeOptions"
|
||||||
empty-option-label=""
|
:label="t('commercial.suppliers.form.address.addressType')"
|
||||||
:required="true"
|
|
||||||
:error="errors?.addressType"
|
|
||||||
@update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-2.06). -->
|
|
||||||
<MalioSelectCheckbox
|
|
||||||
:model-value="model.siteIris"
|
|
||||||
:options="siteOptions"
|
|
||||||
:label="t('commercial.suppliers.form.address.sites')"
|
|
||||||
:display-tag="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
:required="true"
|
|
||||||
:error="errors?.sites"
|
|
||||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Contacts rattaches (M2M, facultatif). -->
|
|
||||||
<MalioSelectCheckbox
|
|
||||||
:model-value="model.contactIris"
|
|
||||||
:options="contactOptions"
|
|
||||||
:label="t('commercial.suppliers.form.address.contacts')"
|
|
||||||
:display-tag="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Filler : aligne le debut de ligne suivant sur la grille (le bloc client
|
|
||||||
porte ici l'email de facturation, absent cote fournisseur). -->
|
|
||||||
<div aria-hidden="true" />
|
|
||||||
|
|
||||||
<!-- Categories de type FOURNISSEUR (>= 1 obligatoire, RG-2.10). -->
|
|
||||||
<MalioSelectCheckbox
|
|
||||||
:model-value="model.categoryIris"
|
|
||||||
:options="categoryOptions"
|
|
||||||
:label="t('commercial.suppliers.form.address.categories')"
|
|
||||||
:display-tag="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
:required="true"
|
|
||||||
:error="errors?.categories"
|
|
||||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MalioSelect
|
|
||||||
:model-value="model.country"
|
|
||||||
:options="countryOptions"
|
|
||||||
:label="t('commercial.suppliers.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('commercial.suppliers.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('commercial.suppliers.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('commercial.suppliers.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('commercial.suppliers.form.address.street')"
|
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:required="true"
|
:disabled="disabled"
|
||||||
:error="errors?.street"
|
empty-option-label=""
|
||||||
:allow-create="true"
|
:required="!readonly && !disabled"
|
||||||
:no-results-text="t('commercial.suppliers.form.address.streetNotFound')"
|
:error="errors?.addressType"
|
||||||
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
@update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))"
|
||||||
@search="onAddressSearch"
|
/>
|
||||||
@select="onAddressSelect"
|
|
||||||
|
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-2.06). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="model.siteIris"
|
||||||
|
:options="siteOptions"
|
||||||
|
:label="t('commercial.suppliers.form.address.sites')"
|
||||||
|
:display-tag="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
:error="errors?.sites"
|
||||||
|
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Contacts rattaches (M2M, facultatif). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-if="!hideEmpty || isFilled(model.contactIris)"
|
||||||
|
:model-value="model.contactIris"
|
||||||
|
:options="contactOptions"
|
||||||
|
:label="t('commercial.suppliers.form.address.contacts')"
|
||||||
|
:display-tag="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Filler : aligne le debut de ligne suivant sur la grille (le bloc client
|
||||||
|
porte ici l'email de facturation, absent cote fournisseur). Inutile en
|
||||||
|
consultation masquee (la grille se recompose sans les champs vides). -->
|
||||||
|
<div v-if="!hideEmpty" aria-hidden="true" />
|
||||||
|
|
||||||
|
<!-- Categories de type FOURNISSEUR (>= 1 obligatoire, RG-2.10). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="model.categoryIris"
|
||||||
|
:options="categoryOptions"
|
||||||
|
:label="t('commercial.suppliers.form.address.categories')"
|
||||||
|
:display-tag="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
:error="errors?.categories"
|
||||||
|
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="model.country"
|
||||||
|
:options="countryOptions"
|
||||||
|
:label="t('commercial.suppliers.form.address.country')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.postalCode"
|
||||||
|
:label="t('commercial.suppliers.form.address.postalCode')"
|
||||||
|
:mask="POSTAL_CODE_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
: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('commercial.suppliers.form.address.city')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
:error="errors?.city"
|
||||||
|
@update:model-value="onCityChange"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-else
|
v-else
|
||||||
:model-value="model.street"
|
:model-value="model.city"
|
||||||
:label="t('commercial.suppliers.form.address.street')"
|
:label="t('commercial.suppliers.form.address.city')"
|
||||||
|
:mask="ADDRESS_MASK"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:required="true"
|
:disabled="disabled"
|
||||||
:error="errors?.street"
|
:required="!readonly && !disabled"
|
||||||
@update:model-value="(v: string) => update('street', v)"
|
: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 && !disabled"
|
||||||
|
:model-value="model.street"
|
||||||
|
:options="addressOptions"
|
||||||
|
:loading="addressLoading"
|
||||||
|
:min-search-length="3"
|
||||||
|
:label="t('commercial.suppliers.form.address.street')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
:error="errors?.street"
|
||||||
|
:allow-create="true"
|
||||||
|
:no-results-text="t('commercial.suppliers.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('commercial.suppliers.form.address.street')"
|
||||||
|
:mask="ADDRESS_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
:error="errors?.street"
|
||||||
|
@update:model-value="(v: string) => update('street', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.streetComplement"
|
||||||
|
:label="t('commercial.suppliers.form.address.streetComplement')"
|
||||||
|
:mask="ADDRESS_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors?.streetComplement"
|
||||||
|
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bennes : stepper (specifique fournisseur, defaut 0). En consultation, 0
|
||||||
|
reste affiche (valeur saisie) ; seul un champ vide serait masque. -->
|
||||||
|
<MalioInputNumber
|
||||||
|
v-if="!hideEmpty || isFilled(model.bennes)"
|
||||||
|
:model-value="model.bennes"
|
||||||
|
:label="t('commercial.suppliers.form.address.bennes')"
|
||||||
|
:min="0"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors?.bennes"
|
||||||
|
@update:model-value="(v: string) => update('bennes', v)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur).
|
||||||
|
Consultation : masquee si non cochee (ERP-193). -->
|
||||||
|
<MalioCheckbox
|
||||||
|
v-if="!hideEmpty || isFilled(model.triageProvider)"
|
||||||
|
id="address-triage-provider"
|
||||||
|
:label="t('commercial.suppliers.form.address.triageProvider')"
|
||||||
|
:model-value="model.triageProvider"
|
||||||
|
group-class="self-center"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
@update:model-value="(v: boolean) => update('triageProvider', v)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-1">
|
|
||||||
<MalioInputText
|
|
||||||
:model-value="model.streetComplement"
|
|
||||||
:label="t('commercial.suppliers.form.address.streetComplement')"
|
|
||||||
:readonly="readonly"
|
|
||||||
:error="errors?.streetComplement"
|
|
||||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bennes : stepper (specifique fournisseur, defaut 0). -->
|
|
||||||
<MalioInputNumber
|
|
||||||
:model-value="model.bennes"
|
|
||||||
:label="t('commercial.suppliers.form.address.bennes')"
|
|
||||||
:min="0"
|
|
||||||
:readonly="readonly"
|
|
||||||
:error="errors?.bennes"
|
|
||||||
@update:model-value="(v: string) => update('bennes', v)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur). -->
|
|
||||||
<MalioCheckbox
|
|
||||||
id="address-triage-provider"
|
|
||||||
:label="t('commercial.suppliers.form.address.triageProvider')"
|
|
||||||
:model-value="model.triageProvider"
|
|
||||||
group-class="self-center"
|
|
||||||
:readonly="readonly"
|
|
||||||
@update:model-value="(v: boolean) => update('triageProvider', v)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -169,6 +200,8 @@
|
|||||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||||
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
|
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
|
||||||
import type { SupplierAddressFormDraft, SupplierAddressType } from '~/modules/commercial/types/supplierForm'
|
import type { SupplierAddressFormDraft, SupplierAddressType } from '~/modules/commercial/types/supplierForm'
|
||||||
|
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
|
||||||
|
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||||
|
|
||||||
// Masque code postal FR : 5 chiffres.
|
// Masque code postal FR : 5 chiffres.
|
||||||
const POSTAL_CODE_MASK = '#####'
|
const POSTAL_CODE_MASK = '#####'
|
||||||
@@ -186,7 +219,13 @@ const props = defineProps<{
|
|||||||
/** Pays disponibles (France par defaut). */
|
/** Pays disponibles (France par defaut). */
|
||||||
countryOptions: RefOption[]
|
countryOptions: RefOption[]
|
||||||
removable?: boolean
|
removable?: boolean
|
||||||
|
/** Dernier bloc de la liste : supprime le filet de separation bas. */
|
||||||
|
last?: boolean
|
||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
|
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||||
|
disabled?: boolean
|
||||||
|
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||||
|
hideEmpty?: boolean
|
||||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||||
errors?: Record<string, string>
|
errors?: Record<string, string>
|
||||||
}>()
|
}>()
|
||||||
@@ -238,11 +277,37 @@ const addressLoading = ref(false)
|
|||||||
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
||||||
let lastAddressSuggestions: AddressSuggestion[] = []
|
let lastAddressSuggestions: AddressSuggestion[] = []
|
||||||
|
|
||||||
|
// Filtrage des caracteres parasites : porte par le mask ADDRESS_MASK (maska) sur
|
||||||
|
// les champs texte editables (complement, ville en mode degrade, voie en repli). La
|
||||||
|
// voie en autocomplete (BAN) et la ville en select ne sont pas masquees (le back
|
||||||
|
// valide via Assert\Regex).
|
||||||
|
|
||||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||||
function update<K extends keyof SupplierAddressFormDraft>(field: K, value: SupplierAddressFormDraft[K]): void {
|
function update<K extends keyof SupplierAddressFormDraft>(field: K, value: SupplierAddressFormDraft[K]): void {
|
||||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus
|
||||||
|
* incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur.
|
||||||
|
* En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset
|
||||||
|
* a chaque frappe).
|
||||||
|
*/
|
||||||
|
function onCityChange(value: string | number | null): void {
|
||||||
|
const next = value === null ? null : String(value)
|
||||||
|
if (next === (props.modelValue.city ?? null)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
banAddressOptions.value = []
|
||||||
|
lastAddressSuggestions = []
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
city: next,
|
||||||
|
street: null,
|
||||||
|
streetComplement: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
||||||
function notifyUnavailable(): void {
|
function notifyUnavailable(): void {
|
||||||
if (!unavailableNotified) {
|
if (!unavailableNotified) {
|
||||||
@@ -253,9 +318,27 @@ function notifyUnavailable(): void {
|
|||||||
|
|
||||||
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
|
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
|
||||||
async function onPostalCodeChange(value: string): Promise<void> {
|
async function onPostalCodeChange(value: string): Promise<void> {
|
||||||
update('postalCode', value)
|
|
||||||
|
|
||||||
const digits = (value ?? '').replace(/\D/g, '')
|
const digits = (value ?? '').replace(/\D/g, '')
|
||||||
|
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
|
||||||
|
|
||||||
|
// CP complet (5 chiffres) et reellement modifie → ville, adresse et complement
|
||||||
|
// deviennent incoherents avec le nouveau code postal : on les vide pour forcer
|
||||||
|
// une re-saisie coherente (on n'efface pas pendant une correction partielle).
|
||||||
|
if (digits.length === 5 && digits !== previousDigits) {
|
||||||
|
banAddressOptions.value = []
|
||||||
|
lastAddressSuggestions = []
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
postalCode: value,
|
||||||
|
city: null,
|
||||||
|
street: null,
|
||||||
|
streetComplement: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
update('postalCode', value)
|
||||||
|
}
|
||||||
|
|
||||||
if (digits.length < 5) {
|
if (digits.length < 5) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +1,99 @@
|
|||||||
<template>
|
<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)]">
|
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
|
||||||
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
(pas de bordure sous le dernier bloc). -->
|
||||||
non supprimable (1er bloc, RG-2.13) ou en lecture seule. -->
|
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
|
||||||
<MalioButtonIcon
|
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
|
||||||
v-if="removable && !readonly"
|
<div class="flex items-center justify-between">
|
||||||
icon="mdi:delete-outline"
|
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
|
||||||
variant="ghost"
|
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||||
button-class="absolute top-3 right-3"
|
non supprimable (1er bloc, RG-2.13) ou en lecture seule. -->
|
||||||
v-bind="{ ariaLabel: t('commercial.suppliers.form.contact.remove') }"
|
<MalioButtonIcon
|
||||||
@click="$emit('remove')"
|
v-if="removable && !readonly && !disabled"
|
||||||
/>
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
<MalioInputText
|
button-class="p-0"
|
||||||
:model-value="model.lastName"
|
v-bind="{ ariaLabel: t('commercial.suppliers.form.contact.remove') }"
|
||||||
:label="t('commercial.suppliers.form.contact.lastName')"
|
@click="$emit('remove')"
|
||||||
:readonly="readonly"
|
/>
|
||||||
:error="errors?.lastName"
|
</div>
|
||||||
@update:model-value="(v: string) => update('lastName', v)"
|
|
||||||
/>
|
<!-- Grille 4 colonnes des champs du contact. -->
|
||||||
<MalioInputText
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
:model-value="model.firstName"
|
<MalioInputText
|
||||||
:label="t('commercial.suppliers.form.contact.firstName')"
|
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||||
:readonly="readonly"
|
:model-value="model.lastName"
|
||||||
:error="errors?.firstName"
|
:label="t('commercial.suppliers.form.contact.lastName')"
|
||||||
@update:model-value="(v: string) => update('firstName', v)"
|
:mask="PERSON_NAME_MASK"
|
||||||
/>
|
:readonly="readonly"
|
||||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
:disabled="disabled"
|
||||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
:error="errors?.lastName"
|
||||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
@update:model-value="(v: string) => update('lastName', v)"
|
||||||
<div class="col-span-2">
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
:model-value="model.jobTitle"
|
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||||
:label="t('commercial.suppliers.form.contact.jobTitle')"
|
:model-value="model.firstName"
|
||||||
:readonly="readonly"
|
:label="t('commercial.suppliers.form.contact.firstName')"
|
||||||
:error="errors?.jobTitle"
|
:mask="PERSON_NAME_MASK"
|
||||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
: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 v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.jobTitle"
|
||||||
|
:label="t('commercial.suppliers.form.contact.jobTitle')"
|
||||||
|
:mask="FREE_TEXT_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors?.jobTitle"
|
||||||
|
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<MalioInputEmail
|
||||||
|
v-if="!hideEmpty || isFilled(model.email)"
|
||||||
|
:model-value="model.email"
|
||||||
|
:label="t('commercial.suppliers.form.contact.email')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:lowercase="true"
|
||||||
|
:error="errors?.email"
|
||||||
|
@update:model-value="(v: string) => update('email', v)"
|
||||||
|
/>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||||
|
:model-value="model.phonePrimary"
|
||||||
|
:label="t('commercial.suppliers.form.contact.phonePrimary')"
|
||||||
|
:mask="PHONE_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors?.phonePrimary"
|
||||||
|
:addable="!model.hasSecondaryPhone && !readonly"
|
||||||
|
:add-button-label="t('commercial.suppliers.form.contact.addPhone')"
|
||||||
|
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||||
|
@add="revealSecondaryPhone"
|
||||||
|
/>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||||
|
:model-value="model.phoneSecondary"
|
||||||
|
:label="t('commercial.suppliers.form.contact.phoneSecondary')"
|
||||||
|
:mask="PHONE_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors?.phoneSecondary"
|
||||||
|
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<MalioInputEmail
|
|
||||||
:model-value="model.email"
|
|
||||||
:label="t('commercial.suppliers.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('commercial.suppliers.form.contact.phonePrimary')"
|
|
||||||
:mask="PHONE_MASK"
|
|
||||||
:readonly="readonly"
|
|
||||||
:error="errors?.phonePrimary"
|
|
||||||
:addable="!model.hasSecondaryPhone && !readonly"
|
|
||||||
:add-button-label="t('commercial.suppliers.form.contact.addPhone')"
|
|
||||||
@update:model-value="(v: string) => update('phonePrimary', v)"
|
|
||||||
@add="revealSecondaryPhone"
|
|
||||||
/>
|
|
||||||
<MalioInputPhone
|
|
||||||
v-if="model.hasSecondaryPhone"
|
|
||||||
:model-value="model.phoneSecondary"
|
|
||||||
:label="t('commercial.suppliers.form.contact.phoneSecondary')"
|
|
||||||
:mask="PHONE_MASK"
|
|
||||||
:readonly="readonly"
|
|
||||||
:error="errors?.phoneSecondary"
|
|
||||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SupplierContactFormDraft } from '~/modules/commercial/types/supplierForm'
|
import type { SupplierContactFormDraft } from '~/modules/commercial/types/supplierForm'
|
||||||
|
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
|
||||||
|
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||||
|
|
||||||
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
|
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
|
||||||
const PHONE_MASK = '## ## ## ## ##'
|
const PHONE_MASK = '## ## ## ## ##'
|
||||||
@@ -81,8 +105,14 @@ const props = defineProps<{
|
|||||||
title: string
|
title: string
|
||||||
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-2.13). */
|
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-2.13). */
|
||||||
removable?: boolean
|
removable?: boolean
|
||||||
|
/** Dernier bloc de la liste : supprime le filet de separation bas. */
|
||||||
|
last?: boolean
|
||||||
/** Bloc en lecture seule (onglet valide). */
|
/** Bloc en lecture seule (onglet valide). */
|
||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
|
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||||
|
disabled?: boolean
|
||||||
|
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||||
|
hideEmpty?: boolean
|
||||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||||
errors?: Record<string, string>
|
errors?: Record<string, string>
|
||||||
}>()
|
}>()
|
||||||
@@ -97,6 +127,10 @@ const { t } = useI18n()
|
|||||||
// Alias local pour la lisibilite du template.
|
// Alias local pour la lisibilite du template.
|
||||||
const model = computed(() => props.modelValue)
|
const model = computed(() => props.modelValue)
|
||||||
|
|
||||||
|
// Filtrage des caracteres parasites : porte par les masks maska sur les champs
|
||||||
|
// (PERSON_NAME_MASK / FREE_TEXT_MASK), filtrage natif au focus/curseur. L'email n'a
|
||||||
|
// pas de mask (ERP-101 : validation de format via Assert\Email + erreur inline).
|
||||||
|
|
||||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||||
function update<K extends keyof SupplierContactFormDraft>(field: K, value: SupplierContactFormDraft[K]): void {
|
function update<K extends keyof SupplierContactFormDraft>(field: K, value: SupplierContactFormDraft[K]): void {
|
||||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||||
|
|||||||
@@ -171,6 +171,182 @@ describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stub MalioInputText emetteur : re-expose `label` et relaie `update:model-value`,
|
||||||
|
* pour piloter le champ Code postal et observer le brouillon emis.
|
||||||
|
*/
|
||||||
|
const MalioInputTextEmitter = defineComponent({
|
||||||
|
name: 'MalioInputTextEmitter',
|
||||||
|
props: {
|
||||||
|
modelValue: { type: [String, Number, null], default: undefined },
|
||||||
|
label: { type: String, default: '' },
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props) {
|
||||||
|
return () => h('div', { 'data-testid': 'addr-input', 'data-label': props.label })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ClientAddressBlock — changement de code postal vide les champs dependants (ERP-193)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
searchCityMock.mockReset()
|
||||||
|
searchCityMock.mockResolvedValue([])
|
||||||
|
})
|
||||||
|
|
||||||
|
function mountFilled() {
|
||||||
|
return mount(ClientAddressBlock, {
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
...emptyAddress(),
|
||||||
|
postalCode: '75001',
|
||||||
|
city: 'Paris',
|
||||||
|
street: '8 Boulevard du Port',
|
||||||
|
streetComplement: 'Bat A',
|
||||||
|
},
|
||||||
|
title: 'Adresse',
|
||||||
|
categoryOptions: [],
|
||||||
|
siteOptions: [],
|
||||||
|
contactOptions: [],
|
||||||
|
countryOptions: [],
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
MalioButtonIcon: true,
|
||||||
|
MalioCheckbox: true,
|
||||||
|
MalioSelect: true,
|
||||||
|
MalioSelectCheckbox: true,
|
||||||
|
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
||||||
|
MalioInputText: MalioInputTextEmitter,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function postalCodeField(wrapper: ReturnType<typeof mountFilled>) {
|
||||||
|
return wrapper.findAllComponents(MalioInputTextEmitter).find(
|
||||||
|
c => c.props('label') === 'commercial.clients.form.address.postalCode',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('vide ville, adresse et complement quand le CP complet change', async () => {
|
||||||
|
const wrapper = mountFilled()
|
||||||
|
|
||||||
|
postalCodeField(wrapper)!.vm.$emit('update:modelValue', '33000')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
|
||||||
|
expect(last.postalCode).toBe('33000')
|
||||||
|
expect(last.city).toBeNull()
|
||||||
|
expect(last.street).toBeNull()
|
||||||
|
expect(last.streetComplement).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne vide pas les champs si le CP reste incomplet (< 5 chiffres)', async () => {
|
||||||
|
const wrapper = mountFilled()
|
||||||
|
|
||||||
|
postalCodeField(wrapper)!.vm.$emit('update:modelValue', '7500')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
|
||||||
|
expect(last.postalCode).toBe('7500')
|
||||||
|
expect(last.city).toBe('Paris')
|
||||||
|
expect(last.street).toBe('8 Boulevard du Port')
|
||||||
|
expect(last.streetComplement).toBe('Bat A')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne vide pas les champs si le CP complet est identique', async () => {
|
||||||
|
const wrapper = mountFilled()
|
||||||
|
|
||||||
|
postalCodeField(wrapper)!.vm.$emit('update:modelValue', '75001')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
|
||||||
|
expect(last.city).toBe('Paris')
|
||||||
|
expect(last.street).toBe('8 Boulevard du Port')
|
||||||
|
expect(last.streetComplement).toBe('Bat A')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stub MalioSelect emetteur : re-expose `label` et relaie `update:model-value`,
|
||||||
|
* pour piloter le select Ville et observer le brouillon emis.
|
||||||
|
*/
|
||||||
|
const MalioSelectEmitter = defineComponent({
|
||||||
|
name: 'MalioSelectEmitter',
|
||||||
|
props: {
|
||||||
|
modelValue: { type: [String, Number, null], default: undefined },
|
||||||
|
label: { type: String, default: '' },
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props) {
|
||||||
|
return () => h('div', { 'data-testid': 'addr-select', 'data-label': props.label })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ClientAddressBlock — changement de ville vide adresse + complement (ERP-193)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
searchCityMock.mockReset()
|
||||||
|
searchCityMock.mockResolvedValue([])
|
||||||
|
})
|
||||||
|
|
||||||
|
function mountFilled() {
|
||||||
|
return mount(ClientAddressBlock, {
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
...emptyAddress(),
|
||||||
|
postalCode: '75001',
|
||||||
|
city: 'Paris',
|
||||||
|
street: '8 Boulevard du Port',
|
||||||
|
streetComplement: 'Bat A',
|
||||||
|
},
|
||||||
|
title: 'Adresse',
|
||||||
|
categoryOptions: [],
|
||||||
|
siteOptions: [],
|
||||||
|
contactOptions: [],
|
||||||
|
countryOptions: [],
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
MalioButtonIcon: true,
|
||||||
|
MalioCheckbox: true,
|
||||||
|
MalioSelectCheckbox: true,
|
||||||
|
MalioInputText: true,
|
||||||
|
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
||||||
|
MalioSelect: MalioSelectEmitter,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function cityField(wrapper: ReturnType<typeof mountFilled>) {
|
||||||
|
return wrapper.findAllComponents(MalioSelectEmitter).find(
|
||||||
|
c => c.props('label') === 'commercial.clients.form.address.city',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('vide adresse et complement quand la ville change', async () => {
|
||||||
|
const wrapper = mountFilled()
|
||||||
|
|
||||||
|
cityField(wrapper)!.vm.$emit('update:modelValue', 'Lyon')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
|
||||||
|
expect(last.city).toBe('Lyon')
|
||||||
|
expect(last.street).toBeNull()
|
||||||
|
expect(last.streetComplement).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne vide pas si la ville selectionnee est identique', async () => {
|
||||||
|
const wrapper = mountFilled()
|
||||||
|
|
||||||
|
cityField(wrapper)!.vm.$emit('update:modelValue', 'Paris')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Aucun nouvel emit (valeur inchangee) → l'adresse reste intacte.
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('ClientAddressBlock — recherche adresse robuste (erreur BAN)', () => {
|
describe('ClientAddressBlock — recherche adresse robuste (erreur BAN)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
searchAddressMock.mockReset()
|
searchAddressMock.mockReset()
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ function makeHydra(total: number): HydraCollection<Client> {
|
|||||||
describe('useClientsRepository', () => {
|
describe('useClientsRepository', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGet.mockReset()
|
mockGet.mockReset()
|
||||||
// 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
|
// 60 items → 3 pages a 25/page : permet de tester la navigation page 2.
|
||||||
mockGet.mockResolvedValue(makeHydra(25))
|
mockGet.mockResolvedValue(makeHydra(60))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('cible la ressource /clients en page 1 par defaut', async () => {
|
it('cible la ressource /clients en page 1 par defaut', async () => {
|
||||||
@@ -35,7 +35,7 @@ describe('useClientsRepository', () => {
|
|||||||
|
|
||||||
expect(mockGet).toHaveBeenLastCalledWith(
|
expect(mockGet).toHaveBeenLastCalledWith(
|
||||||
'/clients',
|
'/clients',
|
||||||
{ page: 1, itemsPerPage: 10 },
|
{ page: 1, itemsPerPage: 25 },
|
||||||
expect.objectContaining({ toast: false }),
|
expect.objectContaining({ toast: false }),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -65,7 +65,7 @@ describe('useClientsRepository', () => {
|
|||||||
'siteId[]': ['1', '2'],
|
'siteId[]': ['1', '2'],
|
||||||
archivedOnly: true,
|
archivedOnly: true,
|
||||||
page: 1,
|
page: 1,
|
||||||
itemsPerPage: 10,
|
itemsPerPage: 25,
|
||||||
},
|
},
|
||||||
expect.objectContaining({ toast: false }),
|
expect.objectContaining({ toast: false }),
|
||||||
)
|
)
|
||||||
@@ -78,7 +78,7 @@ describe('useClientsRepository', () => {
|
|||||||
|
|
||||||
expect(mockGet).toHaveBeenLastCalledWith(
|
expect(mockGet).toHaveBeenLastCalledWith(
|
||||||
'/clients',
|
'/clients',
|
||||||
{ page: 1, itemsPerPage: 10 },
|
{ page: 1, itemsPerPage: 25 },
|
||||||
expect.objectContaining({ toast: false }),
|
expect.objectContaining({ toast: false }),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ function makeHydra(total: number): HydraCollection<Supplier> {
|
|||||||
describe('useSuppliersRepository', () => {
|
describe('useSuppliersRepository', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGet.mockReset()
|
mockGet.mockReset()
|
||||||
// 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
|
// 60 items → 3 pages a 25/page : permet de tester la navigation page 2.
|
||||||
mockGet.mockResolvedValue(makeHydra(25))
|
mockGet.mockResolvedValue(makeHydra(60))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('cible la ressource /suppliers en page 1 par defaut', async () => {
|
it('cible la ressource /suppliers en page 1 par defaut', async () => {
|
||||||
@@ -35,7 +35,7 @@ describe('useSuppliersRepository', () => {
|
|||||||
|
|
||||||
expect(mockGet).toHaveBeenLastCalledWith(
|
expect(mockGet).toHaveBeenLastCalledWith(
|
||||||
'/suppliers',
|
'/suppliers',
|
||||||
{ page: 1, itemsPerPage: 10 },
|
{ page: 1, itemsPerPage: 25 },
|
||||||
expect.objectContaining({ toast: false }),
|
expect.objectContaining({ toast: false }),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -51,7 +51,7 @@ describe('useSuppliersRepository', () => {
|
|||||||
search: 'acme',
|
search: 'acme',
|
||||||
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
|
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
|
||||||
'siteId[]': ['86', '17'],
|
'siteId[]': ['86', '17'],
|
||||||
includeArchived: true,
|
archivedOnly: true,
|
||||||
},
|
},
|
||||||
{ replace: true },
|
{ replace: true },
|
||||||
)
|
)
|
||||||
@@ -63,9 +63,9 @@ describe('useSuppliersRepository', () => {
|
|||||||
search: 'acme',
|
search: 'acme',
|
||||||
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
|
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
|
||||||
'siteId[]': ['86', '17'],
|
'siteId[]': ['86', '17'],
|
||||||
includeArchived: true,
|
archivedOnly: true,
|
||||||
page: 1,
|
page: 1,
|
||||||
itemsPerPage: 10,
|
itemsPerPage: 25,
|
||||||
},
|
},
|
||||||
expect.objectContaining({ toast: false }),
|
expect.objectContaining({ toast: false }),
|
||||||
)
|
)
|
||||||
@@ -73,12 +73,12 @@ describe('useSuppliersRepository', () => {
|
|||||||
|
|
||||||
it('repasse a une query propre apres reinitialisation des filtres', async () => {
|
it('repasse a une query propre apres reinitialisation des filtres', async () => {
|
||||||
const repo = useSuppliersRepository()
|
const repo = useSuppliersRepository()
|
||||||
await repo.setFilters({ search: 'acme', includeArchived: true }, { replace: true })
|
await repo.setFilters({ search: 'acme', archivedOnly: true }, { replace: true })
|
||||||
await repo.setFilters({}, { replace: true })
|
await repo.setFilters({}, { replace: true })
|
||||||
|
|
||||||
expect(mockGet).toHaveBeenLastCalledWith(
|
expect(mockGet).toHaveBeenLastCalledWith(
|
||||||
'/suppliers',
|
'/suppliers',
|
||||||
{ page: 1, itemsPerPage: 10 },
|
{ page: 1, itemsPerPage: 25 },
|
||||||
expect.objectContaining({ toast: false }),
|
expect.objectContaining({ toast: false }),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -49,5 +49,6 @@ export interface Client {
|
|||||||
* gerer.
|
* gerer.
|
||||||
*/
|
*/
|
||||||
export function useClientsRepository() {
|
export function useClientsRepository() {
|
||||||
return usePaginatedList<Client>({ url: '/clients' })
|
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193).
|
||||||
|
return usePaginatedList<Client>({ url: '/clients', defaultItemsPerPage: 25 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,14 +41,16 @@ export interface Supplier {
|
|||||||
* sur la ressource `/suppliers` (RG-13 : pagination serveur obligatoire ; jamais
|
* sur la ressource `/suppliers` (RG-13 : pagination serveur obligatoire ; jamais
|
||||||
* de chargement integral en memoire). Miroir de `useClientsRepository` (M1).
|
* de chargement integral en memoire). Miroir de `useClientsRepository` (M1).
|
||||||
*
|
*
|
||||||
* Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes
|
* Les filtres (recherche, categories, sites, archives) sont pilotes par la page
|
||||||
* par la page via `setFilters` du composable partage — la remise en page 1 est
|
* via `setFilters` du composable partage — la remise en page 1 est garantie.
|
||||||
* garantie.
|
* Cocher « Voir les archivés » envoie `archivedOnly=true` → seules les archives
|
||||||
|
* sont listees (aligne sur Client).
|
||||||
*
|
*
|
||||||
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
|
* 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
|
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
|
||||||
* `usePaginatedList`. Aucun reset au logout a gerer.
|
* `usePaginatedList`. Aucun reset au logout a gerer.
|
||||||
*/
|
*/
|
||||||
export function useSuppliersRepository() {
|
export function useSuppliersRepository() {
|
||||||
return usePaginatedList<Supplier>({ url: '/suppliers' })
|
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193).
|
||||||
|
return usePaginatedList<Supplier>({ url: '/suppliers', defaultItemsPerPage: 25 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,16 +172,16 @@ describe('Répertoire fournisseurs (page /suppliers)', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('repercute le filtre « Inclure les archivés » dans setFilters sans toucher l\'URL', async () => {
|
it('repercute le filtre « Voir les archivés » dans setFilters sans toucher l\'URL', async () => {
|
||||||
const wrapper = mountPage()
|
const wrapper = mountPage()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
// Coche « Inclure les archivés » puis applique les filtres.
|
// Coche « Voir les archivés » puis applique les filtres.
|
||||||
await wrapper.find('input[data-id="filter-include-archived"]').setValue(true)
|
await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
|
||||||
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
|
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
|
||||||
|
|
||||||
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||||
{ includeArchived: true },
|
{ archivedOnly: true },
|
||||||
{ replace: true },
|
{ replace: true },
|
||||||
)
|
)
|
||||||
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
|
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
|
||||||
@@ -192,7 +192,7 @@ describe('Répertoire fournisseurs (page /suppliers)', () => {
|
|||||||
const wrapper = mountPage()
|
const wrapper = mountPage()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
await wrapper.find('input[data-id="filter-include-archived"]').setValue(true)
|
await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
|
||||||
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
|
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
|
||||||
|
|
||||||
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
|
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
icon="mdi:arrow-left-bold"
|
icon="mdi:arrow-left-bold"
|
||||||
icon-size="24"
|
icon-size="24"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
:title="t('commercial.clients.edit.back')"
|
||||||
v-bind="{ ariaLabel: t('commercial.clients.edit.back') }"
|
v-bind="{ ariaLabel: t('commercial.clients.edit.back') }"
|
||||||
@click="goBack"
|
@click="goBack"
|
||||||
/>
|
/>
|
||||||
@@ -25,9 +26,10 @@
|
|||||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="main.companyName"
|
v-model="main.companyName"
|
||||||
|
:mask="FREE_TEXT_MASK"
|
||||||
:label="t('commercial.clients.form.main.companyName')"
|
:label="t('commercial.clients.form.main.companyName')"
|
||||||
:required="true"
|
:required="true"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:error="mainErrors.errors.companyName"
|
:error="mainErrors.errors.companyName"
|
||||||
/>
|
/>
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox
|
||||||
@@ -35,7 +37,7 @@
|
|||||||
:options="mainCategoryOptions"
|
:options="mainCategoryOptions"
|
||||||
:label="t('commercial.clients.form.main.categories')"
|
:label="t('commercial.clients.form.main.categories')"
|
||||||
:display-tag="true"
|
:display-tag="true"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="mainErrors.errors.categories"
|
:error="mainErrors.errors.categories"
|
||||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||||
@@ -46,7 +48,7 @@
|
|||||||
:options="relationOptions"
|
:options="relationOptions"
|
||||||
:label="t('commercial.clients.form.main.relation')"
|
:label="t('commercial.clients.form.main.relation')"
|
||||||
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
@update:model-value="onRelationChange"
|
@update:model-value="onRelationChange"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
@@ -54,7 +56,7 @@
|
|||||||
:model-value="main.brokerIri"
|
:model-value="main.brokerIri"
|
||||||
:options="brokerOptions"
|
:options="brokerOptions"
|
||||||
:label="t('commercial.clients.form.main.brokerName')"
|
:label="t('commercial.clients.form.main.brokerName')"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="mainErrors.errors.broker"
|
:error="mainErrors.errors.broker"
|
||||||
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
||||||
@@ -64,7 +66,7 @@
|
|||||||
:model-value="main.distributorIri"
|
:model-value="main.distributorIri"
|
||||||
:options="distributorOptions"
|
:options="distributorOptions"
|
||||||
:label="t('commercial.clients.form.main.distributorName')"
|
:label="t('commercial.clients.form.main.distributorName')"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="mainErrors.errors.distributor"
|
:error="mainErrors.errors.distributor"
|
||||||
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
|
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
|
||||||
@@ -74,7 +76,7 @@
|
|||||||
v-model="main.triageService"
|
v-model="main.triageService"
|
||||||
:label="t('commercial.clients.form.main.triageService')"
|
:label="t('commercial.clients.form.main.triageService')"
|
||||||
group-class="self-center"
|
group-class="self-center"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,20 +103,24 @@
|
|||||||
resize="none"
|
resize="none"
|
||||||
group-class="row-span-2 pt-1 pb-1"
|
group-class="row-span-2 pt-1 pb-1"
|
||||||
text-input="h-full text-lg"
|
text-input="h-full text-lg"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:error="informationErrors.errors.description"
|
:error="informationErrors.errors.description"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="information.competitors"
|
v-model="information.competitors"
|
||||||
|
:mask="FREE_TEXT_MASK"
|
||||||
:label="t('commercial.clients.form.information.competitors')"
|
:label="t('commercial.clients.form.information.competitors')"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:error="informationErrors.errors.competitors"
|
:error="informationErrors.errors.competitors"
|
||||||
/>
|
/>
|
||||||
|
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
|
||||||
|
le calendrier a aujourd'hui et invalide une saisie future. -->
|
||||||
<MalioDate
|
<MalioDate
|
||||||
v-model="information.foundedAt"
|
v-model="information.foundedAt"
|
||||||
:label="t('commercial.clients.form.information.foundedAt')"
|
:label="t('commercial.clients.form.information.foundedAt')"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
|
:max="maxFoundedAt"
|
||||||
:error="informationErrors.errors.foundedAt"
|
:error="informationErrors.errors.foundedAt"
|
||||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||||
/>
|
/>
|
||||||
@@ -122,25 +128,30 @@
|
|||||||
v-model="information.employeesCount"
|
v-model="information.employeesCount"
|
||||||
:label="t('commercial.clients.form.information.employeesCount')"
|
:label="t('commercial.clients.form.information.employeesCount')"
|
||||||
:mask="EMPLOYEES_MASK"
|
:mask="EMPLOYEES_MASK"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:error="informationErrors.errors.employeesCount"
|
:error="informationErrors.errors.employeesCount"
|
||||||
/>
|
/>
|
||||||
|
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
|
||||||
|
:key force le re-affichage quand on plafonne (modelValue inchange). -->
|
||||||
<MalioInputAmount
|
<MalioInputAmount
|
||||||
v-model="information.revenueAmount"
|
:key="revenueAmountKey"
|
||||||
|
:model-value="information.revenueAmount"
|
||||||
:label="t('commercial.clients.form.information.revenueAmount')"
|
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:error="informationErrors.errors.revenueAmount"
|
:error="informationErrors.errors.revenueAmount"
|
||||||
|
@update:model-value="onRevenueAmountInput"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="information.directorName"
|
v-model="information.directorName"
|
||||||
|
:mask="PERSON_NAME_MASK"
|
||||||
:label="t('commercial.clients.form.information.directorName')"
|
:label="t('commercial.clients.form.information.directorName')"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:error="informationErrors.errors.directorName"
|
:error="informationErrors.errors.directorName"
|
||||||
/>
|
/>
|
||||||
<MalioInputAmount
|
<MalioInputAmount
|
||||||
v-model="information.profitAmount"
|
v-model="information.profitAmount"
|
||||||
:label="t('commercial.clients.form.information.profitAmount')"
|
:label="t('commercial.clients.form.information.profitAmount')"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:error="informationErrors.errors.profitAmount"
|
:error="informationErrors.errors.profitAmount"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,13 +168,18 @@
|
|||||||
<!-- Onglet Contact -->
|
<!-- Onglet Contact -->
|
||||||
<template #contact>
|
<template #contact>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<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
|
<ClientContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="contact.id ?? `new-${index}`"
|
:key="contact.id ?? `new-${index}`"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||||
:removable="contacts.length > 1"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="businessReadonly"
|
:last="index === contacts.length - 1"
|
||||||
|
:disabled="businessReadonly"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@remove="askRemoveContact(index)"
|
@remove="askRemoveContact(index)"
|
||||||
@@ -195,12 +211,13 @@
|
|||||||
:key="address.id ?? `new-${index}`"
|
:key="address.id ?? `new-${index}`"
|
||||||
:model-value="address"
|
:model-value="address"
|
||||||
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
|
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
|
||||||
|
:last="index === addresses.length - 1"
|
||||||
:category-options="addressCategoryOptions"
|
:category-options="addressCategoryOptions"
|
||||||
:site-options="siteOptions"
|
:site-options="siteOptions"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="addresses.length > 1"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@remove="askRemoveAddress(index)"
|
@remove="askRemoveAddress(index)"
|
||||||
@@ -229,20 +246,23 @@
|
|||||||
editable uniquement si accounting.manage). -->
|
editable uniquement si accounting.manage). -->
|
||||||
<template v-if="canAccountingView" #accounting>
|
<template v-if="canAccountingView" #accounting>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<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)]">
|
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
|
||||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
|
||||||
|
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.infoTitle') }}</h2>
|
||||||
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="accounting.siren"
|
v-model="accounting.siren"
|
||||||
:label="t('commercial.clients.form.accounting.siren')"
|
:label="t('commercial.clients.form.accounting.siren')"
|
||||||
:mask="SIREN_MASK"
|
:mask="SIREN_MASK"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.siren"
|
:error="accountingErrors.errors.siren"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="accounting.accountNumber"
|
v-model="accounting.accountNumber"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
:label="t('commercial.clients.form.accounting.accountNumber')"
|
:label="t('commercial.clients.form.accounting.accountNumber')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.accountNumber"
|
:error="accountingErrors.errors.accountNumber"
|
||||||
/>
|
/>
|
||||||
@@ -250,7 +270,7 @@
|
|||||||
:model-value="accounting.tvaModeIri"
|
:model-value="accounting.tvaModeIri"
|
||||||
:options="tvaModeOptions"
|
:options="tvaModeOptions"
|
||||||
:label="t('commercial.clients.form.accounting.tvaMode')"
|
:label="t('commercial.clients.form.accounting.tvaMode')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.tvaMode"
|
:error="accountingErrors.errors.tvaMode"
|
||||||
@@ -258,8 +278,9 @@
|
|||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="accounting.nTva"
|
v-model="accounting.nTva"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
:label="t('commercial.clients.form.accounting.nTva')"
|
:label="t('commercial.clients.form.accounting.nTva')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.nTva"
|
:error="accountingErrors.errors.nTva"
|
||||||
/>
|
/>
|
||||||
@@ -267,7 +288,7 @@
|
|||||||
:model-value="accounting.paymentDelayIri"
|
:model-value="accounting.paymentDelayIri"
|
||||||
:options="paymentDelayOptions"
|
:options="paymentDelayOptions"
|
||||||
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.paymentDelay"
|
:error="accountingErrors.errors.paymentDelay"
|
||||||
@@ -277,7 +298,7 @@
|
|||||||
:model-value="accounting.paymentTypeIri"
|
:model-value="accounting.paymentTypeIri"
|
||||||
:options="paymentTypeOptions"
|
:options="paymentTypeOptions"
|
||||||
:label="t('commercial.clients.form.accounting.paymentType')"
|
:label="t('commercial.clients.form.accounting.paymentType')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.paymentType"
|
:error="accountingErrors.errors.paymentType"
|
||||||
@@ -288,7 +309,7 @@
|
|||||||
:model-value="accounting.bankIri"
|
:model-value="accounting.bankIri"
|
||||||
:options="bankOptions"
|
:options="bankOptions"
|
||||||
:label="t('commercial.clients.form.accounting.bank')"
|
:label="t('commercial.clients.form.accounting.bank')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.bank"
|
:error="accountingErrors.errors.bank"
|
||||||
@@ -297,39 +318,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). -->
|
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13).
|
||||||
|
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
|
||||||
<div
|
<div
|
||||||
v-for="(rib, index) in visibleRibs"
|
v-for="(rib, index) in visibleRibs"
|
||||||
:key="rib.id ?? `new-${index}`"
|
:key="rib.id ?? `new-${index}`"
|
||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="pb-[20px]"
|
||||||
|
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
|
||||||
>
|
>
|
||||||
<MalioButtonIcon
|
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
<div class="flex items-center justify-between">
|
||||||
icon="mdi:delete-outline"
|
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
|
||||||
variant="ghost"
|
<MalioButtonIcon
|
||||||
button-class="absolute top-3 right-3"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
|
icon="mdi:delete-outline"
|
||||||
@click="askRemoveRib(index)"
|
variant="ghost"
|
||||||
/>
|
button-class="p-0"
|
||||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
|
||||||
|
@click="askRemoveRib(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="rib.label"
|
v-model="rib.label"
|
||||||
:label="t('commercial.clients.form.accounting.ribLabel')"
|
:label="t('commercial.clients.form.accounting.ribLabel')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="isRibRequired"
|
:required="isRibRequired"
|
||||||
:error="ribErrors[index]?.label"
|
:error="ribErrors[index]?.label"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="rib.bic"
|
v-model="rib.bic"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
:label="t('commercial.clients.form.accounting.ribBic')"
|
:label="t('commercial.clients.form.accounting.ribBic')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="isRibRequired"
|
:required="isRibRequired"
|
||||||
:error="ribErrors[index]?.bic"
|
:error="ribErrors[index]?.bic"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="rib.iban"
|
v-model="rib.iban"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
:label="t('commercial.clients.form.accounting.ribIban')"
|
:label="t('commercial.clients.form.accounting.ribIban')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="isRibRequired"
|
:required="isRibRequired"
|
||||||
:error="ribErrors[index]?.iban"
|
:error="ribErrors[index]?.iban"
|
||||||
/>
|
/>
|
||||||
@@ -419,6 +448,9 @@ import {
|
|||||||
type InformationFormDraft,
|
type InformationFormDraft,
|
||||||
type MainFormDraft,
|
type MainFormDraft,
|
||||||
} from '~/modules/commercial/utils/forms/clientEdit'
|
} from '~/modules/commercial/utils/forms/clientEdit'
|
||||||
|
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
|
||||||
|
import { todayIso } from '~/shared/utils/date'
|
||||||
|
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
|
||||||
import {
|
import {
|
||||||
buildClientFormTabKeys,
|
buildClientFormTabKeys,
|
||||||
isAddressValid,
|
isAddressValid,
|
||||||
@@ -440,6 +472,7 @@ import {
|
|||||||
type RibFormDraft,
|
type RibFormDraft,
|
||||||
} from '~/modules/commercial/types/clientForm'
|
} from '~/modules/commercial/types/clientForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||||
|
|
||||||
// Masques de saisie (la normalisation finale reste serveur).
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
@@ -486,14 +519,26 @@ const headerTitle = computed(() => client.value?.companyName ?? t('commercial.cl
|
|||||||
const main = reactive<MainFormDraft>(mapMainDraft({} as ClientDetail))
|
const main = reactive<MainFormDraft>(mapMainDraft({} as ClientDetail))
|
||||||
const information = reactive<InformationFormDraft>(mapInformationDraft({} as ClientDetail))
|
const information = reactive<InformationFormDraft>(mapInformationDraft({} as ClientDetail))
|
||||||
const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as ClientDetail))
|
const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as ClientDetail))
|
||||||
|
|
||||||
|
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
|
||||||
|
const maxFoundedAt = todayIso()
|
||||||
|
|
||||||
|
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
|
||||||
|
// champ controle quand le plafonnement laisse le modelValue inchange.
|
||||||
|
const revenueAmountKey = ref(0)
|
||||||
|
|
||||||
|
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
|
||||||
|
function onRevenueAmountInput(value: string | null): void {
|
||||||
|
const clamped = clampRevenueAmount(value)
|
||||||
|
information.revenueAmount = clamped ?? null
|
||||||
|
if (clamped !== value) {
|
||||||
|
revenueAmountKey.value += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
const contacts = ref<ContactFormDraft[]>([])
|
const contacts = ref<ContactFormDraft[]>([])
|
||||||
const addresses = ref<AddressFormDraft[]>([])
|
const addresses = ref<AddressFormDraft[]>([])
|
||||||
const ribs = ref<RibFormDraft[]>([])
|
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 mainSubmitting = ref(false)
|
||||||
const tabSubmitting = ref(false)
|
const tabSubmitting = ref(false)
|
||||||
@@ -667,6 +712,11 @@ function showError(e: unknown, opts: { duplicateCompany?: boolean } = {}): void
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toast de succès après suppression serveur confirmée d'un bloc (contact / adresse / RIB). */
|
||||||
|
function notifyRemovalSuccess(): void {
|
||||||
|
toast.success({ title: t('success.title'), message: t('success.deleted') })
|
||||||
|
}
|
||||||
|
|
||||||
// ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
|
// ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
|
||||||
// Etat d'erreurs factorise avec l'ecran de creation (cf. useClientFormErrors) :
|
// Etat d'erreurs factorise avec l'ecran de creation (cf. useClientFormErrors) :
|
||||||
// un `useFormErrors` par groupe scalaire + un tableau d'erreurs par ligne pour
|
// un `useFormErrors` par groupe scalaire + un tableau d'erreurs par ligne pour
|
||||||
@@ -754,32 +804,32 @@ function addContact(): void {
|
|||||||
if (canAddContact.value) contacts.value.push(emptyContact())
|
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 {
|
function askRemoveContact(index: number): void {
|
||||||
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
|
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => removeCollectionRow({
|
||||||
const removed = contacts.value[index]
|
rows: contacts.value,
|
||||||
if (removed?.id != null) removedContactIds.value.push(removed.id)
|
errors: contactErrors.value,
|
||||||
contacts.value.splice(index, 1)
|
index,
|
||||||
contactErrors.value.splice(index, 1)
|
endpoint: '/client_contacts',
|
||||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (contacts.value.length === 0) contacts.value.push(emptyContact())
|
makeEmpty: emptyContact,
|
||||||
})
|
onError: showError,
|
||||||
|
onSuccess: notifyRemovalSuccess,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valide l'onglet Contact : DELETE des contacts retires (existants), puis
|
* Valide l'onglet Contact : POST/PATCH des blocs restants sur la sous-ressource.
|
||||||
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
|
* Strictement scope a la collection contacts (endpoints client_contact dedies). La
|
||||||
* collection contacts (endpoints client_contact dedies).
|
* suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
|
||||||
*/
|
*/
|
||||||
async function submitContacts(): Promise<void> {
|
async function submitContacts(): Promise<void> {
|
||||||
if (businessReadonly.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
contactErrors.value = []
|
contactErrors.value = []
|
||||||
try {
|
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
|
// 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
|
// 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
|
// les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom
|
||||||
@@ -836,14 +886,16 @@ function addAddress(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function askRemoveAddress(index: number): void {
|
function askRemoveAddress(index: number): void {
|
||||||
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
|
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => removeCollectionRow({
|
||||||
const removed = addresses.value[index]
|
rows: addresses.value,
|
||||||
if (removed?.id != null) removedAddressIds.value.push(removed.id)
|
errors: addressErrors.value,
|
||||||
addresses.value.splice(index, 1)
|
index,
|
||||||
addressErrors.value.splice(index, 1)
|
endpoint: '/client_addresses',
|
||||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
|
makeEmpty: emptyAddress,
|
||||||
})
|
onError: showError,
|
||||||
|
onSuccess: notifyRemovalSuccess,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAddressDegraded(): void {
|
function onAddressDegraded(): void {
|
||||||
@@ -855,17 +907,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> {
|
async function submitAddresses(): Promise<void> {
|
||||||
if (businessReadonly.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
addressErrors.value = []
|
addressErrors.value = []
|
||||||
try {
|
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).
|
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
|
||||||
const hasError = await submitRows(
|
const hasError = await submitRows(
|
||||||
addresses.value,
|
addresses.value,
|
||||||
@@ -937,29 +984,33 @@ function addRib(): void {
|
|||||||
if (canAddRib.value) ribs.value.push(emptyRib())
|
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 {
|
function askRemoveRib(index: number): void {
|
||||||
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
|
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => removeCollectionRow({
|
||||||
const removed = ribs.value[index]
|
rows: ribs.value,
|
||||||
if (removed?.id != null) removedRibIds.value.push(removed.id)
|
errors: ribErrors.value,
|
||||||
ribs.value.splice(index, 1)
|
index,
|
||||||
ribErrors.value.splice(index, 1)
|
endpoint: '/client_ribs',
|
||||||
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
makeEmpty: emptyRib,
|
||||||
})
|
onError: showError,
|
||||||
|
onSuccess: notifyRemovalSuccess,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
||||||
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
|
* 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
|
* back). Les RIB crees d'abord : le back valide RG-1.13 (LCR => au moins un RIB
|
||||||
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
* 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
|
* 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
|
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||||
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-1.28 :
|
||||||
* (corbeille d'un bloc, toujours sous LCR), plus l'auto-suppression au changement
|
* sinon 403 sur tout le payload).
|
||||||
* de type de reglement. Aucun champ main/information dans le payload (mode strict
|
|
||||||
* RG-1.28 : sinon 403 sur tout le payload).
|
|
||||||
*/
|
*/
|
||||||
async function submitAccounting(): Promise<void> {
|
async function submitAccounting(): Promise<void> {
|
||||||
if (accountingReadonly.value || tabSubmitting.value) return
|
if (accountingReadonly.value || tabSubmitting.value) return
|
||||||
@@ -1013,14 +1064,6 @@ async function submitAccounting(): Promise<void> {
|
|||||||
return
|
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') })
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
icon="mdi:arrow-left-bold"
|
icon="mdi:arrow-left-bold"
|
||||||
icon-size="24"
|
icon-size="24"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
:title="t('commercial.clients.consultation.back')"
|
||||||
v-bind="{ ariaLabel: t('commercial.clients.consultation.back') }"
|
v-bind="{ ariaLabel: t('commercial.clients.consultation.back') }"
|
||||||
@click="goBack"
|
@click="goBack"
|
||||||
/>
|
/>
|
||||||
@@ -23,7 +24,7 @@
|
|||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
v-if="showArchive"
|
v-if="showArchive"
|
||||||
variant="secondary"
|
variant="danger"
|
||||||
icon-name="mdi:archive-arrow-down-outline"
|
icon-name="mdi:archive-arrow-down-outline"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
:label="t('commercial.clients.action.archive')"
|
:label="t('commercial.clients.action.archive')"
|
||||||
@@ -48,43 +49,51 @@
|
|||||||
<!-- ── Formulaire principal (lecture seule) ──────────────────────── -->
|
<!-- ── Formulaire principal (lecture seule) ──────────────────────── -->
|
||||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-if="isFilled(client.companyName)"
|
||||||
:model-value="client.companyName"
|
:model-value="client.companyName"
|
||||||
:label="t('commercial.clients.form.main.companyName')"
|
:label="t('commercial.clients.form.main.companyName')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox
|
||||||
|
v-if="isFilled(categoryIris)"
|
||||||
:model-value="categoryIris"
|
:model-value="categoryIris"
|
||||||
:options="mainCategoryOptions"
|
:options="mainCategoryOptions"
|
||||||
:label="t('commercial.clients.form.main.categories')"
|
:label="t('commercial.clients.form.main.categories')"
|
||||||
:display-tag="true"
|
:display-tag="true"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<!-- Relation toujours affichee (vide = « Aucun »), comme en edition. -->
|
<!-- Relation : masquee en consultation si aucune (ERP-193) ; en edition
|
||||||
|
elle reste toujours visible (vide = « Aucun »). -->
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
|
v-if="isFilled(relation.type)"
|
||||||
:model-value="relation.type"
|
:model-value="relation.type"
|
||||||
:options="relationOptions"
|
:options="relationOptions"
|
||||||
:label="t('commercial.clients.form.main.relation')"
|
:label="t('commercial.clients.form.main.relation')"
|
||||||
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<!-- Nom du distributeur/courtier : conditionnel (libelle type-dependant,
|
<!-- Nom du distributeur/courtier : conditionnel (libelle type-dependant,
|
||||||
aucune valeur sans relation — meme comportement qu'en edition). -->
|
aucune valeur sans relation — meme comportement qu'en edition). -->
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-if="relation.type"
|
v-if="relation.type && isFilled(relation.name)"
|
||||||
:model-value="relation.name"
|
:model-value="relation.name"
|
||||||
:label="relation.type === 'distributeur' ? t('commercial.clients.form.main.distributorName') : t('commercial.clients.form.main.brokerName')"
|
:label="relation.type === 'distributeur' ? t('commercial.clients.form.main.distributorName') : t('commercial.clients.form.main.brokerName')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
|
<!-- Service de triage : case a cocher masquee si non cochee (ERP-193). -->
|
||||||
<MalioCheckbox
|
<MalioCheckbox
|
||||||
|
v-if="isFilled(client.triageService === true)"
|
||||||
:model-value="client.triageService === true"
|
:model-value="client.triageService === true"
|
||||||
:label="t('commercial.clients.form.main.triageService')"
|
:label="t('commercial.clients.form.main.triageService')"
|
||||||
group-class="self-center"
|
group-class="self-center"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Onglets (navigation libre, tout en lecture seule) ─────────── -->
|
<!-- ── Onglets (navigation libre, tout en lecture seule) ─────────── -->
|
||||||
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
<!-- ERP-193 : on n'affiche la barre que s'il reste au moins un onglet
|
||||||
|
non vide (sinon seul le bloc principal est visible). -->
|
||||||
|
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||||
<!-- Onglet Information -->
|
<!-- Onglet Information -->
|
||||||
<template #information>
|
<template #information>
|
||||||
<div class="mt-12 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)]">
|
<div class="mt-12 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)]">
|
||||||
@@ -92,42 +101,49 @@
|
|||||||
sur les inputs (champ 40px centre dans un h-12 -> ~4px de
|
sur les inputs (champ 40px centre dans un h-12 -> ~4px de
|
||||||
coussin de chaque cote). -->
|
coussin de chaque cote). -->
|
||||||
<MalioInputTextArea
|
<MalioInputTextArea
|
||||||
|
v-if="isFilled(information.description)"
|
||||||
:model-value="information.description"
|
:model-value="information.description"
|
||||||
:label="t('commercial.clients.form.information.description')"
|
:label="t('commercial.clients.form.information.description')"
|
||||||
resize="none"
|
resize="none"
|
||||||
group-class="row-span-2 pt-1 pb-1"
|
group-class="row-span-2 pt-1 pb-1"
|
||||||
text-input="h-full text-lg"
|
text-input="h-full text-lg"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-if="isFilled(information.competitors)"
|
||||||
:model-value="information.competitors"
|
:model-value="information.competitors"
|
||||||
:label="t('commercial.clients.form.information.competitors')"
|
:label="t('commercial.clients.form.information.competitors')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioDate
|
<MalioDate
|
||||||
|
v-if="isFilled(information.foundedAt)"
|
||||||
:model-value="information.foundedAt"
|
:model-value="information.foundedAt"
|
||||||
:label="t('commercial.clients.form.information.foundedAt')"
|
:label="t('commercial.clients.form.information.foundedAt')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-if="isFilled(information.employeesCount)"
|
||||||
:model-value="information.employeesCount"
|
:model-value="information.employeesCount"
|
||||||
:label="t('commercial.clients.form.information.employeesCount')"
|
:label="t('commercial.clients.form.information.employeesCount')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioInputAmount
|
<MalioInputAmount
|
||||||
|
v-if="isFilled(information.revenueAmount)"
|
||||||
:model-value="information.revenueAmount"
|
:model-value="information.revenueAmount"
|
||||||
:label="t('commercial.clients.form.information.revenueAmount')"
|
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-if="isFilled(information.directorName)"
|
||||||
:model-value="information.directorName"
|
:model-value="information.directorName"
|
||||||
:label="t('commercial.clients.form.information.directorName')"
|
:label="t('commercial.clients.form.information.directorName')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioInputAmount
|
<MalioInputAmount
|
||||||
|
v-if="isFilled(information.profitAmount)"
|
||||||
:model-value="information.profitAmount"
|
:model-value="information.profitAmount"
|
||||||
:label="t('commercial.clients.form.information.profitAmount')"
|
:label="t('commercial.clients.form.information.profitAmount')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -140,7 +156,9 @@
|
|||||||
:key="contact.id ?? index"
|
:key="contact.id ?? index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||||
readonly
|
:last="index === contacts.length - 1"
|
||||||
|
disabled
|
||||||
|
hide-empty
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -153,11 +171,13 @@
|
|||||||
:key="view.draft.id ?? index"
|
:key="view.draft.id ?? index"
|
||||||
:model-value="view.draft"
|
:model-value="view.draft"
|
||||||
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
|
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
|
||||||
|
:last="index === addressViews.length - 1"
|
||||||
:category-options="view.categoryOptions"
|
:category-options="view.categoryOptions"
|
||||||
:site-options="allSiteOptions"
|
:site-options="allSiteOptions"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
readonly
|
disabled
|
||||||
|
hide-empty
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -165,44 +185,52 @@
|
|||||||
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
|
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
|
||||||
<template v-if="canAccountingView" #accounting>
|
<template v-if="canAccountingView" #accounting>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<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)]">
|
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
|
||||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
<div class="pb-[20px]" :class="{ 'border-b border-black': ribs.length > 0 }">
|
||||||
|
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.infoTitle') }}</h2>
|
||||||
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-if="isFilled(accounting.siren)"
|
||||||
:model-value="accounting.siren"
|
:model-value="accounting.siren"
|
||||||
:label="t('commercial.clients.form.accounting.siren')"
|
:label="t('commercial.clients.form.accounting.siren')"
|
||||||
:mask="SIREN_MASK"
|
:mask="SIREN_MASK"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-if="isFilled(accounting.accountNumber)"
|
||||||
:model-value="accounting.accountNumber"
|
:model-value="accounting.accountNumber"
|
||||||
:label="t('commercial.clients.form.accounting.accountNumber')"
|
:label="t('commercial.clients.form.accounting.accountNumber')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
|
v-if="isFilled(accounting.tvaModeIri)"
|
||||||
:model-value="accounting.tvaModeIri"
|
:model-value="accounting.tvaModeIri"
|
||||||
:options="tvaModeOptions"
|
:options="tvaModeOptions"
|
||||||
:label="t('commercial.clients.form.accounting.tvaMode')"
|
:label="t('commercial.clients.form.accounting.tvaMode')"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-if="isFilled(accounting.nTva)"
|
||||||
:model-value="accounting.nTva"
|
:model-value="accounting.nTva"
|
||||||
:label="t('commercial.clients.form.accounting.nTva')"
|
:label="t('commercial.clients.form.accounting.nTva')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
|
v-if="isFilled(accounting.paymentDelayIri)"
|
||||||
:model-value="accounting.paymentDelayIri"
|
:model-value="accounting.paymentDelayIri"
|
||||||
:options="paymentDelayOptions"
|
:options="paymentDelayOptions"
|
||||||
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
|
v-if="isFilled(accounting.paymentTypeIri)"
|
||||||
:model-value="accounting.paymentTypeIri"
|
:model-value="accounting.paymentTypeIri"
|
||||||
:options="paymentTypeOptions"
|
:options="paymentTypeOptions"
|
||||||
:label="t('commercial.clients.form.accounting.paymentType')"
|
:label="t('commercial.clients.form.accounting.paymentType')"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-if="accounting.bankIri"
|
v-if="accounting.bankIri"
|
||||||
@@ -210,43 +238,46 @@
|
|||||||
:options="bankOptions"
|
:options="bankOptions"
|
||||||
:label="t('commercial.clients.form.accounting.bank')"
|
:label="t('commercial.clients.form.accounting.bank')"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Blocs RIB (0..n), lecture seule. -->
|
<!-- Blocs RIB (0..n), lecture seule.
|
||||||
|
Titre « RIB N », filet de separation sauf sous le dernier. -->
|
||||||
<div
|
<div
|
||||||
v-for="(rib, index) in ribs"
|
v-for="(rib, index) in ribs"
|
||||||
:key="rib.id ?? index"
|
:key="rib.id ?? index"
|
||||||
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="pb-[20px]"
|
||||||
|
:class="{ 'border-b border-black': index !== ribs.length - 1 }"
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
|
||||||
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-if="isFilled(rib.label)"
|
||||||
:model-value="rib.label"
|
:model-value="rib.label"
|
||||||
:label="t('commercial.clients.form.accounting.ribLabel')"
|
:label="t('commercial.clients.form.accounting.ribLabel')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-if="isFilled(rib.bic)"
|
||||||
:model-value="rib.bic"
|
:model-value="rib.bic"
|
||||||
:label="t('commercial.clients.form.accounting.ribBic')"
|
:label="t('commercial.clients.form.accounting.ribBic')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-if="isFilled(rib.iban)"
|
||||||
:model-value="rib.iban"
|
:model-value="rib.iban"
|
||||||
:label="t('commercial.clients.form.accounting.ribIban')"
|
:label="t('commercial.clients.form.accounting.ribIban')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- ERP-193 : les onglets « a venir » (Transport / Statistiques /
|
||||||
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
|
Rapports / Echanges) ne sont plus rendus en consultation
|
||||||
<template #transport><ComingSoonPlaceholder /></template>
|
(masquage des onglets vides) — slots supprimes. -->
|
||||||
<template #statistics><ComingSoonPlaceholder /></template>
|
|
||||||
<template #reports><ComingSoonPlaceholder /></template>
|
|
||||||
<template #exchanges><ComingSoonPlaceholder /></template>
|
|
||||||
</MalioTabList>
|
</MalioTabList>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -278,13 +309,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useClient } from '~/modules/commercial/composables/useClient'
|
import { useClient } from '~/modules/commercial/composables/useClient'
|
||||||
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
|
import { isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
|
||||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||||
import {
|
import {
|
||||||
canEditClient,
|
canEditClient,
|
||||||
categoryOptionsOf,
|
categoryOptionsOf,
|
||||||
|
clientConsultationVisibleTabs,
|
||||||
contactOptionsOf,
|
contactOptionsOf,
|
||||||
mapAccountingDraft,
|
mapAccountingDraft,
|
||||||
mapAddressView,
|
mapAddressView,
|
||||||
@@ -299,6 +331,7 @@ import {
|
|||||||
type SelectOption,
|
type SelectOption,
|
||||||
} from '~/modules/commercial/utils/forms/clientConsultation'
|
} from '~/modules/commercial/utils/forms/clientConsultation'
|
||||||
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
|
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
|
||||||
|
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||||
|
|
||||||
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
||||||
const SIREN_MASK = '#########'
|
const SIREN_MASK = '#########'
|
||||||
@@ -412,9 +445,11 @@ const paymentTypeOptions = computed(() => referentialOptionOf(client.value?.paym
|
|||||||
const bankOptions = computed(() => referentialOptionOf(client.value?.bank))
|
const bankOptions = computed(() => referentialOptionOf(client.value?.bank))
|
||||||
|
|
||||||
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
|
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
|
||||||
// 4 onglets actifs (Information, Contact, Adresse, + Comptabilite si droit) et
|
// ERP-193 (retour metier) : on masque les coquilles non implementees ET tout
|
||||||
// 4 coquilles (Transport, Statistiques, Rapports, Echanges).
|
// onglet de donnees vide. La liste depend donc du payload charge.
|
||||||
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
|
const visibleTabKeys = computed(() => clientConsultationVisibleTabs(client.value, {
|
||||||
|
canAccountingView: canAccountingView.value,
|
||||||
|
}))
|
||||||
|
|
||||||
const TAB_ICONS: Record<string, string> = {
|
const TAB_ICONS: Record<string, string> = {
|
||||||
information: 'mdi:account-outline',
|
information: 'mdi:account-outline',
|
||||||
@@ -427,14 +462,26 @@ const TAB_ICONS: Record<string, string> = {
|
|||||||
exchanges: 'mdi:account-group-outline',
|
exchanges: 'mdi:account-group-outline',
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs = computed(() => tabKeys.value.map(key => ({
|
const tabs = computed(() => visibleTabKeys.value.map(key => ({
|
||||||
key,
|
key,
|
||||||
label: t(`commercial.clients.tab.${key}`),
|
label: t(`commercial.clients.tab.${key}`),
|
||||||
icon: TAB_ICONS[key],
|
icon: TAB_ICONS[key],
|
||||||
})))
|
})))
|
||||||
|
|
||||||
// Onglet initial : repris de l'edition au retour (history.state), sinon Information.
|
// Onglet initial : vide tant que le client n'est pas charge. Des que la liste
|
||||||
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
|
// des onglets visibles est connue, on cale sur l'onglet repris de l'edition
|
||||||
|
// (history.state) s'il est encore visible, sinon le premier onglet visible.
|
||||||
|
// Un watcher recale aussi si l'onglet courant disparait (ex: changement de droit).
|
||||||
|
const activeTab = ref('')
|
||||||
|
watch(visibleTabKeys, (keys) => {
|
||||||
|
if (keys.length === 0) {
|
||||||
|
activeTab.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!keys.includes(activeTab.value)) {
|
||||||
|
activeTab.value = readHistoryTab(keys) ?? keys[0]
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||||
function goBack(): void {
|
function goBack(): void {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
@update:page="goToPage"
|
@update:page="goToPage"
|
||||||
@update:per-page="setItemsPerPage"
|
@update:per-page="setItemsPerPage"
|
||||||
>
|
>
|
||||||
<!-- Categories : codes stables separes par une virgule (ERP-78). -->
|
<!-- Categories : libelles (name) separes par une virgule (ERP-193). -->
|
||||||
<template #cell-categories="{ item }">
|
<template #cell-categories="{ item }">
|
||||||
{{ formatCategories(item) }}
|
{{ formatCategories(item) }}
|
||||||
</template>
|
</template>
|
||||||
@@ -209,10 +209,10 @@ const columns = [
|
|||||||
{ key: 'lastActivity', label: t('commercial.clients.column.lastActivity') },
|
{ key: 'lastActivity', label: t('commercial.clients.column.lastActivity') },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Codes des categories du client, separes par une virgule (ERP-78). */
|
/** Libelles (name) des categories du client, separes par une virgule (ERP-193). */
|
||||||
function formatCategories(item: Record<string, unknown>): string {
|
function formatCategories(item: Record<string, unknown>): string {
|
||||||
const categories = (item.categories as Client['categories']) ?? []
|
const categories = (item.categories as Client['categories']) ?? []
|
||||||
return categories.map(c => c.code).join(', ')
|
return categories.map(c => c.name).join(', ')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
icon="mdi:arrow-left-bold"
|
icon="mdi:arrow-left-bold"
|
||||||
icon-size="24"
|
icon-size="24"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
:title="t('commercial.clients.form.back')"
|
||||||
v-bind="{ ariaLabel: t('commercial.clients.form.back') }"
|
v-bind="{ ariaLabel: t('commercial.clients.form.back') }"
|
||||||
@click="goBack"
|
@click="goBack"
|
||||||
/>
|
/>
|
||||||
@@ -19,9 +20,10 @@
|
|||||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="main.companyName"
|
v-model="main.companyName"
|
||||||
|
:mask="FREE_TEXT_MASK"
|
||||||
:label="t('commercial.clients.form.main.companyName')"
|
:label="t('commercial.clients.form.main.companyName')"
|
||||||
:required="true"
|
:required="true"
|
||||||
:readonly="mainLocked"
|
:disabled="mainLocked"
|
||||||
:error="mainErrors.errors.companyName"
|
:error="mainErrors.errors.companyName"
|
||||||
/>
|
/>
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox
|
||||||
@@ -29,7 +31,7 @@
|
|||||||
:options="referentials.categories.value"
|
:options="referentials.categories.value"
|
||||||
:label="t('commercial.clients.form.main.categories')"
|
:label="t('commercial.clients.form.main.categories')"
|
||||||
:display-tag="true"
|
:display-tag="true"
|
||||||
:readonly="mainLocked"
|
:disabled="mainLocked"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="mainErrors.errors.categories"
|
:error="mainErrors.errors.categories"
|
||||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||||
@@ -40,7 +42,7 @@
|
|||||||
:options="relationOptions"
|
:options="relationOptions"
|
||||||
:label="t('commercial.clients.form.main.relation')"
|
:label="t('commercial.clients.form.main.relation')"
|
||||||
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
||||||
:readonly="mainLocked"
|
:disabled="mainLocked"
|
||||||
@update:model-value="onRelationChange"
|
@update:model-value="onRelationChange"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
@@ -48,7 +50,7 @@
|
|||||||
:model-value="main.brokerIri"
|
:model-value="main.brokerIri"
|
||||||
:options="referentials.brokers.value"
|
:options="referentials.brokers.value"
|
||||||
:label="t('commercial.clients.form.main.brokerName')"
|
:label="t('commercial.clients.form.main.brokerName')"
|
||||||
:readonly="mainLocked"
|
:disabled="mainLocked"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="mainErrors.errors.broker"
|
:error="mainErrors.errors.broker"
|
||||||
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
||||||
@@ -58,7 +60,7 @@
|
|||||||
:model-value="main.distributorIri"
|
:model-value="main.distributorIri"
|
||||||
:options="referentials.distributors.value"
|
:options="referentials.distributors.value"
|
||||||
:label="t('commercial.clients.form.main.distributorName')"
|
:label="t('commercial.clients.form.main.distributorName')"
|
||||||
:readonly="mainLocked"
|
:disabled="mainLocked"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="mainErrors.errors.distributor"
|
:error="mainErrors.errors.distributor"
|
||||||
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
|
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
|
||||||
@@ -68,7 +70,7 @@
|
|||||||
v-model="main.triageService"
|
v-model="main.triageService"
|
||||||
:label="t('commercial.clients.form.main.triageService')"
|
:label="t('commercial.clients.form.main.triageService')"
|
||||||
group-class="self-center"
|
group-class="self-center"
|
||||||
:readonly="mainLocked"
|
:disabled="mainLocked"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -96,20 +98,24 @@
|
|||||||
resize="none"
|
resize="none"
|
||||||
group-class="row-span-2 pt-1 pb-1"
|
group-class="row-span-2 pt-1 pb-1"
|
||||||
text-input="h-full text-lg"
|
text-input="h-full text-lg"
|
||||||
:readonly="isValidated('information')"
|
:disabled="isValidated('information')"
|
||||||
:error="informationErrors.errors.description"
|
:error="informationErrors.errors.description"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="information.competitors"
|
v-model="information.competitors"
|
||||||
|
:mask="FREE_TEXT_MASK"
|
||||||
:label="t('commercial.clients.form.information.competitors')"
|
:label="t('commercial.clients.form.information.competitors')"
|
||||||
:readonly="isValidated('information')"
|
:disabled="isValidated('information')"
|
||||||
:error="informationErrors.errors.competitors"
|
:error="informationErrors.errors.competitors"
|
||||||
/>
|
/>
|
||||||
|
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
|
||||||
|
le calendrier a aujourd'hui et invalide une saisie future. -->
|
||||||
<MalioDate
|
<MalioDate
|
||||||
v-model="information.foundedAt"
|
v-model="information.foundedAt"
|
||||||
:label="t('commercial.clients.form.information.foundedAt')"
|
:label="t('commercial.clients.form.information.foundedAt')"
|
||||||
:readonly="isValidated('information')"
|
:disabled="isValidated('information')"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
|
:max="maxFoundedAt"
|
||||||
:error="informationErrors.errors.foundedAt"
|
:error="informationErrors.errors.foundedAt"
|
||||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||||
/>
|
/>
|
||||||
@@ -117,37 +123,42 @@
|
|||||||
v-model="information.employeesCount"
|
v-model="information.employeesCount"
|
||||||
:label="t('commercial.clients.form.information.employeesCount')"
|
:label="t('commercial.clients.form.information.employeesCount')"
|
||||||
:mask="EMPLOYEES_MASK"
|
:mask="EMPLOYEES_MASK"
|
||||||
:readonly="isValidated('information')"
|
:disabled="isValidated('information')"
|
||||||
:error="informationErrors.errors.employeesCount"
|
:error="informationErrors.errors.employeesCount"
|
||||||
/>
|
/>
|
||||||
|
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
|
||||||
|
:key force le re-affichage quand on plafonne (modelValue inchange). -->
|
||||||
<MalioInputAmount
|
<MalioInputAmount
|
||||||
v-model="information.revenueAmount"
|
:key="revenueAmountKey"
|
||||||
|
:model-value="information.revenueAmount"
|
||||||
:label="t('commercial.clients.form.information.revenueAmount')"
|
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||||
:readonly="isValidated('information')"
|
:disabled="isValidated('information')"
|
||||||
:error="informationErrors.errors.revenueAmount"
|
:error="informationErrors.errors.revenueAmount"
|
||||||
|
@update:model-value="onRevenueAmountInput"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="information.directorName"
|
v-model="information.directorName"
|
||||||
|
:mask="PERSON_NAME_MASK"
|
||||||
:label="t('commercial.clients.form.information.directorName')"
|
:label="t('commercial.clients.form.information.directorName')"
|
||||||
:readonly="isValidated('information')"
|
:disabled="isValidated('information')"
|
||||||
:error="informationErrors.errors.directorName"
|
:error="informationErrors.errors.directorName"
|
||||||
/>
|
/>
|
||||||
<MalioInputAmount
|
<MalioInputAmount
|
||||||
v-model="information.profitAmount"
|
v-model="information.profitAmount"
|
||||||
:label="t('commercial.clients.form.information.profitAmount')"
|
:label="t('commercial.clients.form.information.profitAmount')"
|
||||||
:readonly="isValidated('information')"
|
:disabled="isValidated('information')"
|
||||||
:error="informationErrors.errors.profitAmount"
|
:error="informationErrors.errors.profitAmount"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
|
<!-- Masque tant que le client n'est pas cree : Information etant
|
||||||
<!-- Desactive tant que le client n'est pas cree (evite un PATCH
|
l'onglet actif par defaut, son Valider ne doit pas apparaitre a
|
||||||
avant le POST si clic trop tot, Information etant l'onglet
|
cote de celui du formulaire principal (ERP-193). Onglet facultatif :
|
||||||
actif par defaut). Onglet facultatif : un enregistrement a
|
un enregistrement a vide reste possible, c'est le back qui valide. -->
|
||||||
vide reste possible, c'est le back qui valide. -->
|
<div v-if="!isValidated('information') && clientId !== null" class="mt-12 flex justify-center">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:label="t('commercial.clients.form.submit')"
|
:label="t('commercial.clients.form.submit')"
|
||||||
:disabled="tabSubmitting || clientId === null"
|
:disabled="tabSubmitting"
|
||||||
@click="submitInformation"
|
@click="submitInformation"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,13 +167,18 @@
|
|||||||
<!-- Onglet Contact -->
|
<!-- Onglet Contact -->
|
||||||
<template #contact>
|
<template #contact>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<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
|
<ClientContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="isValidated('contact')"
|
:last="index === contacts.length - 1"
|
||||||
|
:disabled="isValidated('contact')"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@remove="askRemoveContact(index)"
|
@remove="askRemoveContact(index)"
|
||||||
@@ -194,12 +210,13 @@
|
|||||||
:key="index"
|
:key="index"
|
||||||
:model-value="address"
|
:model-value="address"
|
||||||
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
|
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
|
||||||
|
:last="index === addresses.length - 1"
|
||||||
:category-options="addressCategoryOptions"
|
:category-options="addressCategoryOptions"
|
||||||
:site-options="referentials.sites.value"
|
:site-options="referentials.sites.value"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="isValidated('address')"
|
:disabled="isValidated('address')"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@remove="askRemoveAddress(index)"
|
@remove="askRemoveAddress(index)"
|
||||||
@@ -227,20 +244,23 @@
|
|||||||
<!-- Onglet Comptabilite (present uniquement si accounting.view) -->
|
<!-- Onglet Comptabilite (present uniquement si accounting.view) -->
|
||||||
<template v-if="canAccountingView" #accounting>
|
<template v-if="canAccountingView" #accounting>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<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)]">
|
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
|
||||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
|
||||||
|
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.infoTitle') }}</h2>
|
||||||
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="accounting.siren"
|
v-model="accounting.siren"
|
||||||
:label="t('commercial.clients.form.accounting.siren')"
|
:label="t('commercial.clients.form.accounting.siren')"
|
||||||
:mask="SIREN_MASK"
|
:mask="SIREN_MASK"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.siren"
|
:error="accountingErrors.errors.siren"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="accounting.accountNumber"
|
v-model="accounting.accountNumber"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
:label="t('commercial.clients.form.accounting.accountNumber')"
|
:label="t('commercial.clients.form.accounting.accountNumber')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.accountNumber"
|
:error="accountingErrors.errors.accountNumber"
|
||||||
/>
|
/>
|
||||||
@@ -248,7 +268,7 @@
|
|||||||
:model-value="accounting.tvaModeIri"
|
:model-value="accounting.tvaModeIri"
|
||||||
:options="referentials.tvaModes.value"
|
:options="referentials.tvaModes.value"
|
||||||
:label="t('commercial.clients.form.accounting.tvaMode')"
|
:label="t('commercial.clients.form.accounting.tvaMode')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.tvaMode"
|
:error="accountingErrors.errors.tvaMode"
|
||||||
@@ -256,8 +276,9 @@
|
|||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="accounting.nTva"
|
v-model="accounting.nTva"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
:label="t('commercial.clients.form.accounting.nTva')"
|
:label="t('commercial.clients.form.accounting.nTva')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.nTva"
|
:error="accountingErrors.errors.nTva"
|
||||||
/>
|
/>
|
||||||
@@ -265,7 +286,7 @@
|
|||||||
:model-value="accounting.paymentDelayIri"
|
:model-value="accounting.paymentDelayIri"
|
||||||
:options="referentials.paymentDelays.value"
|
:options="referentials.paymentDelays.value"
|
||||||
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.paymentDelay"
|
:error="accountingErrors.errors.paymentDelay"
|
||||||
@@ -275,7 +296,7 @@
|
|||||||
:model-value="accounting.paymentTypeIri"
|
:model-value="accounting.paymentTypeIri"
|
||||||
:options="referentials.paymentTypes.value"
|
:options="referentials.paymentTypes.value"
|
||||||
:label="t('commercial.clients.form.accounting.paymentType')"
|
:label="t('commercial.clients.form.accounting.paymentType')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.paymentType"
|
:error="accountingErrors.errors.paymentType"
|
||||||
@@ -286,7 +307,7 @@
|
|||||||
:model-value="accounting.bankIri"
|
:model-value="accounting.bankIri"
|
||||||
:options="referentials.banks.value"
|
:options="referentials.banks.value"
|
||||||
:label="t('commercial.clients.form.accounting.bank')"
|
:label="t('commercial.clients.form.accounting.bank')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.bank"
|
:error="accountingErrors.errors.bank"
|
||||||
@@ -295,40 +316,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). -->
|
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13).
|
||||||
|
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
|
||||||
<div
|
<div
|
||||||
v-for="(rib, index) in visibleRibs"
|
v-for="(rib, index) in visibleRibs"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="pb-[20px]"
|
||||||
|
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
|
||||||
>
|
>
|
||||||
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
|
||||||
<MalioButtonIcon
|
<div class="flex items-center justify-between">
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
|
||||||
icon="mdi:delete-outline"
|
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||||
variant="ghost"
|
<MalioButtonIcon
|
||||||
button-class="absolute top-3 right-3"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
|
icon="mdi:delete-outline"
|
||||||
@click="askRemoveRib(index)"
|
variant="ghost"
|
||||||
/>
|
button-class="p-0"
|
||||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
|
||||||
|
@click="askRemoveRib(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="rib.label"
|
v-model="rib.label"
|
||||||
:label="t('commercial.clients.form.accounting.ribLabel')"
|
:label="t('commercial.clients.form.accounting.ribLabel')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="isRibRequired"
|
:required="isRibRequired"
|
||||||
:error="ribErrors[index]?.label"
|
:error="ribErrors[index]?.label"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="rib.bic"
|
v-model="rib.bic"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
:label="t('commercial.clients.form.accounting.ribBic')"
|
:label="t('commercial.clients.form.accounting.ribBic')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="isRibRequired"
|
:required="isRibRequired"
|
||||||
:error="ribErrors[index]?.bic"
|
:error="ribErrors[index]?.bic"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="rib.iban"
|
v-model="rib.iban"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
:label="t('commercial.clients.form.accounting.ribIban')"
|
:label="t('commercial.clients.form.accounting.ribIban')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="isRibRequired"
|
:required="isRibRequired"
|
||||||
:error="ribErrors[index]?.iban"
|
:error="ribErrors[index]?.iban"
|
||||||
/>
|
/>
|
||||||
@@ -403,6 +432,9 @@ import {
|
|||||||
lastFillableTabKey,
|
lastFillableTabKey,
|
||||||
showsRelationAndTriageFields,
|
showsRelationAndTriageFields,
|
||||||
} from '~/modules/commercial/utils/forms/clientFormRules'
|
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||||
|
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
|
||||||
|
import { todayIso } from '~/shared/utils/date'
|
||||||
|
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
|
||||||
import {
|
import {
|
||||||
buildAddressPayload,
|
buildAddressPayload,
|
||||||
buildMainPayload,
|
buildMainPayload,
|
||||||
@@ -417,6 +449,7 @@ import {
|
|||||||
type RibFormDraft,
|
type RibFormDraft,
|
||||||
} from '~/modules/commercial/types/clientForm'
|
} from '~/modules/commercial/types/clientForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||||
|
|
||||||
// Masques de saisie (la normalisation finale reste serveur).
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
const SIREN_MASK = '#########'
|
const SIREN_MASK = '#########'
|
||||||
@@ -660,6 +693,22 @@ const information = reactive({
|
|||||||
directorName: null as string | null,
|
directorName: null as string | null,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
|
||||||
|
const maxFoundedAt = todayIso()
|
||||||
|
|
||||||
|
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
|
||||||
|
// champ controle quand le plafonnement laisse le modelValue inchange.
|
||||||
|
const revenueAmountKey = ref(0)
|
||||||
|
|
||||||
|
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
|
||||||
|
function onRevenueAmountInput(value: string | null): void {
|
||||||
|
const clamped = clampRevenueAmount(value)
|
||||||
|
information.revenueAmount = clamped ?? null
|
||||||
|
if (clamped !== value) {
|
||||||
|
revenueAmountKey.value += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
|
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
|
||||||
async function submitInformation(): Promise<void> {
|
async function submitInformation(): Promise<void> {
|
||||||
if (clientId.value === null || tabSubmitting.value) return
|
if (clientId.value === null || tabSubmitting.value) return
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
icon="mdi:arrow-left-bold"
|
icon="mdi:arrow-left-bold"
|
||||||
icon-size="24"
|
icon-size="24"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
:title="t('commercial.suppliers.edit.back')"
|
||||||
v-bind="{ ariaLabel: t('commercial.suppliers.edit.back') }"
|
v-bind="{ ariaLabel: t('commercial.suppliers.edit.back') }"
|
||||||
@click="goBack"
|
@click="goBack"
|
||||||
/>
|
/>
|
||||||
@@ -26,15 +27,16 @@
|
|||||||
v-model="main.companyName"
|
v-model="main.companyName"
|
||||||
:label="t('commercial.suppliers.form.main.companyName')"
|
:label="t('commercial.suppliers.form.main.companyName')"
|
||||||
:required="true"
|
:required="true"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:error="mainErrors.errors.companyName"
|
:error="mainErrors.errors.companyName"
|
||||||
|
:mask="FREE_TEXT_MASK"
|
||||||
/>
|
/>
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox
|
||||||
:model-value="main.categoryIris"
|
:model-value="main.categoryIris"
|
||||||
:options="mainCategoryOptions"
|
:options="mainCategoryOptions"
|
||||||
:label="t('commercial.suppliers.form.main.categories')"
|
:label="t('commercial.suppliers.form.main.categories')"
|
||||||
:display-tag="true"
|
:display-tag="true"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="mainErrors.errors.categories"
|
:error="mainErrors.errors.categories"
|
||||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||||
@@ -62,20 +64,24 @@
|
|||||||
resize="none"
|
resize="none"
|
||||||
group-class="row-span-2 pt-1 pb-1"
|
group-class="row-span-2 pt-1 pb-1"
|
||||||
text-input="h-full text-lg"
|
text-input="h-full text-lg"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:error="informationErrors.errors.description"
|
:error="informationErrors.errors.description"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="information.competitors"
|
v-model="information.competitors"
|
||||||
:label="t('commercial.suppliers.form.information.competitors')"
|
:label="t('commercial.suppliers.form.information.competitors')"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:error="informationErrors.errors.competitors"
|
:error="informationErrors.errors.competitors"
|
||||||
|
:mask="FREE_TEXT_MASK"
|
||||||
/>
|
/>
|
||||||
|
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
|
||||||
|
le calendrier a aujourd'hui et invalide une saisie future. -->
|
||||||
<MalioDate
|
<MalioDate
|
||||||
v-model="information.foundedAt"
|
v-model="information.foundedAt"
|
||||||
:label="t('commercial.suppliers.form.information.foundedAt')"
|
:label="t('commercial.suppliers.form.information.foundedAt')"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
|
:max="maxFoundedAt"
|
||||||
:error="informationErrors.errors.foundedAt"
|
:error="informationErrors.errors.foundedAt"
|
||||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||||
/>
|
/>
|
||||||
@@ -83,25 +89,30 @@
|
|||||||
v-model="information.employeesCount"
|
v-model="information.employeesCount"
|
||||||
:label="t('commercial.suppliers.form.information.employeesCount')"
|
:label="t('commercial.suppliers.form.information.employeesCount')"
|
||||||
:mask="EMPLOYEES_MASK"
|
:mask="EMPLOYEES_MASK"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:error="informationErrors.errors.employeesCount"
|
:error="informationErrors.errors.employeesCount"
|
||||||
/>
|
/>
|
||||||
|
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
|
||||||
|
:key force le re-affichage quand on plafonne (modelValue inchange). -->
|
||||||
<MalioInputAmount
|
<MalioInputAmount
|
||||||
v-model="information.revenueAmount"
|
:key="revenueAmountKey"
|
||||||
|
:model-value="information.revenueAmount"
|
||||||
:label="t('commercial.suppliers.form.information.revenueAmount')"
|
:label="t('commercial.suppliers.form.information.revenueAmount')"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:error="informationErrors.errors.revenueAmount"
|
:error="informationErrors.errors.revenueAmount"
|
||||||
|
@update:model-value="onRevenueAmountInput"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="information.directorName"
|
v-model="information.directorName"
|
||||||
:label="t('commercial.suppliers.form.information.directorName')"
|
:label="t('commercial.suppliers.form.information.directorName')"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:error="informationErrors.errors.directorName"
|
:error="informationErrors.errors.directorName"
|
||||||
|
:mask="PERSON_NAME_MASK"
|
||||||
/>
|
/>
|
||||||
<MalioInputAmount
|
<MalioInputAmount
|
||||||
v-model="information.profitAmount"
|
v-model="information.profitAmount"
|
||||||
:label="t('commercial.suppliers.form.information.profitAmount')"
|
:label="t('commercial.suppliers.form.information.profitAmount')"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:error="informationErrors.errors.profitAmount"
|
:error="informationErrors.errors.profitAmount"
|
||||||
/>
|
/>
|
||||||
<!-- Volume previsionnel : specifique fournisseur (entier). -->
|
<!-- Volume previsionnel : specifique fournisseur (entier). -->
|
||||||
@@ -109,7 +120,7 @@
|
|||||||
v-model="information.volumeForecast"
|
v-model="information.volumeForecast"
|
||||||
:label="t('commercial.suppliers.form.information.volumeForecast')"
|
:label="t('commercial.suppliers.form.information.volumeForecast')"
|
||||||
:mask="VOLUME_FORECAST_MASK"
|
:mask="VOLUME_FORECAST_MASK"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:error="informationErrors.errors.volumeForecast"
|
:error="informationErrors.errors.volumeForecast"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,13 +137,18 @@
|
|||||||
<!-- Onglet Contacts -->
|
<!-- Onglet Contacts -->
|
||||||
<template #contacts>
|
<template #contacts>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<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
|
<SupplierContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="contact.id ?? `new-${index}`"
|
:key="contact.id ?? `new-${index}`"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||||
:removable="contacts.length > 1"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="businessReadonly"
|
:last="index === contacts.length - 1"
|
||||||
|
:disabled="businessReadonly"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@remove="askRemoveContact(index)"
|
@remove="askRemoveContact(index)"
|
||||||
@@ -164,12 +180,13 @@
|
|||||||
:key="address.id ?? `new-${index}`"
|
:key="address.id ?? `new-${index}`"
|
||||||
:model-value="address"
|
:model-value="address"
|
||||||
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
|
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
|
||||||
|
:last="index === addresses.length - 1"
|
||||||
:category-options="mainCategoryOptions"
|
:category-options="mainCategoryOptions"
|
||||||
:site-options="siteOptions"
|
:site-options="siteOptions"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="addresses.length > 1"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@remove="askRemoveAddress(index)"
|
@remove="askRemoveAddress(index)"
|
||||||
@@ -198,28 +215,31 @@
|
|||||||
editable uniquement si accounting.manage). -->
|
editable uniquement si accounting.manage). -->
|
||||||
<template v-if="canAccountingView" #accounting>
|
<template v-if="canAccountingView" #accounting>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<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)]">
|
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
|
||||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
|
||||||
|
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.infoTitle') }}</h2>
|
||||||
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="accounting.siren"
|
v-model="accounting.siren"
|
||||||
:label="t('commercial.suppliers.form.accounting.siren')"
|
:label="t('commercial.suppliers.form.accounting.siren')"
|
||||||
:mask="SIREN_MASK"
|
:mask="SIREN_MASK"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.siren"
|
:error="accountingErrors.errors.siren"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="accounting.accountNumber"
|
v-model="accounting.accountNumber"
|
||||||
:label="t('commercial.suppliers.form.accounting.accountNumber')"
|
:label="t('commercial.suppliers.form.accounting.accountNumber')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.accountNumber"
|
:error="accountingErrors.errors.accountNumber"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
:model-value="accounting.tvaModeIri"
|
:model-value="accounting.tvaModeIri"
|
||||||
:options="tvaModeOptions"
|
:options="tvaModeOptions"
|
||||||
:label="t('commercial.suppliers.form.accounting.tvaMode')"
|
:label="t('commercial.suppliers.form.accounting.tvaMode')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.tvaMode"
|
:error="accountingErrors.errors.tvaMode"
|
||||||
@@ -228,15 +248,16 @@
|
|||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="accounting.nTva"
|
v-model="accounting.nTva"
|
||||||
:label="t('commercial.suppliers.form.accounting.nTva')"
|
:label="t('commercial.suppliers.form.accounting.nTva')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.nTva"
|
:error="accountingErrors.errors.nTva"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
:model-value="accounting.paymentDelayIri"
|
:model-value="accounting.paymentDelayIri"
|
||||||
:options="paymentDelayOptions"
|
:options="paymentDelayOptions"
|
||||||
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
|
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.paymentDelay"
|
:error="accountingErrors.errors.paymentDelay"
|
||||||
@@ -246,7 +267,7 @@
|
|||||||
:model-value="accounting.paymentTypeIri"
|
:model-value="accounting.paymentTypeIri"
|
||||||
:options="paymentTypeOptions"
|
:options="paymentTypeOptions"
|
||||||
:label="t('commercial.suppliers.form.accounting.paymentType')"
|
:label="t('commercial.suppliers.form.accounting.paymentType')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.paymentType"
|
:error="accountingErrors.errors.paymentType"
|
||||||
@@ -257,7 +278,7 @@
|
|||||||
:model-value="accounting.bankIri"
|
:model-value="accounting.bankIri"
|
||||||
:options="bankOptions"
|
:options="bankOptions"
|
||||||
:label="t('commercial.suppliers.form.accounting.bank')"
|
:label="t('commercial.suppliers.form.accounting.bank')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.bank"
|
:error="accountingErrors.errors.bank"
|
||||||
@@ -266,41 +287,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08). -->
|
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08).
|
||||||
|
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
|
||||||
<div
|
<div
|
||||||
v-for="(rib, index) in visibleRibs"
|
v-for="(rib, index) in visibleRibs"
|
||||||
:key="rib.id ?? `new-${index}`"
|
:key="rib.id ?? `new-${index}`"
|
||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="pb-[20px]"
|
||||||
|
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
|
||||||
>
|
>
|
||||||
<MalioButtonIcon
|
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
<div class="flex items-center justify-between">
|
||||||
icon="mdi:delete-outline"
|
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
|
||||||
variant="ghost"
|
<MalioButtonIcon
|
||||||
button-class="absolute top-3 right-3"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
|
icon="mdi:delete-outline"
|
||||||
@click="askRemoveRib(index)"
|
variant="ghost"
|
||||||
/>
|
button-class="p-0"
|
||||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
|
||||||
|
@click="askRemoveRib(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="rib.label"
|
v-model="rib.label"
|
||||||
:label="t('commercial.suppliers.form.accounting.ribLabel')"
|
:label="t('commercial.suppliers.form.accounting.ribLabel')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="isRibRequired"
|
:required="isRibRequired"
|
||||||
:error="ribErrors[index]?.label"
|
:error="ribErrors[index]?.label"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="rib.bic"
|
v-model="rib.bic"
|
||||||
:label="t('commercial.suppliers.form.accounting.ribBic')"
|
:label="t('commercial.suppliers.form.accounting.ribBic')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="isRibRequired"
|
:required="isRibRequired"
|
||||||
:error="ribErrors[index]?.bic"
|
:error="ribErrors[index]?.bic"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="rib.iban"
|
v-model="rib.iban"
|
||||||
:label="t('commercial.suppliers.form.accounting.ribIban')"
|
:label="t('commercial.suppliers.form.accounting.ribIban')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="isRibRequired"
|
:required="isRibRequired"
|
||||||
:error="ribErrors[index]?.iban"
|
:error="ribErrors[index]?.iban"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -388,6 +417,8 @@ import {
|
|||||||
type MainFormDraft,
|
type MainFormDraft,
|
||||||
type SupplierEditAbilities,
|
type SupplierEditAbilities,
|
||||||
} from '~/modules/commercial/utils/forms/supplierEdit'
|
} from '~/modules/commercial/utils/forms/supplierEdit'
|
||||||
|
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
|
||||||
|
import { todayIso } from '~/shared/utils/date'
|
||||||
import {
|
import {
|
||||||
buildSupplierFormTabKeys,
|
buildSupplierFormTabKeys,
|
||||||
isAddressValid,
|
isAddressValid,
|
||||||
@@ -407,6 +438,8 @@ import {
|
|||||||
type SupplierRibFormDraft,
|
type SupplierRibFormDraft,
|
||||||
} from '~/modules/commercial/types/supplierForm'
|
} from '~/modules/commercial/types/supplierForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||||
|
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
|
||||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||||
|
|
||||||
// Masques de saisie (la normalisation finale reste serveur).
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
@@ -452,14 +485,26 @@ const headerTitle = computed(() => supplier.value?.companyName ?? t('commercial.
|
|||||||
const main = reactive<MainFormDraft>(mapMainDraft({} as SupplierDetail))
|
const main = reactive<MainFormDraft>(mapMainDraft({} as SupplierDetail))
|
||||||
const information = reactive<InformationFormDraft>(mapInformationDraft({} as SupplierDetail))
|
const information = reactive<InformationFormDraft>(mapInformationDraft({} as SupplierDetail))
|
||||||
const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as SupplierDetail))
|
const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as SupplierDetail))
|
||||||
|
|
||||||
|
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
|
||||||
|
const maxFoundedAt = todayIso()
|
||||||
|
|
||||||
|
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
|
||||||
|
// champ controle quand le plafonnement laisse le modelValue inchange.
|
||||||
|
const revenueAmountKey = ref(0)
|
||||||
|
|
||||||
|
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
|
||||||
|
function onRevenueAmountInput(value: string | null): void {
|
||||||
|
const clamped = clampRevenueAmount(value)
|
||||||
|
information.revenueAmount = clamped ?? null
|
||||||
|
if (clamped !== value) {
|
||||||
|
revenueAmountKey.value += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
const contacts = ref<SupplierContactFormDraft[]>([])
|
const contacts = ref<SupplierContactFormDraft[]>([])
|
||||||
const addresses = ref<SupplierAddressFormDraft[]>([])
|
const addresses = ref<SupplierAddressFormDraft[]>([])
|
||||||
const ribs = ref<SupplierRibFormDraft[]>([])
|
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 mainSubmitting = ref(false)
|
||||||
const tabSubmitting = ref(false)
|
const tabSubmitting = ref(false)
|
||||||
@@ -582,6 +627,11 @@ function showError(e: unknown): void {
|
|||||||
toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(e) })
|
toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(e) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toast de succès après suppression serveur confirmée d'un bloc (contact / adresse / RIB). */
|
||||||
|
function notifyRemovalSuccess(): void {
|
||||||
|
toast.success({ title: t('success.title'), message: t('success.deleted') })
|
||||||
|
}
|
||||||
|
|
||||||
// ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
|
// ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
|
||||||
const {
|
const {
|
||||||
mainErrors,
|
mainErrors,
|
||||||
@@ -653,32 +703,32 @@ function addContact(): void {
|
|||||||
if (canAddContact.value) contacts.value.push(emptyContact())
|
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 {
|
function askRemoveContact(index: number): void {
|
||||||
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => {
|
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => removeCollectionRow({
|
||||||
const removed = contacts.value[index]
|
rows: contacts.value,
|
||||||
if (removed?.id != null) removedContactIds.value.push(removed.id)
|
errors: contactErrors.value,
|
||||||
contacts.value.splice(index, 1)
|
index,
|
||||||
contactErrors.value.splice(index, 1)
|
endpoint: '/supplier_contacts',
|
||||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (contacts.value.length === 0) contacts.value.push(emptyContact())
|
makeEmpty: emptyContact,
|
||||||
})
|
onError: showError,
|
||||||
|
onSuccess: notifyRemovalSuccess,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valide l'onglet Contacts : DELETE des contacts retires (existants), puis
|
* Valide l'onglet Contacts : POST/PATCH des blocs restants sur la sous-ressource.
|
||||||
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
|
* Strictement scope a la collection contacts (endpoints supplier_contact dedies).
|
||||||
* collection contacts (endpoints supplier_contact dedies).
|
* La suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
|
||||||
*/
|
*/
|
||||||
async function submitContacts(): Promise<void> {
|
async function submitContacts(): Promise<void> {
|
||||||
if (businessReadonly.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
contactErrors.value = []
|
contactErrors.value = []
|
||||||
try {
|
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
|
// 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).
|
// 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))
|
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
|
||||||
@@ -726,14 +776,16 @@ function addAddress(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function askRemoveAddress(index: number): void {
|
function askRemoveAddress(index: number): void {
|
||||||
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => {
|
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => removeCollectionRow({
|
||||||
const removed = addresses.value[index]
|
rows: addresses.value,
|
||||||
if (removed?.id != null) removedAddressIds.value.push(removed.id)
|
errors: addressErrors.value,
|
||||||
addresses.value.splice(index, 1)
|
index,
|
||||||
addressErrors.value.splice(index, 1)
|
endpoint: '/supplier_addresses',
|
||||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
|
makeEmpty: emptyAddress,
|
||||||
})
|
onError: showError,
|
||||||
|
onSuccess: notifyRemovalSuccess,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAddressDegraded(): void {
|
function onAddressDegraded(): void {
|
||||||
@@ -745,17 +797,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> {
|
async function submitAddresses(): Promise<void> {
|
||||||
if (businessReadonly.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
addressErrors.value = []
|
addressErrors.value = []
|
||||||
try {
|
try {
|
||||||
for (const id of removedAddressIds.value) {
|
|
||||||
await api.delete(`/supplier_addresses/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
removedAddressIds.value = []
|
|
||||||
|
|
||||||
const hasError = await submitRows(
|
const hasError = await submitRows(
|
||||||
addresses.value,
|
addresses.value,
|
||||||
addressErrors,
|
addressErrors,
|
||||||
@@ -826,15 +873,19 @@ function addRib(): void {
|
|||||||
if (canAddRib.value) ribs.value.push(emptyRib())
|
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 {
|
function askRemoveRib(index: number): void {
|
||||||
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => {
|
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => removeCollectionRow({
|
||||||
const removed = ribs.value[index]
|
rows: ribs.value,
|
||||||
if (removed?.id != null) removedRibIds.value.push(removed.id)
|
errors: ribErrors.value,
|
||||||
ribs.value.splice(index, 1)
|
index,
|
||||||
ribErrors.value.splice(index, 1)
|
endpoint: '/supplier_ribs',
|
||||||
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
makeEmpty: emptyRib,
|
||||||
})
|
onError: showError,
|
||||||
|
onSuccess: notifyRemovalSuccess,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -843,11 +894,12 @@ function askRemoveRib(index: number): void {
|
|||||||
* cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
|
* 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.
|
* 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
|
* 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
|
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||||
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-2.16 :
|
||||||
* (corbeille d'un bloc, toujours sous LCR). Aucun champ main/information dans le
|
* sinon 403 sur tout le payload).
|
||||||
* payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
|
|
||||||
*/
|
*/
|
||||||
async function submitAccounting(): Promise<void> {
|
async function submitAccounting(): Promise<void> {
|
||||||
if (accountingReadonly.value || tabSubmitting.value) return
|
if (accountingReadonly.value || tabSubmitting.value) return
|
||||||
@@ -897,14 +949,6 @@ async function submitAccounting(): Promise<void> {
|
|||||||
return
|
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') })
|
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
icon="mdi:arrow-left-bold"
|
icon="mdi:arrow-left-bold"
|
||||||
icon-size="24"
|
icon-size="24"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
:title="t('commercial.suppliers.consultation.back')"
|
||||||
v-bind="{ ariaLabel: t('commercial.suppliers.consultation.back') }"
|
v-bind="{ ariaLabel: t('commercial.suppliers.consultation.back') }"
|
||||||
@click="goBack"
|
@click="goBack"
|
||||||
/>
|
/>
|
||||||
@@ -23,7 +24,7 @@
|
|||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
v-if="showArchive"
|
v-if="showArchive"
|
||||||
variant="secondary"
|
variant="danger"
|
||||||
icon-name="mdi:archive-arrow-down-outline"
|
icon-name="mdi:archive-arrow-down-outline"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
:label="t('commercial.suppliers.action.archive')"
|
:label="t('commercial.suppliers.action.archive')"
|
||||||
@@ -48,69 +49,82 @@
|
|||||||
<!-- ── Formulaire principal (lecture seule) ──────────────────────── -->
|
<!-- ── Formulaire principal (lecture seule) ──────────────────────── -->
|
||||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-if="isFilled(supplier.companyName)"
|
||||||
:model-value="supplier.companyName"
|
:model-value="supplier.companyName"
|
||||||
:label="t('commercial.suppliers.form.main.companyName')"
|
:label="t('commercial.suppliers.form.main.companyName')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox
|
||||||
|
v-if="isFilled(categoryIris)"
|
||||||
:model-value="categoryIris"
|
:model-value="categoryIris"
|
||||||
:options="mainCategoryOptions"
|
:options="mainCategoryOptions"
|
||||||
:label="t('commercial.suppliers.form.main.categories')"
|
:label="t('commercial.suppliers.form.main.categories')"
|
||||||
:display-tag="true"
|
:display-tag="true"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Onglets (navigation libre, tout en lecture seule) ─────────── -->
|
<!-- ── Onglets (navigation libre, tout en lecture seule) ─────────── -->
|
||||||
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
<!-- Masque la barre d'onglets (et sa bordure) quand aucun onglet n'est
|
||||||
|
visible : seul le formulaire principal est rempli (aligné sur le
|
||||||
|
client). -->
|
||||||
|
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||||
<!-- Onglet Information -->
|
<!-- Onglet Information -->
|
||||||
<template #information>
|
<template #information>
|
||||||
<div class="mt-12 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)]">
|
<div class="mt-12 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)]">
|
||||||
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
|
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
|
||||||
sur les inputs (champ 40px centre dans un h-12). -->
|
sur les inputs (champ 40px centre dans un h-12). -->
|
||||||
<MalioInputTextArea
|
<MalioInputTextArea
|
||||||
|
v-if="isFilled(information.description)"
|
||||||
:model-value="information.description"
|
:model-value="information.description"
|
||||||
:label="t('commercial.suppliers.form.information.description')"
|
:label="t('commercial.suppliers.form.information.description')"
|
||||||
resize="none"
|
resize="none"
|
||||||
group-class="row-span-2 pt-1 pb-1"
|
group-class="row-span-2 pt-1 pb-1"
|
||||||
text-input="h-full text-lg"
|
text-input="h-full text-lg"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-if="isFilled(information.competitors)"
|
||||||
:model-value="information.competitors"
|
:model-value="information.competitors"
|
||||||
:label="t('commercial.suppliers.form.information.competitors')"
|
:label="t('commercial.suppliers.form.information.competitors')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioDate
|
<MalioDate
|
||||||
|
v-if="isFilled(information.foundedAt)"
|
||||||
:model-value="information.foundedAt"
|
:model-value="information.foundedAt"
|
||||||
:label="t('commercial.suppliers.form.information.foundedAt')"
|
:label="t('commercial.suppliers.form.information.foundedAt')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-if="isFilled(information.employeesCount)"
|
||||||
:model-value="information.employeesCount"
|
:model-value="information.employeesCount"
|
||||||
:label="t('commercial.suppliers.form.information.employeesCount')"
|
:label="t('commercial.suppliers.form.information.employeesCount')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioInputAmount
|
<MalioInputAmount
|
||||||
|
v-if="isFilled(information.revenueAmount)"
|
||||||
:model-value="information.revenueAmount"
|
:model-value="information.revenueAmount"
|
||||||
:label="t('commercial.suppliers.form.information.revenueAmount')"
|
:label="t('commercial.suppliers.form.information.revenueAmount')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-if="isFilled(information.directorName)"
|
||||||
:model-value="information.directorName"
|
:model-value="information.directorName"
|
||||||
:label="t('commercial.suppliers.form.information.directorName')"
|
:label="t('commercial.suppliers.form.information.directorName')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioInputAmount
|
<MalioInputAmount
|
||||||
|
v-if="isFilled(information.profitAmount)"
|
||||||
:model-value="information.profitAmount"
|
:model-value="information.profitAmount"
|
||||||
:label="t('commercial.suppliers.form.information.profitAmount')"
|
:label="t('commercial.suppliers.form.information.profitAmount')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<!-- Volume previsionnel : specifique fournisseur (entier). -->
|
<!-- Volume previsionnel : specifique fournisseur (entier). -->
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-if="isFilled(information.volumeForecast)"
|
||||||
:model-value="information.volumeForecast"
|
:model-value="information.volumeForecast"
|
||||||
:label="t('commercial.suppliers.form.information.volumeForecast')"
|
:label="t('commercial.suppliers.form.information.volumeForecast')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -123,7 +137,9 @@
|
|||||||
:key="contact.id ?? index"
|
:key="contact.id ?? index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||||
readonly
|
:last="index === contacts.length - 1"
|
||||||
|
disabled
|
||||||
|
hide-empty
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -136,11 +152,13 @@
|
|||||||
:key="view.draft.id ?? index"
|
:key="view.draft.id ?? index"
|
||||||
:model-value="view.draft"
|
:model-value="view.draft"
|
||||||
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
|
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
|
||||||
|
:last="index === addressViews.length - 1"
|
||||||
:category-options="view.categoryOptions"
|
:category-options="view.categoryOptions"
|
||||||
:site-options="allSiteOptions"
|
:site-options="allSiteOptions"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
readonly
|
disabled
|
||||||
|
hide-empty
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -148,44 +166,52 @@
|
|||||||
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
|
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
|
||||||
<template v-if="canAccountingView" #accounting>
|
<template v-if="canAccountingView" #accounting>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<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)]">
|
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
|
||||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
<div class="pb-[20px]" :class="{ 'border-b border-black': ribs.length > 0 }">
|
||||||
|
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.infoTitle') }}</h2>
|
||||||
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-if="isFilled(accounting.siren)"
|
||||||
:model-value="accounting.siren"
|
:model-value="accounting.siren"
|
||||||
:label="t('commercial.suppliers.form.accounting.siren')"
|
:label="t('commercial.suppliers.form.accounting.siren')"
|
||||||
:mask="SIREN_MASK"
|
:mask="SIREN_MASK"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-if="isFilled(accounting.accountNumber)"
|
||||||
:model-value="accounting.accountNumber"
|
:model-value="accounting.accountNumber"
|
||||||
:label="t('commercial.suppliers.form.accounting.accountNumber')"
|
:label="t('commercial.suppliers.form.accounting.accountNumber')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
|
v-if="isFilled(accounting.tvaModeIri)"
|
||||||
:model-value="accounting.tvaModeIri"
|
:model-value="accounting.tvaModeIri"
|
||||||
:options="tvaModeOptions"
|
:options="tvaModeOptions"
|
||||||
:label="t('commercial.suppliers.form.accounting.tvaMode')"
|
:label="t('commercial.suppliers.form.accounting.tvaMode')"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-if="isFilled(accounting.nTva)"
|
||||||
:model-value="accounting.nTva"
|
:model-value="accounting.nTva"
|
||||||
:label="t('commercial.suppliers.form.accounting.nTva')"
|
:label="t('commercial.suppliers.form.accounting.nTva')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
|
v-if="isFilled(accounting.paymentDelayIri)"
|
||||||
:model-value="accounting.paymentDelayIri"
|
:model-value="accounting.paymentDelayIri"
|
||||||
:options="paymentDelayOptions"
|
:options="paymentDelayOptions"
|
||||||
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
|
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
|
v-if="isFilled(accounting.paymentTypeIri)"
|
||||||
:model-value="accounting.paymentTypeIri"
|
:model-value="accounting.paymentTypeIri"
|
||||||
:options="paymentTypeOptions"
|
:options="paymentTypeOptions"
|
||||||
:label="t('commercial.suppliers.form.accounting.paymentType')"
|
:label="t('commercial.suppliers.form.accounting.paymentType')"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-if="accounting.bankIri"
|
v-if="accounting.bankIri"
|
||||||
@@ -193,43 +219,46 @@
|
|||||||
:options="bankOptions"
|
:options="bankOptions"
|
||||||
:label="t('commercial.suppliers.form.accounting.bank')"
|
:label="t('commercial.suppliers.form.accounting.bank')"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Blocs RIB (0..n), lecture seule. -->
|
<!-- Blocs RIB (0..n), lecture seule.
|
||||||
|
Titre « RIB N », filet de separation sauf sous le dernier. -->
|
||||||
<div
|
<div
|
||||||
v-for="(rib, index) in ribs"
|
v-for="(rib, index) in ribs"
|
||||||
:key="rib.id ?? index"
|
:key="rib.id ?? index"
|
||||||
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="pb-[20px]"
|
||||||
|
:class="{ 'border-b border-black': index !== ribs.length - 1 }"
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
|
||||||
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-if="isFilled(rib.label)"
|
||||||
:model-value="rib.label"
|
:model-value="rib.label"
|
||||||
:label="t('commercial.suppliers.form.accounting.ribLabel')"
|
:label="t('commercial.suppliers.form.accounting.ribLabel')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-if="isFilled(rib.bic)"
|
||||||
:model-value="rib.bic"
|
:model-value="rib.bic"
|
||||||
:label="t('commercial.suppliers.form.accounting.ribBic')"
|
:label="t('commercial.suppliers.form.accounting.ribBic')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-if="isFilled(rib.iban)"
|
||||||
:model-value="rib.iban"
|
:model-value="rib.iban"
|
||||||
:label="t('commercial.suppliers.form.accounting.ribIban')"
|
:label="t('commercial.suppliers.form.accounting.ribIban')"
|
||||||
readonly
|
disabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- ERP-193 : les onglets « a venir » (Transport / Statistiques /
|
||||||
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
|
Rapports / Echanges) ne sont plus rendus en consultation
|
||||||
<template #transport><ComingSoonPlaceholder /></template>
|
(masquage des onglets vides) — slots supprimes. -->
|
||||||
<template #statistics><ComingSoonPlaceholder /></template>
|
|
||||||
<template #reports><ComingSoonPlaceholder /></template>
|
|
||||||
<template #exchanges><ComingSoonPlaceholder /></template>
|
|
||||||
</MalioTabList>
|
</MalioTabList>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -261,9 +290,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
|
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
|
||||||
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
|
import { isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||||
import {
|
import {
|
||||||
canEditSupplier,
|
canEditSupplier,
|
||||||
@@ -278,10 +307,12 @@ import {
|
|||||||
referentialOptionOf,
|
referentialOptionOf,
|
||||||
showArchiveAction,
|
showArchiveAction,
|
||||||
showRestoreAction,
|
showRestoreAction,
|
||||||
|
supplierConsultationVisibleTabs,
|
||||||
type SelectOption,
|
type SelectOption,
|
||||||
type SupplierDetail,
|
type SupplierDetail,
|
||||||
} from '~/modules/commercial/utils/forms/supplierConsultation'
|
} from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||||
import { emptyContact } from '~/modules/commercial/types/supplierForm'
|
import { emptyContact } from '~/modules/commercial/types/supplierForm'
|
||||||
|
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||||
|
|
||||||
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
||||||
const SIREN_MASK = '#########'
|
const SIREN_MASK = '#########'
|
||||||
@@ -387,9 +418,11 @@ const paymentTypeOptions = computed(() => referentialOptionOf(supplier.value?.pa
|
|||||||
const bankOptions = computed(() => referentialOptionOf(supplier.value?.bank))
|
const bankOptions = computed(() => referentialOptionOf(supplier.value?.bank))
|
||||||
|
|
||||||
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
|
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
|
||||||
// 3 onglets actifs (Information, Contacts, Adresses, + Comptabilite si droit) et
|
// ERP-193 (retour metier) : on masque les coquilles non implementees ET tout
|
||||||
// 4 coquilles (Transport, Statistiques, Rapports, Echanges).
|
// onglet de donnees vide. La liste depend donc du payload charge.
|
||||||
const tabKeys = computed(() => buildSupplierFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
|
const visibleTabKeys = computed(() => supplierConsultationVisibleTabs(supplier.value, {
|
||||||
|
canAccountingView: canAccountingView.value,
|
||||||
|
}))
|
||||||
|
|
||||||
const TAB_ICONS: Record<string, string> = {
|
const TAB_ICONS: Record<string, string> = {
|
||||||
information: 'mdi:account-outline',
|
information: 'mdi:account-outline',
|
||||||
@@ -402,14 +435,25 @@ const TAB_ICONS: Record<string, string> = {
|
|||||||
exchanges: 'mdi:account-group-outline',
|
exchanges: 'mdi:account-group-outline',
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs = computed(() => tabKeys.value.map(key => ({
|
const tabs = computed(() => visibleTabKeys.value.map(key => ({
|
||||||
key,
|
key,
|
||||||
label: t(`commercial.suppliers.tab.${key}`),
|
label: t(`commercial.suppliers.tab.${key}`),
|
||||||
icon: TAB_ICONS[key],
|
icon: TAB_ICONS[key],
|
||||||
})))
|
})))
|
||||||
|
|
||||||
// Onglet initial : repris de l'edition au retour (history.state), sinon Information.
|
// Onglet initial : vide tant que le fournisseur n'est pas charge. Des que la
|
||||||
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
|
// liste des onglets visibles est connue, on cale sur l'onglet repris de
|
||||||
|
// l'edition (history.state) s'il est encore visible, sinon le premier visible.
|
||||||
|
const activeTab = ref('')
|
||||||
|
watch(visibleTabKeys, (keys) => {
|
||||||
|
if (keys.length === 0) {
|
||||||
|
activeTab.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!keys.includes(activeTab.value)) {
|
||||||
|
activeTab.value = readHistoryTab(keys) ?? keys[0]
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||||
function goBack(): void {
|
function goBack(): void {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
@update:page="goToPage"
|
@update:page="goToPage"
|
||||||
@update:per-page="setItemsPerPage"
|
@update:per-page="setItemsPerPage"
|
||||||
>
|
>
|
||||||
<!-- Categories : libelles (name) separes par une virgule (spec M2). -->
|
<!-- Categories : libelles (name) separes par une virgule, aligne sur le client (ERP-193). -->
|
||||||
<template #cell-categories="{ item }">
|
<template #cell-categories="{ item }">
|
||||||
{{ formatCategories(item) }}
|
{{ formatCategories(item) }}
|
||||||
</template>
|
</template>
|
||||||
@@ -128,13 +128,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</MalioAccordionItem>
|
</MalioAccordionItem>
|
||||||
|
|
||||||
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). -->
|
<!-- Statut : bool unique. Coche = archives uniquement, sinon actifs. -->
|
||||||
<MalioAccordionItem :title="t('commercial.suppliers.filters.status')" value="status">
|
<MalioAccordionItem :title="t('commercial.suppliers.filters.status')" value="status">
|
||||||
<MalioCheckbox
|
<MalioCheckbox
|
||||||
id="filter-include-archived"
|
id="filter-archived-only"
|
||||||
:label="t('commercial.suppliers.filters.includeArchived')"
|
:label="t('commercial.suppliers.filters.archivedOnly')"
|
||||||
:model-value="draftIncludeArchived"
|
:model-value="draftArchivedOnly"
|
||||||
@update:model-value="(val: boolean) => draftIncludeArchived = val"
|
@update:model-value="(val: boolean) => draftArchivedOnly = val"
|
||||||
/>
|
/>
|
||||||
</MalioAccordionItem>
|
</MalioAccordionItem>
|
||||||
</MalioAccordion>
|
</MalioAccordion>
|
||||||
@@ -209,7 +209,7 @@ const columns = [
|
|||||||
{ key: 'lastActivity', label: t('commercial.suppliers.column.lastActivity') },
|
{ key: 'lastActivity', label: t('commercial.suppliers.column.lastActivity') },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Libelles des categories du fournisseur, separes par une virgule (spec M2 : name). */
|
/** Libelles (name) des categories du fournisseur, separes par une virgule (aligne sur le client, ERP-193). */
|
||||||
function formatCategories(item: Record<string, unknown>): string {
|
function formatCategories(item: Record<string, unknown>): string {
|
||||||
const categories = (item.categories as Supplier['categories']) ?? []
|
const categories = (item.categories as Supplier['categories']) ?? []
|
||||||
return categories.map(c => c.name).join(', ')
|
return categories.map(c => c.name).join(', ')
|
||||||
@@ -254,12 +254,12 @@ const filterDrawerOpen = ref(false)
|
|||||||
const draftSearch = ref('')
|
const draftSearch = ref('')
|
||||||
const draftCategoryCodes = ref<string[]>([])
|
const draftCategoryCodes = ref<string[]>([])
|
||||||
const draftSiteIds = ref<string[]>([])
|
const draftSiteIds = ref<string[]>([])
|
||||||
const draftIncludeArchived = ref(false)
|
const draftArchivedOnly = ref(false)
|
||||||
|
|
||||||
const appliedSearch = ref('')
|
const appliedSearch = ref('')
|
||||||
const appliedCategoryCodes = ref<string[]>([])
|
const appliedCategoryCodes = ref<string[]>([])
|
||||||
const appliedSiteIds = ref<string[]>([])
|
const appliedSiteIds = ref<string[]>([])
|
||||||
const appliedIncludeArchived = ref(false)
|
const appliedArchivedOnly = ref(false)
|
||||||
|
|
||||||
// Options des selects multi, chargees une fois (referentiels courts).
|
// Options des selects multi, chargees une fois (referentiels courts).
|
||||||
const categoryOptions = ref<FilterOption[]>([])
|
const categoryOptions = ref<FilterOption[]>([])
|
||||||
@@ -270,7 +270,7 @@ const activeFilterCount = computed(() => {
|
|||||||
if (appliedSearch.value.trim() !== '') count++
|
if (appliedSearch.value.trim() !== '') count++
|
||||||
if (appliedCategoryCodes.value.length > 0) count++
|
if (appliedCategoryCodes.value.length > 0) count++
|
||||||
if (appliedSiteIds.value.length > 0) count++
|
if (appliedSiteIds.value.length > 0) count++
|
||||||
if (appliedIncludeArchived.value) count++
|
if (appliedArchivedOnly.value) count++
|
||||||
return count
|
return count
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -285,7 +285,7 @@ function openFilters(): void {
|
|||||||
draftSearch.value = appliedSearch.value
|
draftSearch.value = appliedSearch.value
|
||||||
draftCategoryCodes.value = [...appliedCategoryCodes.value]
|
draftCategoryCodes.value = [...appliedCategoryCodes.value]
|
||||||
draftSiteIds.value = [...appliedSiteIds.value]
|
draftSiteIds.value = [...appliedSiteIds.value]
|
||||||
draftIncludeArchived.value = appliedIncludeArchived.value
|
draftArchivedOnly.value = appliedArchivedOnly.value
|
||||||
filterDrawerOpen.value = true
|
filterDrawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ function buildFilterPayload(): Record<string, string | string[] | boolean> {
|
|||||||
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
||||||
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
|
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
|
||||||
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
|
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
|
||||||
if (appliedIncludeArchived.value) payload.includeArchived = true
|
if (appliedArchivedOnly.value) payload.archivedOnly = true
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +321,7 @@ function applyFilters(): void {
|
|||||||
appliedSearch.value = draftSearch.value.trim()
|
appliedSearch.value = draftSearch.value.trim()
|
||||||
appliedCategoryCodes.value = [...draftCategoryCodes.value]
|
appliedCategoryCodes.value = [...draftCategoryCodes.value]
|
||||||
appliedSiteIds.value = [...draftSiteIds.value]
|
appliedSiteIds.value = [...draftSiteIds.value]
|
||||||
appliedIncludeArchived.value = draftIncludeArchived.value
|
appliedArchivedOnly.value = draftArchivedOnly.value
|
||||||
|
|
||||||
setFilters(buildFilterPayload(), { replace: true })
|
setFilters(buildFilterPayload(), { replace: true })
|
||||||
filterDrawerOpen.value = false
|
filterDrawerOpen.value = false
|
||||||
@@ -333,12 +333,12 @@ function resetFilters(): void {
|
|||||||
draftSearch.value = ''
|
draftSearch.value = ''
|
||||||
draftCategoryCodes.value = []
|
draftCategoryCodes.value = []
|
||||||
draftSiteIds.value = []
|
draftSiteIds.value = []
|
||||||
draftIncludeArchived.value = false
|
draftArchivedOnly.value = false
|
||||||
|
|
||||||
appliedSearch.value = ''
|
appliedSearch.value = ''
|
||||||
appliedCategoryCodes.value = []
|
appliedCategoryCodes.value = []
|
||||||
appliedSiteIds.value = []
|
appliedSiteIds.value = []
|
||||||
appliedIncludeArchived.value = false
|
appliedArchivedOnly.value = false
|
||||||
|
|
||||||
setFilters({}, { replace: true })
|
setFilters({}, { replace: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
icon="mdi:arrow-left-bold"
|
icon="mdi:arrow-left-bold"
|
||||||
icon-size="24"
|
icon-size="24"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
:title="t('commercial.suppliers.form.back')"
|
||||||
v-bind="{ ariaLabel: t('commercial.suppliers.form.back') }"
|
v-bind="{ ariaLabel: t('commercial.suppliers.form.back') }"
|
||||||
@click="goBack"
|
@click="goBack"
|
||||||
/>
|
/>
|
||||||
@@ -21,15 +22,16 @@
|
|||||||
v-model="main.companyName"
|
v-model="main.companyName"
|
||||||
:label="t('commercial.suppliers.form.main.companyName')"
|
:label="t('commercial.suppliers.form.main.companyName')"
|
||||||
:required="true"
|
:required="true"
|
||||||
:readonly="mainLocked"
|
:disabled="mainLocked"
|
||||||
:error="mainErrors.errors.companyName"
|
:error="mainErrors.errors.companyName"
|
||||||
|
:mask="FREE_TEXT_MASK"
|
||||||
/>
|
/>
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox
|
||||||
:model-value="main.categoryIris"
|
:model-value="main.categoryIris"
|
||||||
:options="referentials.categories.value"
|
:options="referentials.categories.value"
|
||||||
:label="t('commercial.suppliers.form.main.categories')"
|
:label="t('commercial.suppliers.form.main.categories')"
|
||||||
:display-tag="true"
|
:display-tag="true"
|
||||||
:readonly="mainLocked"
|
:disabled="mainLocked"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="mainErrors.errors.categories"
|
:error="mainErrors.errors.categories"
|
||||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||||
@@ -56,20 +58,24 @@
|
|||||||
resize="none"
|
resize="none"
|
||||||
group-class="row-span-2 pt-1 pb-1"
|
group-class="row-span-2 pt-1 pb-1"
|
||||||
text-input="h-full text-lg"
|
text-input="h-full text-lg"
|
||||||
:readonly="isValidated('information')"
|
:disabled="isValidated('information')"
|
||||||
:error="informationErrors.errors.description"
|
:error="informationErrors.errors.description"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="information.competitors"
|
v-model="information.competitors"
|
||||||
:label="t('commercial.suppliers.form.information.competitors')"
|
:label="t('commercial.suppliers.form.information.competitors')"
|
||||||
:readonly="isValidated('information')"
|
:disabled="isValidated('information')"
|
||||||
:error="informationErrors.errors.competitors"
|
:error="informationErrors.errors.competitors"
|
||||||
|
:mask="FREE_TEXT_MASK"
|
||||||
/>
|
/>
|
||||||
|
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
|
||||||
|
le calendrier a aujourd'hui et invalide une saisie future. -->
|
||||||
<MalioDate
|
<MalioDate
|
||||||
v-model="information.foundedAt"
|
v-model="information.foundedAt"
|
||||||
:label="t('commercial.suppliers.form.information.foundedAt')"
|
:label="t('commercial.suppliers.form.information.foundedAt')"
|
||||||
:readonly="isValidated('information')"
|
:disabled="isValidated('information')"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
|
:max="maxFoundedAt"
|
||||||
:error="informationErrors.errors.foundedAt"
|
:error="informationErrors.errors.foundedAt"
|
||||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||||
/>
|
/>
|
||||||
@@ -77,25 +83,30 @@
|
|||||||
v-model="information.employeesCount"
|
v-model="information.employeesCount"
|
||||||
:label="t('commercial.suppliers.form.information.employeesCount')"
|
:label="t('commercial.suppliers.form.information.employeesCount')"
|
||||||
:mask="EMPLOYEES_MASK"
|
:mask="EMPLOYEES_MASK"
|
||||||
:readonly="isValidated('information')"
|
:disabled="isValidated('information')"
|
||||||
:error="informationErrors.errors.employeesCount"
|
:error="informationErrors.errors.employeesCount"
|
||||||
/>
|
/>
|
||||||
|
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
|
||||||
|
:key force le re-affichage quand on plafonne (modelValue inchange). -->
|
||||||
<MalioInputAmount
|
<MalioInputAmount
|
||||||
v-model="information.revenueAmount"
|
:key="revenueAmountKey"
|
||||||
|
:model-value="information.revenueAmount"
|
||||||
:label="t('commercial.suppliers.form.information.revenueAmount')"
|
:label="t('commercial.suppliers.form.information.revenueAmount')"
|
||||||
:readonly="isValidated('information')"
|
:disabled="isValidated('information')"
|
||||||
:error="informationErrors.errors.revenueAmount"
|
:error="informationErrors.errors.revenueAmount"
|
||||||
|
@update:model-value="onRevenueAmountInput"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="information.directorName"
|
v-model="information.directorName"
|
||||||
:label="t('commercial.suppliers.form.information.directorName')"
|
:label="t('commercial.suppliers.form.information.directorName')"
|
||||||
:readonly="isValidated('information')"
|
:disabled="isValidated('information')"
|
||||||
:error="informationErrors.errors.directorName"
|
:error="informationErrors.errors.directorName"
|
||||||
|
:mask="PERSON_NAME_MASK"
|
||||||
/>
|
/>
|
||||||
<MalioInputAmount
|
<MalioInputAmount
|
||||||
v-model="information.profitAmount"
|
v-model="information.profitAmount"
|
||||||
:label="t('commercial.suppliers.form.information.profitAmount')"
|
:label="t('commercial.suppliers.form.information.profitAmount')"
|
||||||
:readonly="isValidated('information')"
|
:disabled="isValidated('information')"
|
||||||
:error="informationErrors.errors.profitAmount"
|
:error="informationErrors.errors.profitAmount"
|
||||||
/>
|
/>
|
||||||
<!-- Volume previsionnel : specifique fournisseur. Champ texte
|
<!-- Volume previsionnel : specifique fournisseur. Champ texte
|
||||||
@@ -104,15 +115,18 @@
|
|||||||
v-model="information.volumeForecast"
|
v-model="information.volumeForecast"
|
||||||
:label="t('commercial.suppliers.form.information.volumeForecast')"
|
:label="t('commercial.suppliers.form.information.volumeForecast')"
|
||||||
:mask="VOLUME_FORECAST_MASK"
|
:mask="VOLUME_FORECAST_MASK"
|
||||||
:readonly="isValidated('information')"
|
:disabled="isValidated('information')"
|
||||||
:error="informationErrors.errors.volumeForecast"
|
:error="informationErrors.errors.volumeForecast"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
|
<!-- Masque tant que le fournisseur n'est pas cree : Information etant
|
||||||
|
l'onglet actif par defaut, son Valider ne doit pas apparaitre a cote
|
||||||
|
de celui du formulaire principal (ERP-193). -->
|
||||||
|
<div v-if="!isValidated('information') && supplierId !== null" class="mt-12 flex justify-center">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:label="t('commercial.suppliers.form.submit')"
|
:label="t('commercial.suppliers.form.submit')"
|
||||||
:disabled="tabSubmitting || supplierId === null"
|
:disabled="tabSubmitting"
|
||||||
@click="submitInformation"
|
@click="submitInformation"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,13 +135,18 @@
|
|||||||
<!-- Onglet Contacts -->
|
<!-- Onglet Contacts -->
|
||||||
<template #contacts>
|
<template #contacts>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<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
|
<SupplierContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="isValidated('contacts')"
|
:last="index === contacts.length - 1"
|
||||||
|
:disabled="isValidated('contacts')"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@remove="askRemoveContact(index)"
|
@remove="askRemoveContact(index)"
|
||||||
@@ -159,12 +178,13 @@
|
|||||||
:key="index"
|
:key="index"
|
||||||
:model-value="address"
|
:model-value="address"
|
||||||
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
|
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
|
||||||
|
:last="index === addresses.length - 1"
|
||||||
:category-options="referentials.categories.value"
|
:category-options="referentials.categories.value"
|
||||||
:site-options="referentials.sites.value"
|
:site-options="referentials.sites.value"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="isValidated('addresses')"
|
:disabled="isValidated('addresses')"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@remove="askRemoveAddress(index)"
|
@remove="askRemoveAddress(index)"
|
||||||
@@ -192,28 +212,31 @@
|
|||||||
<!-- Onglet Comptabilite (present uniquement si accounting.view) -->
|
<!-- Onglet Comptabilite (present uniquement si accounting.view) -->
|
||||||
<template v-if="canAccountingView" #accounting>
|
<template v-if="canAccountingView" #accounting>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<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)]">
|
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
|
||||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
|
||||||
|
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.infoTitle') }}</h2>
|
||||||
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="accounting.siren"
|
v-model="accounting.siren"
|
||||||
:label="t('commercial.suppliers.form.accounting.siren')"
|
:label="t('commercial.suppliers.form.accounting.siren')"
|
||||||
:mask="SIREN_MASK"
|
:mask="SIREN_MASK"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.siren"
|
:error="accountingErrors.errors.siren"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="accounting.accountNumber"
|
v-model="accounting.accountNumber"
|
||||||
:label="t('commercial.suppliers.form.accounting.accountNumber')"
|
:label="t('commercial.suppliers.form.accounting.accountNumber')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.accountNumber"
|
:error="accountingErrors.errors.accountNumber"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
:model-value="accounting.tvaModeIri"
|
:model-value="accounting.tvaModeIri"
|
||||||
:options="referentials.tvaModes.value"
|
:options="referentials.tvaModes.value"
|
||||||
:label="t('commercial.suppliers.form.accounting.tvaMode')"
|
:label="t('commercial.suppliers.form.accounting.tvaMode')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.tvaMode"
|
:error="accountingErrors.errors.tvaMode"
|
||||||
@@ -222,15 +245,16 @@
|
|||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="accounting.nTva"
|
v-model="accounting.nTva"
|
||||||
:label="t('commercial.suppliers.form.accounting.nTva')"
|
:label="t('commercial.suppliers.form.accounting.nTva')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.nTva"
|
:error="accountingErrors.errors.nTva"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
:model-value="accounting.paymentDelayIri"
|
:model-value="accounting.paymentDelayIri"
|
||||||
:options="referentials.paymentDelays.value"
|
:options="referentials.paymentDelays.value"
|
||||||
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
|
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.paymentDelay"
|
:error="accountingErrors.errors.paymentDelay"
|
||||||
@@ -240,7 +264,7 @@
|
|||||||
:model-value="accounting.paymentTypeIri"
|
:model-value="accounting.paymentTypeIri"
|
||||||
:options="referentials.paymentTypes.value"
|
:options="referentials.paymentTypes.value"
|
||||||
:label="t('commercial.suppliers.form.accounting.paymentType')"
|
:label="t('commercial.suppliers.form.accounting.paymentType')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.paymentType"
|
:error="accountingErrors.errors.paymentType"
|
||||||
@@ -251,7 +275,7 @@
|
|||||||
:model-value="accounting.bankIri"
|
:model-value="accounting.bankIri"
|
||||||
:options="referentials.banks.value"
|
:options="referentials.banks.value"
|
||||||
:label="t('commercial.suppliers.form.accounting.bank')"
|
:label="t('commercial.suppliers.form.accounting.bank')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="accountingErrors.errors.bank"
|
:error="accountingErrors.errors.bank"
|
||||||
@@ -260,41 +284,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08). -->
|
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08).
|
||||||
|
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
|
||||||
<div
|
<div
|
||||||
v-for="(rib, index) in visibleRibs"
|
v-for="(rib, index) in visibleRibs"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="pb-[20px]"
|
||||||
|
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
|
||||||
>
|
>
|
||||||
<MalioButtonIcon
|
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
<div class="flex items-center justify-between">
|
||||||
icon="mdi:delete-outline"
|
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
|
||||||
variant="ghost"
|
<MalioButtonIcon
|
||||||
button-class="absolute top-3 right-3"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
|
icon="mdi:delete-outline"
|
||||||
@click="askRemoveRib(index)"
|
variant="ghost"
|
||||||
/>
|
button-class="p-0"
|
||||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
|
||||||
|
@click="askRemoveRib(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="rib.label"
|
v-model="rib.label"
|
||||||
:label="t('commercial.suppliers.form.accounting.ribLabel')"
|
:label="t('commercial.suppliers.form.accounting.ribLabel')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="isRibRequired"
|
:required="isRibRequired"
|
||||||
:error="ribErrors[index]?.label"
|
:error="ribErrors[index]?.label"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="rib.bic"
|
v-model="rib.bic"
|
||||||
:label="t('commercial.suppliers.form.accounting.ribBic')"
|
:label="t('commercial.suppliers.form.accounting.ribBic')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="isRibRequired"
|
:required="isRibRequired"
|
||||||
:error="ribErrors[index]?.bic"
|
:error="ribErrors[index]?.bic"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="rib.iban"
|
v-model="rib.iban"
|
||||||
:label="t('commercial.suppliers.form.accounting.ribIban')"
|
:label="t('commercial.suppliers.form.accounting.ribIban')"
|
||||||
:readonly="accountingReadonly"
|
:disabled="accountingReadonly"
|
||||||
:required="isRibRequired"
|
:required="isRibRequired"
|
||||||
:error="ribErrors[index]?.iban"
|
:error="ribErrors[index]?.iban"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -371,6 +403,8 @@ import {
|
|||||||
buildMainPayload,
|
buildMainPayload,
|
||||||
buildRibPayload,
|
buildRibPayload,
|
||||||
} from '~/modules/commercial/utils/forms/supplierEdit'
|
} from '~/modules/commercial/utils/forms/supplierEdit'
|
||||||
|
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
|
||||||
|
import { todayIso } from '~/shared/utils/date'
|
||||||
import {
|
import {
|
||||||
emptyAddress,
|
emptyAddress,
|
||||||
emptyContact,
|
emptyContact,
|
||||||
@@ -380,6 +414,8 @@ import {
|
|||||||
type SupplierRibFormDraft,
|
type SupplierRibFormDraft,
|
||||||
} from '~/modules/commercial/types/supplierForm'
|
} from '~/modules/commercial/types/supplierForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||||
|
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
|
||||||
|
|
||||||
// Masques de saisie (la normalisation finale reste serveur).
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
const SIREN_MASK = '#########'
|
const SIREN_MASK = '#########'
|
||||||
@@ -559,6 +595,22 @@ const information = reactive({
|
|||||||
volumeForecast: null as string | null,
|
volumeForecast: null as string | null,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
|
||||||
|
const maxFoundedAt = todayIso()
|
||||||
|
|
||||||
|
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
|
||||||
|
// champ controle quand le plafonnement laisse le modelValue inchange.
|
||||||
|
const revenueAmountKey = ref(0)
|
||||||
|
|
||||||
|
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
|
||||||
|
function onRevenueAmountInput(value: string | null): void {
|
||||||
|
const clamped = clampRevenueAmount(value)
|
||||||
|
information.revenueAmount = clamped ?? null
|
||||||
|
if (clamped !== value) {
|
||||||
|
revenueAmountKey.value += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** PATCH /suppliers/{id} — mode strict : uniquement les champs du groupe information. */
|
/** PATCH /suppliers/{id} — mode strict : uniquement les champs du groupe information. */
|
||||||
async function submitInformation(): Promise<void> {
|
async function submitInformation(): Promise<void> {
|
||||||
if (supplierId.value === null || tabSubmitting.value) return
|
if (supplierId.value === null || tabSubmitting.value) return
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { clampRevenueAmount, REVENUE_AMOUNT_MAX } from '../amountInput'
|
||||||
|
|
||||||
|
describe('clampRevenueAmount', () => {
|
||||||
|
it('laisse les valeurs vides / nulles telles quelles', () => {
|
||||||
|
expect(clampRevenueAmount(null)).toBeNull()
|
||||||
|
expect(clampRevenueAmount(undefined)).toBeUndefined()
|
||||||
|
expect(clampRevenueAmount('')).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('laisse une valeur sous le plafond inchangee', () => {
|
||||||
|
expect(clampRevenueAmount('1000.50')).toBe('1000.50')
|
||||||
|
expect(clampRevenueAmount('999999999999.99')).toBe('999999999999.99')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('plafonne une valeur au-dessus du maximum', () => {
|
||||||
|
expect(clampRevenueAmount('1000000000000')).toBe('999999999999.99')
|
||||||
|
expect(clampRevenueAmount('999999999999999.99')).toBe('999999999999.99')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tolere une saisie a virgule / avec espaces (securite)', () => {
|
||||||
|
expect(clampRevenueAmount('1 000 000 000 000,00')).toBe('999999999999.99')
|
||||||
|
expect(clampRevenueAmount('12,5')).toBe('12,5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne touche pas une saisie non numerique', () => {
|
||||||
|
expect(clampRevenueAmount('abc')).toBe('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expose le plafond metier', () => {
|
||||||
|
expect(REVENUE_AMOUNT_MAX).toBe(999_999_999_999.99)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
canEditClient,
|
canEditClient,
|
||||||
categoryOptionsOf,
|
categoryOptionsOf,
|
||||||
|
clientConsultationVisibleTabs,
|
||||||
contactOptionsOf,
|
contactOptionsOf,
|
||||||
|
hasAccountingData,
|
||||||
|
hasInformationData,
|
||||||
iriOf,
|
iriOf,
|
||||||
mapAccountingDraft,
|
mapAccountingDraft,
|
||||||
mapAddressToDraft,
|
mapAddressToDraft,
|
||||||
@@ -248,3 +251,73 @@ describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', (
|
|||||||
expect(paymentTypeCodeOf(undefined)).toBeNull()
|
expect(paymentTypeCodeOf(undefined)).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('hasInformationData', () => {
|
||||||
|
it('faux si tous les champs Information sont vides/absents', () => {
|
||||||
|
expect(hasInformationData({ '@id': '/api/clients/1', id: 1 })).toBe(false)
|
||||||
|
expect(hasInformationData({ '@id': '/api/clients/1', id: 1, description: ' ' })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vrai des qu\'un champ Information porte une donnee', () => {
|
||||||
|
expect(hasInformationData({ '@id': '/api/clients/1', id: 1, directorName: 'Dupont' })).toBe(true)
|
||||||
|
expect(hasInformationData({ '@id': '/api/clients/1', id: 1, employeesCount: 0 })).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hasAccountingData', () => {
|
||||||
|
it('faux sans champ comptable ni RIB', () => {
|
||||||
|
expect(hasAccountingData({ '@id': '/api/clients/1', id: 1 })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vrai avec un champ comptable scalaire', () => {
|
||||||
|
expect(hasAccountingData({ '@id': '/api/clients/1', id: 1, siren: '123456789' })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vrai avec une relation comptable embarquee (paymentType)', () => {
|
||||||
|
expect(hasAccountingData({
|
||||||
|
'@id': '/api/clients/1', id: 1,
|
||||||
|
paymentType: { '@id': '/api/payment_types/1', code: 'LCR' },
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vrai avec au moins un RIB', () => {
|
||||||
|
expect(hasAccountingData({
|
||||||
|
'@id': '/api/clients/1', id: 1,
|
||||||
|
ribs: [{ '@id': '/api/ribs/1', id: 1, iban: 'FR76...' }],
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clientConsultationVisibleTabs', () => {
|
||||||
|
it('retourne [] tant que le client n\'est pas charge', () => {
|
||||||
|
expect(clientConsultationVisibleTabs(null, { canAccountingView: true })).toEqual([])
|
||||||
|
expect(clientConsultationVisibleTabs(undefined, { canAccountingView: true })).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masque les coquilles et les onglets vides (client minimal)', () => {
|
||||||
|
const client: ClientDetail = { '@id': '/api/clients/1', id: 1, companyName: 'ACME' }
|
||||||
|
expect(clientConsultationVisibleTabs(client, { canAccountingView: true })).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche les onglets non vides dans l\'ordre information/contact/address/accounting', () => {
|
||||||
|
const client: ClientDetail = {
|
||||||
|
'@id': '/api/clients/1', id: 1,
|
||||||
|
directorName: 'Dupont',
|
||||||
|
contacts: [{ '@id': '/api/client_contacts/1', id: 1 }],
|
||||||
|
addresses: [{ '@id': '/api/client_addresses/1', id: 1 }],
|
||||||
|
siren: '123456789',
|
||||||
|
}
|
||||||
|
expect(clientConsultationVisibleTabs(client, { canAccountingView: true }))
|
||||||
|
.toEqual(['information', 'contact', 'address', 'accounting'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masque Comptabilite sans le droit accounting.view meme si des donnees existent', () => {
|
||||||
|
const client: ClientDetail = {
|
||||||
|
'@id': '/api/clients/1', id: 1,
|
||||||
|
contacts: [{ '@id': '/api/client_contacts/1', id: 1 }],
|
||||||
|
siren: '123456789',
|
||||||
|
}
|
||||||
|
expect(clientConsultationVisibleTabs(client, { canAccountingView: false }))
|
||||||
|
.toEqual(['contact'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
canEditSupplier,
|
canEditSupplier,
|
||||||
categoryOptionsOf,
|
categoryOptionsOf,
|
||||||
contactOptionsOf,
|
contactOptionsOf,
|
||||||
|
hasAccountingData,
|
||||||
|
hasInformationData,
|
||||||
iriOf,
|
iriOf,
|
||||||
mapAccountingDraft,
|
mapAccountingDraft,
|
||||||
mapAddressToDraft,
|
mapAddressToDraft,
|
||||||
@@ -14,6 +16,7 @@ import {
|
|||||||
showArchiveAction,
|
showArchiveAction,
|
||||||
showRestoreAction,
|
showRestoreAction,
|
||||||
siteOptionsOf,
|
siteOptionsOf,
|
||||||
|
supplierConsultationVisibleTabs,
|
||||||
type SupplierDetail,
|
type SupplierDetail,
|
||||||
} from '../supplierConsultation'
|
} from '../supplierConsultation'
|
||||||
|
|
||||||
@@ -237,3 +240,60 @@ describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', (
|
|||||||
expect(paymentTypeCodeOf(undefined)).toBeNull()
|
expect(paymentTypeCodeOf(undefined)).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('hasInformationData (fournisseur)', () => {
|
||||||
|
it('faux si tous les champs Information (volume previsionnel inclus) sont vides', () => {
|
||||||
|
expect(hasInformationData({ '@id': '/api/suppliers/1', id: 1 })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vrai des qu\'un champ Information porte une donnee', () => {
|
||||||
|
expect(hasInformationData({ '@id': '/api/suppliers/1', id: 1, directorName: 'Martin' })).toBe(true)
|
||||||
|
expect(hasInformationData({ '@id': '/api/suppliers/1', id: 1, volumeForecast: 1200 })).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hasAccountingData (fournisseur)', () => {
|
||||||
|
it('faux sans champ comptable ni RIB', () => {
|
||||||
|
expect(hasAccountingData({ '@id': '/api/suppliers/1', id: 1 })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vrai avec un champ comptable ou un RIB', () => {
|
||||||
|
expect(hasAccountingData({ '@id': '/api/suppliers/1', id: 1, siren: '123456789' })).toBe(true)
|
||||||
|
expect(hasAccountingData({
|
||||||
|
'@id': '/api/suppliers/1', id: 1,
|
||||||
|
ribs: [{ '@id': '/api/supplier_ribs/1', id: 1, iban: 'FR76...' }],
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('supplierConsultationVisibleTabs', () => {
|
||||||
|
it('retourne [] tant que le fournisseur n\'est pas charge', () => {
|
||||||
|
expect(supplierConsultationVisibleTabs(null, { canAccountingView: true })).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masque les coquilles et les onglets vides (fournisseur minimal)', () => {
|
||||||
|
expect(supplierConsultationVisibleTabs(
|
||||||
|
{ '@id': '/api/suppliers/1', id: 1, companyName: 'ACME' },
|
||||||
|
{ canAccountingView: true },
|
||||||
|
)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche information/contacts/addresses/accounting (cles plurielles) dans l\'ordre', () => {
|
||||||
|
const supplier: SupplierDetail = {
|
||||||
|
'@id': '/api/suppliers/1', id: 1,
|
||||||
|
volumeForecast: 1000,
|
||||||
|
contacts: [{ '@id': '/api/supplier_contacts/1', id: 1 }],
|
||||||
|
addresses: [{ '@id': '/api/supplier_addresses/1', id: 1 }],
|
||||||
|
siren: '123456789',
|
||||||
|
}
|
||||||
|
expect(supplierConsultationVisibleTabs(supplier, { canAccountingView: true }))
|
||||||
|
.toEqual(['information', 'contacts', 'addresses', 'accounting'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masque Comptabilite sans le droit accounting.view', () => {
|
||||||
|
expect(supplierConsultationVisibleTabs(
|
||||||
|
{ '@id': '/api/suppliers/1', id: 1, siren: '123456789' },
|
||||||
|
{ canAccountingView: false },
|
||||||
|
)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Helpers de saisie des montants des formulaires Client / Fournisseur (commercial).
|
||||||
|
* Purs / testables. Pendant FRONT de la contrainte back `LessThanOrEqual` posee sur
|
||||||
|
* `revenueAmount` (Client/Supplier) — retour metier ERP-193 : le chiffre d'affaires
|
||||||
|
* est plafonne a 999 999 999 999,99.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Plafond metier du chiffre d'affaires (CA) : 999 999 999 999,99. */
|
||||||
|
export const REVENUE_AMOUNT_MAX = 999_999_999_999.99
|
||||||
|
|
||||||
|
/** Valeur « modele » (decimale a point, sans separateur) renvoyee quand on plafonne. */
|
||||||
|
const REVENUE_AMOUNT_MAX_MODEL = '999999999999.99'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plafonne le CA au maximum metier. Recoit le modele emis par `MalioInputAmount`
|
||||||
|
* (chaine propre a decimale `.`, sans espaces) ; tolere malgre tout une virgule /
|
||||||
|
* des espaces par securite. Renvoie la valeur telle quelle si elle est vide, non
|
||||||
|
* numerique ou sous le plafond ; sinon la valeur plafonnee.
|
||||||
|
*/
|
||||||
|
export function clampRevenueAmount(value: string | null | undefined): string | null | undefined {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const n = Number(String(value).replace(/\s/g, '').replace(',', '.'))
|
||||||
|
if (Number.isNaN(n)) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return n > REVENUE_AMOUNT_MAX ? REVENUE_AMOUNT_MAX_MODEL : value
|
||||||
|
}
|
||||||
@@ -317,6 +317,77 @@ export function mapAddressView(address: AddressRead): AddressView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai si une valeur scalaire porte une donnee affichable (non null/undefined,
|
||||||
|
* et non chaine vide apres trim). Sert aux predicats « onglet vide » ci-dessous.
|
||||||
|
*/
|
||||||
|
function hasValue(value: unknown): boolean {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.trim() !== ''
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai si l'onglet Information porte au moins une donnee. ERP-193 : en
|
||||||
|
* consultation on masque les onglets vides ; Information n'echappe pas a la
|
||||||
|
* regle malgre son statut d'onglet d'atterrissage par defaut.
|
||||||
|
*/
|
||||||
|
export function hasInformationData(client: ClientDetail): boolean {
|
||||||
|
return [
|
||||||
|
client.description,
|
||||||
|
client.competitors,
|
||||||
|
client.foundedAt,
|
||||||
|
client.employeesCount,
|
||||||
|
client.revenueAmount,
|
||||||
|
client.profitAmount,
|
||||||
|
client.directorName,
|
||||||
|
].some(hasValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai si l'onglet Comptabilite porte au moins un champ comptable OU un RIB.
|
||||||
|
* (Le gating permission `accounting.view` reste applique en amont par l'appelant.)
|
||||||
|
*/
|
||||||
|
export function hasAccountingData(client: ClientDetail): boolean {
|
||||||
|
const draft = mapAccountingDraft(client)
|
||||||
|
const hasField = Object.values(draft).some(hasValue)
|
||||||
|
const hasRib = (client.ribs ?? []).length > 0
|
||||||
|
return hasField || hasRib
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onglets visibles en consultation (ERP-193, retour metier) : on masque les
|
||||||
|
* coquilles non implementees (Transport / Statistiques / Rapports / Echanges)
|
||||||
|
* ET tout onglet de donnees vide. L'ordre reproduit `buildClientFormTabKeys`.
|
||||||
|
* Retourne `[]` tant que le client n'est pas charge.
|
||||||
|
*/
|
||||||
|
export function clientConsultationVisibleTabs(
|
||||||
|
client: ClientDetail | null | undefined,
|
||||||
|
options: { canAccountingView: boolean },
|
||||||
|
): string[] {
|
||||||
|
if (!client) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const visible: string[] = []
|
||||||
|
if (hasInformationData(client)) {
|
||||||
|
visible.push('information')
|
||||||
|
}
|
||||||
|
if ((client.contacts ?? []).length > 0) {
|
||||||
|
visible.push('contact')
|
||||||
|
}
|
||||||
|
if ((client.addresses ?? []).length > 0) {
|
||||||
|
visible.push('address')
|
||||||
|
}
|
||||||
|
if (options.canAccountingView && hasAccountingData(client)) {
|
||||||
|
visible.push('accounting')
|
||||||
|
}
|
||||||
|
return visible
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
|
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
|
||||||
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
|
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
|
||||||
|
|||||||
@@ -292,6 +292,78 @@ export function mapAddressView(address: AddressRead): AddressView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai si une valeur scalaire porte une donnee affichable (non null/undefined,
|
||||||
|
* et non chaine vide apres trim). Sert aux predicats « onglet vide » ci-dessous.
|
||||||
|
*/
|
||||||
|
function hasValue(value: unknown): boolean {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.trim() !== ''
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai si l'onglet Information porte au moins une donnee (volume previsionnel
|
||||||
|
* inclus, specifique fournisseur). ERP-193 : en consultation on masque les
|
||||||
|
* onglets vides, Information comprise.
|
||||||
|
*/
|
||||||
|
export function hasInformationData(supplier: SupplierDetail): boolean {
|
||||||
|
return [
|
||||||
|
supplier.description,
|
||||||
|
supplier.competitors,
|
||||||
|
supplier.foundedAt,
|
||||||
|
supplier.employeesCount,
|
||||||
|
supplier.revenueAmount,
|
||||||
|
supplier.profitAmount,
|
||||||
|
supplier.directorName,
|
||||||
|
supplier.volumeForecast,
|
||||||
|
].some(hasValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai si l'onglet Comptabilite porte au moins un champ comptable OU un RIB.
|
||||||
|
* (Le gating permission `accounting.view` reste applique en amont par l'appelant.)
|
||||||
|
*/
|
||||||
|
export function hasAccountingData(supplier: SupplierDetail): boolean {
|
||||||
|
const draft = mapAccountingDraft(supplier)
|
||||||
|
const hasField = Object.values(draft).some(hasValue)
|
||||||
|
const hasRib = (supplier.ribs ?? []).length > 0
|
||||||
|
return hasField || hasRib
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onglets visibles en consultation (ERP-193, retour metier) : on masque les
|
||||||
|
* coquilles non implementees (Transport / Statistiques / Rapports / Echanges)
|
||||||
|
* ET tout onglet de donnees vide. L'ordre reproduit `buildSupplierFormTabKeys`.
|
||||||
|
* Retourne `[]` tant que le fournisseur n'est pas charge.
|
||||||
|
*/
|
||||||
|
export function supplierConsultationVisibleTabs(
|
||||||
|
supplier: SupplierDetail | null | undefined,
|
||||||
|
options: { canAccountingView: boolean },
|
||||||
|
): string[] {
|
||||||
|
if (!supplier) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const visible: string[] = []
|
||||||
|
if (hasInformationData(supplier)) {
|
||||||
|
visible.push('information')
|
||||||
|
}
|
||||||
|
if ((supplier.contacts ?? []).length > 0) {
|
||||||
|
visible.push('contacts')
|
||||||
|
}
|
||||||
|
if ((supplier.addresses ?? []).length > 0) {
|
||||||
|
visible.push('addresses')
|
||||||
|
}
|
||||||
|
if (options.canAccountingView && hasAccountingData(supplier)) {
|
||||||
|
visible.push('accounting')
|
||||||
|
}
|
||||||
|
return visible
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
|
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
|
||||||
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
|
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
@click="emit('update:modelValue', false)"
|
@click="emit('update:modelValue', false)"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
:label="t('common.save')"
|
:label="isEditMode ? t('common.save') : t('common.validate')"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
button-class="w-m-btn-action"
|
button-class="w-m-btn-action"
|
||||||
:disabled="saving || permissionsLoadFailed"
|
:disabled="saving || permissionsLoadFailed"
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Padding vertical piloté par la page (1er bloc sans pt, dernier sans pb). -->
|
||||||
|
<div>
|
||||||
|
<!-- En-tête du bloc : titre + boutons de pesée (bascule / manuelle). -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-[20px] font-semibold text-m-primary">{{ title }}</h2>
|
||||||
|
<div class="flex items-center gap-8">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:weight"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('logistique.weighingTickets.form.weighbridge.auto')"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="$emit('request-auto')"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
icon-name="mdi:weight"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('logistique.weighingTickets.form.weighbridge.manual')"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="$emit('request-manual')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ligne : Date/heure, Poids, DSD. L'immatriculation et « Tout format »
|
||||||
|
vivent désormais dans les 4 champs du haut, hors des blocs (ERP-193). -->
|
||||||
|
<div class="mt-6 grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<!-- Date/heure de la pesée — date du jour + heure courante par défaut
|
||||||
|
(RG-5.07), ré-horodatée à la validation de la pesée. -->
|
||||||
|
<MalioDateTime
|
||||||
|
:model-value="block.date"
|
||||||
|
:label="t('logistique.weighingTickets.form.date')"
|
||||||
|
:required="true"
|
||||||
|
:editable="true"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors.date"
|
||||||
|
@update:model-value="(v: string | null) => emitBlock('date', v)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Poids : champ texte verrouillé sur les chiffres, toujours désactivé
|
||||||
|
(rempli par la pesée, jamais saisi à la main — RG-5.07). -->
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="weightDisplay"
|
||||||
|
:mask="NUMERIC_MASK"
|
||||||
|
:label="t('logistique.weighingTickets.form.weight')"
|
||||||
|
:required="true"
|
||||||
|
:disabled="true"
|
||||||
|
:error="errors.weight"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- DSD : champ texte verrouillé sur les chiffres, toujours désactivé
|
||||||
|
(rempli par la pesée — RG-5.04 / RG-5.07). -->
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="dsdDisplay"
|
||||||
|
:mask="NUMERIC_MASK"
|
||||||
|
:label="t('logistique.weighingTickets.form.dsd')"
|
||||||
|
:required="true"
|
||||||
|
:disabled="true"
|
||||||
|
:error="errors.dsd"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||||
|
import { NUMERIC_MASK } from '~/modules/logistique/utils/weighingMasks'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bloc de pesée (« Poids à vide » ou « Poids à plein ») de l'écran Ticket de pesée.
|
||||||
|
* Champs Date/heure / Poids / DSD + boutons de pesée (bascule / manuelle). Depuis
|
||||||
|
* ERP-193, la contrepartie, l'immatriculation et « Tout format » sont remontés dans
|
||||||
|
* les 4 champs du haut de page (hors blocs). Masque numérique factorisé dans
|
||||||
|
* `utils/weighingMasks`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
/** Identifiant technique du bloc (pour les `id` de champs uniques). */
|
||||||
|
blockId: string
|
||||||
|
title: string
|
||||||
|
block: WeighingBlockState
|
||||||
|
/** Erreurs 422 par champ (propertyPath → message). */
|
||||||
|
errors?: Record<string, string>
|
||||||
|
disabled?: boolean
|
||||||
|
}>(), {
|
||||||
|
errors: () => ({}),
|
||||||
|
disabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:block': [field: keyof WeighingBlockState, value: unknown]
|
||||||
|
'request-auto': []
|
||||||
|
'request-manual': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// Poids / DSD : champs texte → on présente l'entier sous forme de chaîne (vide
|
||||||
|
// tant que la pesée n'a pas rempli la valeur).
|
||||||
|
const weightDisplay = computed(() => (props.block.weight === null ? '' : String(props.block.weight)))
|
||||||
|
const dsdDisplay = computed(() => (props.block.dsd === null ? '' : String(props.block.dsd)))
|
||||||
|
|
||||||
|
/** Remonte la mutation d'un champ du bloc au parent (état des pesées centralisé). */
|
||||||
|
function emitBlock(field: keyof WeighingBlockState, value: unknown): void {
|
||||||
|
emit('update:block', field, value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
// useApi / useI18n sont des auto-imports Nuxt : on les expose en globals.
|
||||||
|
const mockPost = vi.hoisted(() => vi.fn())
|
||||||
|
vi.stubGlobal('useApi', () => ({ post: mockPost }))
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
|
||||||
|
const { useWeighbridge } = await import('../useWeighbridge')
|
||||||
|
|
||||||
|
describe('useWeighbridge', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPost.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('AUTO : POST { mode: AUTO } sans toast et renvoie la lecture', async () => {
|
||||||
|
mockPost.mockResolvedValue({ weight: 23187, dsd: 42, mode: 'AUTO' })
|
||||||
|
const { triggerAuto } = useWeighbridge()
|
||||||
|
|
||||||
|
const reading = await triggerAuto()
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/weighbridge_readings',
|
||||||
|
{ mode: 'AUTO' },
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
expect(reading).toEqual({ weight: 23187, dsd: 42, mode: 'AUTO' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('MANUAL : POST { mode: MANUAL, weight, dsd } et renvoie la lecture', async () => {
|
||||||
|
// Le DSD est saisi par l'opérateur et conservé tel quel (ERP-193).
|
||||||
|
mockPost.mockResolvedValue({ weight: 5000, dsd: 16619, mode: 'MANUAL' })
|
||||||
|
const { triggerManual } = useWeighbridge()
|
||||||
|
|
||||||
|
const reading = await triggerManual(5000, 16619)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/weighbridge_readings',
|
||||||
|
{ mode: 'MANUAL', weight: 5000, dsd: 16619 },
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
expect(reading.dsd).toBe(16619)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erreur (RG-5.06) : extractWeighbridgeError privilégie le detail du 503', () => {
|
||||||
|
const { extractWeighbridgeError } = useWeighbridge()
|
||||||
|
const error = { response: { status: 503, _data: { title: 'Pont bascule indisponible', detail: 'Passez en pesée manuelle.' } } }
|
||||||
|
expect(extractWeighbridgeError(error)).toBe('Passez en pesée manuelle.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erreur sans payload exploitable : retombe sur le libellé i18n générique', () => {
|
||||||
|
const { extractWeighbridgeError } = useWeighbridge()
|
||||||
|
expect(extractWeighbridgeError(new Error('network')))
|
||||||
|
.toBe('logistique.weighingTickets.form.weighbridge.unavailable')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('triggerAuto propage l\'erreur API (gestion par l\'écran)', async () => {
|
||||||
|
mockPost.mockRejectedValue({ response: { status: 503 } })
|
||||||
|
const { triggerAuto } = useWeighbridge()
|
||||||
|
await expect(triggerAuto()).rejects.toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
|
||||||
|
// `nowIsoDateTime` est importé par le composable : on le stubbe pour un instant déterministe.
|
||||||
|
vi.mock('~/shared/utils/date', () => ({ nowIsoDateTime: () => '2026-06-22T08:30:00' }))
|
||||||
|
|
||||||
|
const { useWeighingTicketForm } = await import('../useWeighingTicketForm')
|
||||||
|
|
||||||
|
describe('useWeighingTicketForm', () => {
|
||||||
|
it('initialise les 2 blocs à la date/heure courante (RG-5.07), sans poids ni DSD', () => {
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
expect(form.empty.date).toBe('2026-06-22T08:30:00')
|
||||||
|
expect(form.full.date).toBe('2026-06-22T08:30:00')
|
||||||
|
expect(form.empty.weight).toBeNull()
|
||||||
|
expect(form.empty.dsd).toBeNull()
|
||||||
|
expect(form.counterpartyType.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Omission des requis vides (compact) ──────────────────────────────────
|
||||||
|
it('buildDraftPayload : brouillon vierge → pas de champ requis ni de bloc non pesé', () => {
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
// Formulaire vierge : counterpartyType / immatriculation non remplis, aucune pesée.
|
||||||
|
const payload = form.buildDraftPayload()
|
||||||
|
// Absents (et non null) → le back laisse jouer les contraintes du groupe finalize.
|
||||||
|
expect(payload).not.toHaveProperty('counterpartyType')
|
||||||
|
expect(payload).not.toHaveProperty('immatriculation')
|
||||||
|
// Bloc non pesé → ni poids ni date (on n'envoie pas une date de pesée sans pesée).
|
||||||
|
expect(payload).not.toHaveProperty('emptyWeight')
|
||||||
|
expect(payload).not.toHaveProperty('emptyDate')
|
||||||
|
// Seul le booléen « Tout format » reste.
|
||||||
|
expect(payload.plateFreeFormat).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Pesée obligatoire front-only (RG-5.07) ───────────────────────────────
|
||||||
|
it('missingWeighingFields liste Poids/DSD manquants, puis vide après pesée', () => {
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
expect(form.missingWeighingFields('empty')).toEqual(['emptyWeight', 'emptyDsd'])
|
||||||
|
expect(form.missingWeighingFields('full')).toEqual(['fullWeight', 'fullDsd'])
|
||||||
|
|
||||||
|
form.applyReading(form.empty, { weight: 7150, dsd: 1, mode: 'AUTO' })
|
||||||
|
expect(form.missingWeighingFields('empty')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Contrepartie conditionnelle (RG-5.03) ────────────────────────────────
|
||||||
|
it('CLIENT : ne conserve que le client, purge supplier et otherLabel', () => {
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
form.supplierIri.value = '/api/suppliers/3'
|
||||||
|
form.otherLabel.value = 'Particulier'
|
||||||
|
|
||||||
|
form.setCounterpartyType('CLIENT')
|
||||||
|
form.clientIri.value = '/api/clients/629'
|
||||||
|
|
||||||
|
expect(form.counterpartyField.value).toBe('client')
|
||||||
|
expect(form.supplierIri.value).toBeNull()
|
||||||
|
expect(form.otherLabel.value).toBeNull()
|
||||||
|
|
||||||
|
const payload = form.buildDraftPayload()
|
||||||
|
expect(payload.counterpartyType).toBe('CLIENT')
|
||||||
|
expect(payload.client).toBe('/api/clients/629')
|
||||||
|
expect(payload).not.toHaveProperty('supplier')
|
||||||
|
expect(payload).not.toHaveProperty('otherLabel')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FOURNISSEUR : ne conserve que le supplier', () => {
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
form.clientIri.value = '/api/clients/1'
|
||||||
|
form.setCounterpartyType('FOURNISSEUR')
|
||||||
|
form.supplierIri.value = '/api/suppliers/7'
|
||||||
|
|
||||||
|
expect(form.counterpartyField.value).toBe('supplier')
|
||||||
|
expect(form.clientIri.value).toBeNull()
|
||||||
|
expect(form.buildDraftPayload().supplier).toBe('/api/suppliers/7')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('AUTRE : ne conserve que le libellé libre', () => {
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
form.clientIri.value = '/api/clients/1'
|
||||||
|
form.setCounterpartyType('AUTRE')
|
||||||
|
form.otherLabel.value = 'Reprise interne'
|
||||||
|
|
||||||
|
expect(form.counterpartyField.value).toBe('other')
|
||||||
|
expect(form.clientIri.value).toBeNull()
|
||||||
|
expect(form.buildDraftPayload().otherLabel).toBe('Reprise interne')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('buildDraftPayload : type choisi mais champ associé vide → contrepartie omise (pas de 500 chk_wt_*_branch)', () => {
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
// L'opérateur ouvre le menu « Client » mais n'a pas encore choisi le client.
|
||||||
|
form.setCounterpartyType('CLIENT')
|
||||||
|
|
||||||
|
const draft = form.buildDraftPayload()
|
||||||
|
// On n'émet ni le type ni la FK : un brouillon incohérent serait rejeté en 500 par le back.
|
||||||
|
expect(draft).not.toHaveProperty('counterpartyType')
|
||||||
|
expect(draft).not.toHaveProperty('client')
|
||||||
|
|
||||||
|
// En revanche la validation envoie toujours le type, pour déclencher la 422 métier.
|
||||||
|
expect(form.buildValidatePayload().counterpartyType).toBe('CLIENT')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('buildDraftPayload : AUTRE avec libellé vide → contrepartie omise', () => {
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
form.setCounterpartyType('AUTRE')
|
||||||
|
form.otherLabel.value = ' '
|
||||||
|
|
||||||
|
const draft = form.buildDraftPayload()
|
||||||
|
expect(draft).not.toHaveProperty('counterpartyType')
|
||||||
|
expect(draft).not.toHaveProperty('otherLabel')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Immatriculation / « Tout format » partagés entre blocs (RG-5.01) ──────
|
||||||
|
it('immatriculation et plateFreeFormat sont partagés (une seule valeur)', () => {
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
form.immatriculation.value = 'AB-123-CD'
|
||||||
|
form.plateFreeFormat.value = true
|
||||||
|
|
||||||
|
// Les 2 payloads (brouillon + validation) reflètent la même valeur.
|
||||||
|
expect(form.buildDraftPayload().immatriculation).toBe('AB-123-CD')
|
||||||
|
expect(form.buildDraftPayload().plateFreeFormat).toBe(true)
|
||||||
|
expect(form.buildValidatePayload().immatriculation).toBe('AB-123-CD')
|
||||||
|
expect(form.buildValidatePayload().plateFreeFormat).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Application d'une lecture de pesée ────────────────────────────────────
|
||||||
|
it('applyReading remplit poids / DSD / mode et ré-horodate le bloc à l\'instant de la pesée', () => {
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
// Date périmée (ouverture du formulaire bien avant la pesée).
|
||||||
|
form.empty.date = '2020-01-01T00:00:00'
|
||||||
|
form.applyReading(form.empty, { weight: 7150, dsd: 1, mode: 'AUTO' })
|
||||||
|
// La pesée validée ré-horodate le bloc à maintenant (stub 2026-06-22T08:30:00).
|
||||||
|
expect(form.empty.date).toBe('2026-06-22T08:30:00')
|
||||||
|
expect(form.empty.weight).toBe(7150)
|
||||||
|
expect(form.empty.dsd).toBe(1)
|
||||||
|
expect(form.empty.mode).toBe('AUTO')
|
||||||
|
|
||||||
|
// Pesée manuelle : le DSD saisi (16619) est conservé tel quel (ERP-193).
|
||||||
|
form.applyReading(form.full, { weight: 14300, dsd: 16619, mode: 'MANUAL' })
|
||||||
|
expect(form.full.weight).toBe(14300)
|
||||||
|
expect(form.full.dsd).toBe(16619)
|
||||||
|
expect(form.full.mode).toBe('MANUAL')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('buildDraftPayload porte les pesées effectuées ; buildValidatePayload les 4 champs du haut', () => {
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
form.setCounterpartyType('CLIENT')
|
||||||
|
form.clientIri.value = '/api/clients/1'
|
||||||
|
form.immatriculation.value = 'AB-123-CD'
|
||||||
|
form.applyReading(form.empty, { weight: 7150, dsd: 1, mode: 'AUTO' })
|
||||||
|
form.applyReading(form.full, { weight: 14300, dsd: 2, mode: 'AUTO' })
|
||||||
|
|
||||||
|
// Le brouillon porte LES DEUX pesées effectuées.
|
||||||
|
const draft = form.buildDraftPayload()
|
||||||
|
expect(draft.emptyWeight).toBe(7150)
|
||||||
|
expect(draft.emptyMode).toBe('AUTO')
|
||||||
|
expect(draft.fullWeight).toBe(14300)
|
||||||
|
expect(draft.fullMode).toBe('AUTO')
|
||||||
|
|
||||||
|
// La validation ne porte que les 4 champs du haut (pesées déjà persistées).
|
||||||
|
const validate = form.buildValidatePayload()
|
||||||
|
expect(validate.counterpartyType).toBe('CLIENT')
|
||||||
|
expect(validate.client).toBe('/api/clients/1')
|
||||||
|
expect(validate.immatriculation).toBe('AB-123-CD')
|
||||||
|
expect(validate).not.toHaveProperty('emptyWeight')
|
||||||
|
expect(validate).not.toHaveProperty('fullWeight')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Pré-remplissage (écran Modification, ERP-190) ─────────────────────────
|
||||||
|
it('hydrate pré-remplit l\'état depuis le détail (datetime ISO ramené en local, heure conservée)', () => {
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
form.hydrate({
|
||||||
|
id: 9,
|
||||||
|
counterpartyType: 'CLIENT',
|
||||||
|
client: { '@id': '/api/clients/629' },
|
||||||
|
immatriculation: 'AB-123-CD',
|
||||||
|
plateFreeFormat: false,
|
||||||
|
emptyDate: '2026-06-17T09:00:00+02:00',
|
||||||
|
emptyWeight: 7150,
|
||||||
|
emptyDsd: 1,
|
||||||
|
emptyMode: 'AUTO',
|
||||||
|
fullDate: '2026-06-17T09:12:00+02:00',
|
||||||
|
fullWeight: 14300,
|
||||||
|
fullDsd: 2,
|
||||||
|
fullMode: 'AUTO',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(form.ticketId.value).toBe(9)
|
||||||
|
expect(form.counterpartyType.value).toBe('CLIENT')
|
||||||
|
expect(form.counterpartyField.value).toBe('client')
|
||||||
|
expect(form.clientIri.value).toBe('/api/clients/629')
|
||||||
|
expect(form.immatriculation.value).toBe('AB-123-CD')
|
||||||
|
// Datetime back (avec fuseau) -> local sans fuseau, heure conservée pour MalioDateTime.
|
||||||
|
expect(form.empty.date).toBe('2026-06-17T09:00:00')
|
||||||
|
expect(form.full.date).toBe('2026-06-17T09:12:00')
|
||||||
|
expect(form.empty.weight).toBe(7150)
|
||||||
|
expect(form.full.weight).toBe(14300)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hydrate gère les champs null omis (skip_null_values) avec des défauts', () => {
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
form.hydrate({ id: 5, counterpartyType: 'AUTRE', otherLabel: 'Reprise' })
|
||||||
|
expect(form.otherLabel.value).toBe('Reprise')
|
||||||
|
expect(form.supplierIri.value).toBeNull()
|
||||||
|
expect(form.plateFreeFormat.value).toBe(false)
|
||||||
|
// Pas de date back -> repli sur l'instant courant (stub 2026-06-22T08:30:00).
|
||||||
|
expect(form.empty.date).toBe('2026-06-22T08:30:00')
|
||||||
|
expect(form.empty.weight).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('buildDraftPayload après hydrate porte contrepartie + véhicule + les 2 pesées', () => {
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
form.hydrate({
|
||||||
|
id: 9,
|
||||||
|
status: 'VALIDATED',
|
||||||
|
counterpartyType: 'CLIENT',
|
||||||
|
client: { '@id': '/api/clients/629' },
|
||||||
|
immatriculation: 'AB-123-CD',
|
||||||
|
emptyWeight: 7150, emptyDsd: 1, emptyMode: 'AUTO',
|
||||||
|
fullWeight: 14300, fullDsd: 2, fullMode: 'AUTO',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(form.status.value).toBe('VALIDATED')
|
||||||
|
|
||||||
|
const payload = form.buildDraftPayload()
|
||||||
|
expect(payload.counterpartyType).toBe('CLIENT')
|
||||||
|
expect(payload.client).toBe('/api/clients/629')
|
||||||
|
expect(payload.emptyWeight).toBe(7150)
|
||||||
|
expect(payload.fullWeight).toBe(14300)
|
||||||
|
expect(payload.immatriculation).toBe('AB-123-CD')
|
||||||
|
})
|
||||||
|
})
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { useWeighingTicketsRepository, type WeighingTicket } from '../useWeighingTicketsRepository'
|
||||||
|
|
||||||
|
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||||
|
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests du repertoire des tickets de pesee (M5, ERP-188).
|
||||||
|
*
|
||||||
|
* `useWeighingTicketsRepository` est une fine enveloppe de
|
||||||
|
* `usePaginatedList<WeighingTicket>` sur `/weighing_tickets`. 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 `/weighing_tickets` ;
|
||||||
|
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
|
||||||
|
* renvoie un tableau plat sans pagination) ;
|
||||||
|
* - DEFAUT 25 ITEMS/PAGE : la liste etant consultee en volume, le premier
|
||||||
|
* fetch demande 25 items (et non le defaut 10) — l'utilisateur peut toujours
|
||||||
|
* rebasculer via le selecteur.
|
||||||
|
*/
|
||||||
|
describe('useWeighingTicketsRepository', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockApiGet.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Une page de tickets Hydra minimale. */
|
||||||
|
const PAGE: WeighingTicket[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
status: 'VALIDATED',
|
||||||
|
number: '86-TP-0001',
|
||||||
|
client: { id: 7, companyName: 'ACME' },
|
||||||
|
supplier: null,
|
||||||
|
otherLabel: null,
|
||||||
|
displayDate: '2026-06-17T09:12:00+02:00',
|
||||||
|
netWeight: 7150,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
it('cible /weighing_tickets en Hydra avec 25 items/page par defaut', async () => {
|
||||||
|
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||||
|
const repo = useWeighingTicketsRepository()
|
||||||
|
|
||||||
|
await repo.fetch()
|
||||||
|
|
||||||
|
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
||||||
|
const [url, query, opts] = mockApiGet.mock.calls[0]
|
||||||
|
expect(url).toBe('/weighing_tickets')
|
||||||
|
expect(query).toMatchObject({ page: 1, itemsPerPage: 25 })
|
||||||
|
expect(opts).toMatchObject({
|
||||||
|
toast: false,
|
||||||
|
headers: { Accept: 'application/ld+json' },
|
||||||
|
})
|
||||||
|
expect(repo.itemsPerPage.value).toBe(25)
|
||||||
|
expect(repo.items.value).toEqual(PAGE)
|
||||||
|
expect(repo.totalItems.value).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Pesée au pont bascule (M5, ERP-189) — déclenche une lecture de poids via
|
||||||
|
* `POST /api/weighbridge_readings` (spec-back § 4.2). Action autonome : le ticket
|
||||||
|
* n'existe pas encore quand on pèse depuis le formulaire principal.
|
||||||
|
*
|
||||||
|
* Deux modes :
|
||||||
|
* - AUTO (« Pesée bascule ») : le serveur résout le site courant, lit le poids
|
||||||
|
* (stub aléatoire au M5) et alloue le DSD. Peut échouer (RG-5.06 → 503) : le
|
||||||
|
* pont est indisponible, on invite l'utilisateur à passer en pesée manuelle.
|
||||||
|
* - MANUAL (« Pesée manuelle ») : poids + DSD saisis par l'opérateur ; le serveur
|
||||||
|
* les conserve tels quels — plus d'auto-incrément (ERP-193).
|
||||||
|
*
|
||||||
|
* Composable UI-agnostique : il appelle l'API (`useApi`, jamais `$fetch`) et
|
||||||
|
* renvoie la lecture, ou lève l'erreur — la gestion de la modal/de l'affichage
|
||||||
|
* reste à la charge de l'écran. `extractWeighbridgeError` factorise la lecture
|
||||||
|
* du message d'erreur 503 (RG-5.06) pour l'afficher dans la modal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Mode de pesée — miroir de l'enum back. */
|
||||||
|
export type WeighbridgeMode = 'AUTO' | 'MANUAL'
|
||||||
|
|
||||||
|
/** Lecture renvoyée par le pont bascule (spec-back § 4.2). */
|
||||||
|
export interface WeighbridgeReading {
|
||||||
|
weight: number
|
||||||
|
dsd: number
|
||||||
|
mode: WeighbridgeMode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWeighbridge() {
|
||||||
|
const api = useApi()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pesée bascule (AUTO). Le site courant est résolu serveur — rien à envoyer.
|
||||||
|
* `toast: false` : l'erreur (RG-5.06) est affichée inline dans la modal, pas
|
||||||
|
* en toast global.
|
||||||
|
*/
|
||||||
|
async function triggerAuto(): Promise<WeighbridgeReading> {
|
||||||
|
return await api.post<WeighbridgeReading>(
|
||||||
|
'/weighbridge_readings',
|
||||||
|
{ mode: 'AUTO' },
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pesée manuelle (MANUAL). Le poids ET le DSD sont saisis par l'opérateur (le
|
||||||
|
* DSD = numéro du pont réellement utilisé) et conservés tels quels (ERP-193).
|
||||||
|
*/
|
||||||
|
async function triggerManual(weight: number, dsd: number): Promise<WeighbridgeReading> {
|
||||||
|
return await api.post<WeighbridgeReading>(
|
||||||
|
'/weighbridge_readings',
|
||||||
|
{ mode: 'MANUAL', weight, dsd },
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message d'erreur de pesée bascule (RG-5.06). Le back renvoie un 503
|
||||||
|
* `{ title, detail }` (« Pont bascule indisponible » / « Passez en pesée
|
||||||
|
* manuelle. ») — on privilégie le `detail`, puis le `title`, sinon un libellé
|
||||||
|
* générique invitant à la pesée manuelle.
|
||||||
|
*/
|
||||||
|
function extractWeighbridgeError(error: unknown): string {
|
||||||
|
const data = (error as { response?: { _data?: unknown } })?.response?._data as
|
||||||
|
| { detail?: string, title?: string }
|
||||||
|
| undefined
|
||||||
|
return data?.detail || data?.title || t('logistique.weighingTickets.form.weighbridge.unavailable')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { triggerAuto, triggerManual, extractWeighbridgeError }
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge'
|
||||||
|
import type { CounterpartyType, WeighingTicketStatus } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détail d'un ticket de pesée (`GET /api/weighing_tickets/{id}`, spec-back
|
||||||
|
* § 4.0.bis). Champs null OMIS du JSON (`skip_null_values`) → tous optionnels,
|
||||||
|
* lus avec un défaut côté hydratation du formulaire.
|
||||||
|
*/
|
||||||
|
export interface WeighingTicketDetail {
|
||||||
|
id: number
|
||||||
|
/** Cycle de vie (DRAFT/VALIDATED, ERP-193). */
|
||||||
|
status?: WeighingTicketStatus
|
||||||
|
/** Numéro `{siteCode}-TP-{NNNN}` — null tant que brouillon, immuable ensuite (RG-5.09). */
|
||||||
|
number?: string | null
|
||||||
|
/** Site rattaché (embarqué) — immuable (RG-5.09). */
|
||||||
|
site?: { id: number, name: string, code: string } | null
|
||||||
|
counterpartyType?: CounterpartyType | null
|
||||||
|
client?: { '@id': string, companyName: string } | null
|
||||||
|
supplier?: { '@id': string, companyName: string } | null
|
||||||
|
otherLabel?: string | null
|
||||||
|
immatriculation?: string | null
|
||||||
|
plateFreeFormat?: boolean
|
||||||
|
// Pesée à vide
|
||||||
|
emptyDate?: string | null
|
||||||
|
emptyWeight?: number | null
|
||||||
|
emptyDsd?: number | null
|
||||||
|
emptyMode?: WeighbridgeMode | null
|
||||||
|
// Pesée à plein
|
||||||
|
fullDate?: string | null
|
||||||
|
fullWeight?: number | null
|
||||||
|
fullDsd?: number | null
|
||||||
|
fullMode?: WeighbridgeMode | null
|
||||||
|
netWeight?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge le détail d'un ticket de pesée pour l'écran de modification (M5,
|
||||||
|
* ERP-190). `Accept: application/ld+json` impose l'enveloppe Hydra (relations
|
||||||
|
* embarquées : client/supplier/site). Appel via `useApi()` (jamais `$fetch`).
|
||||||
|
*/
|
||||||
|
export function useWeighingTicket() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function fetchTicket(id: number | string): Promise<WeighingTicketDetail> {
|
||||||
|
return await api.get<WeighingTicketDetail>(
|
||||||
|
`/weighing_tickets/${id}`,
|
||||||
|
{},
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fetchTicket }
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { nowIsoDateTime } from '~/shared/utils/date'
|
||||||
|
import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* État et logique du formulaire « Ajouter / Modifier un ticket de pesée » (M5,
|
||||||
|
* ERP-189). L'écran est composé de DEUX blocs empilés — pesée à vide puis pesée
|
||||||
|
* à plein — qui partagent un même véhicule.
|
||||||
|
*
|
||||||
|
* Points clés (spec-front § Écran Ajouter, spec-back § 2.4 / 2.9 / 2.10) :
|
||||||
|
* - **Contrepartie conditionnelle (RG-5.03)** : `counterpartyType` (CLIENT /
|
||||||
|
* FOURNISSEUR / AUTRE) pilote le champ requis (client / supplier / otherLabel).
|
||||||
|
* Changer de type purge les champs des autres types — aucune donnée fantôme.
|
||||||
|
* - **Immatriculation + « Tout format »** font partie des 4 champs du haut, hors
|
||||||
|
* blocs (ERP-193). Une seule valeur, partagée entre les 2 pesées (RG-5.01).
|
||||||
|
* - **Cycle brouillon -> validé (ERP-193)** : `buildDraftPayload()` persiste l'état
|
||||||
|
* courant (pesée enregistrée dès la validation de sa modale, même sans
|
||||||
|
* contrepartie/immat) via POST (création du brouillon) puis PATCH ; quand les 3
|
||||||
|
* champs du haut + les 2 pesées sont là, `buildValidatePayload()` finalise via
|
||||||
|
* `PATCH /weighing_tickets/{id}/validate` (numéro attribué, status VALIDATED).
|
||||||
|
*
|
||||||
|
* Composable UI-agnostique et testable : aucune dépendance API ici (les appels
|
||||||
|
* vivent dans l'écran via `useApi`). Instancié PAR écran (refs locales).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Type de contrepartie — miroir de l'enum back (spec-back § 2.9). */
|
||||||
|
export type CounterpartyType = 'CLIENT' | 'FOURNISSEUR' | 'AUTRE'
|
||||||
|
|
||||||
|
/** Saisie d'une pesée (bloc vide OU bloc plein). */
|
||||||
|
export interface WeighingBlockState {
|
||||||
|
/** Date/heure de la pesée (ISO local `YYYY-MM-DDTHH:mm:ss`) — date du jour + heure courante par défaut (RG-5.07). */
|
||||||
|
date: string | null
|
||||||
|
/** Poids en kg — readonly, rempli par la pesée (bascule ou manuelle). */
|
||||||
|
weight: number | null
|
||||||
|
/** DSD — pesée bascule : fourni par le pont ; pesée manuelle : saisi (RG-5.04, ERP-193). */
|
||||||
|
dsd: number | null
|
||||||
|
/** Mode de la dernière pesée appliquée au bloc. */
|
||||||
|
mode: WeighbridgeMode | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cycle de vie du ticket (miroir back, ERP-193). */
|
||||||
|
export type WeighingTicketStatus = 'DRAFT' | 'VALIDATED'
|
||||||
|
|
||||||
|
/** Forme minimale d'un détail de ticket consommée par `hydrate` (cf. useWeighingTicket). */
|
||||||
|
export interface WeighingTicketHydration {
|
||||||
|
id: number
|
||||||
|
status?: WeighingTicketStatus
|
||||||
|
counterpartyType?: CounterpartyType | null
|
||||||
|
client?: { '@id': string } | null
|
||||||
|
supplier?: { '@id': string } | null
|
||||||
|
otherLabel?: string | null
|
||||||
|
immatriculation?: string | null
|
||||||
|
plateFreeFormat?: boolean
|
||||||
|
emptyDate?: string | null
|
||||||
|
emptyWeight?: number | null
|
||||||
|
emptyDsd?: number | null
|
||||||
|
emptyMode?: WeighbridgeMode | null
|
||||||
|
fullDate?: string | null
|
||||||
|
fullWeight?: number | null
|
||||||
|
fullDsd?: number | null
|
||||||
|
fullMode?: WeighbridgeMode | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ramène une chaîne ISO datetime du back (`2026-06-17T09:00:00+02:00`) au format
|
||||||
|
* local `YYYY-MM-DDTHH:mm:ss` attendu par MalioDateTime (secondes, sans fuseau) :
|
||||||
|
* on garde les 19 premiers caractères (date + heure), on retire l'offset. Null si
|
||||||
|
* absente.
|
||||||
|
*/
|
||||||
|
function toLocalIsoDateTime(value: string | null | undefined): string | null {
|
||||||
|
return value ? value.slice(0, 19) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retire les clés à valeur `null` d'un payload (pattern « omission des requis
|
||||||
|
* vides » M1). Avec `collectDenormalizationErrors` côté back, envoyer `null` sur
|
||||||
|
* un scalaire requis (ex. `counterpartyType`) produit une violation de TYPE
|
||||||
|
* opaque (« Cette valeur doit être de type string. ») au lieu du message métier
|
||||||
|
* `NotBlank` : une clé ABSENTE laisse au contraire jouer la contrainte `NotBlank`
|
||||||
|
* et son message FR. On omet donc les null ; les champs réellement requis non
|
||||||
|
* remplis déclenchent leur vrai message, les optionnels restent simplement absents.
|
||||||
|
*/
|
||||||
|
function compact(payload: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== null))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Crée l'état initial d'un bloc de pesée (date/heure = maintenant, RG-5.07). */
|
||||||
|
function emptyBlock(now: string): WeighingBlockState {
|
||||||
|
return {
|
||||||
|
date: now,
|
||||||
|
weight: null,
|
||||||
|
dsd: null,
|
||||||
|
mode: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWeighingTicketForm() {
|
||||||
|
const now = nowIsoDateTime()
|
||||||
|
|
||||||
|
// ── Contrepartie (RG-5.03) ───────────────────────────────────────────────
|
||||||
|
const counterpartyType = ref<CounterpartyType | null>(null)
|
||||||
|
const clientIri = ref<string | null>(null)
|
||||||
|
const supplierIri = ref<string | null>(null)
|
||||||
|
const otherLabel = ref<string | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change le type de contrepartie et purge les champs devenus hors-sujet :
|
||||||
|
* un seul de client / supplier / otherLabel est conservé selon le type
|
||||||
|
* (RG-5.03 — pas de FK fantôme envoyée au back).
|
||||||
|
*/
|
||||||
|
function setCounterpartyType(type: CounterpartyType | null): void {
|
||||||
|
counterpartyType.value = type
|
||||||
|
if (type !== 'CLIENT') clientIri.value = null
|
||||||
|
if (type !== 'FOURNISSEUR') supplierIri.value = null
|
||||||
|
if (type !== 'AUTRE') otherLabel.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Véhicule : partagé entre les 2 blocs (RG-5.01) ────────────────────────
|
||||||
|
// Refs UNIQUES : les 2 blocs bindent la même valeur → connexion automatique.
|
||||||
|
const immatriculation = ref<string | null>(null)
|
||||||
|
const plateFreeFormat = ref<boolean>(false)
|
||||||
|
|
||||||
|
// ── Les deux pesées ───────────────────────────────────────────────────────
|
||||||
|
const empty = reactive<WeighingBlockState>(emptyBlock(now))
|
||||||
|
const full = reactive<WeighingBlockState>(emptyBlock(now))
|
||||||
|
|
||||||
|
// Id du ticket persisté (POST du 1er enregistrement de pesée) — pilote ensuite
|
||||||
|
// les PATCH (brouillon) puis la validation. Null tant que rien n'est persisté.
|
||||||
|
const ticketId = ref<number | null>(null)
|
||||||
|
|
||||||
|
// Cycle de vie courant (DRAFT tant que non validé, ERP-193).
|
||||||
|
const status = ref<WeighingTicketStatus>('DRAFT')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champ de contrepartie attendu selon le type courant — utilisé par l'écran
|
||||||
|
* pour afficher conditionnellement le bon champ (RG-5.03).
|
||||||
|
*/
|
||||||
|
const counterpartyField = computed<'client' | 'supplier' | 'other' | null>(() => {
|
||||||
|
switch (counterpartyType.value) {
|
||||||
|
case 'CLIENT': return 'client'
|
||||||
|
case 'FOURNISSEUR': return 'supplier'
|
||||||
|
case 'AUTRE': return 'other'
|
||||||
|
default: return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champs de pesée manquants d'un bloc (Poids / DSD), RG-5.07. Le back rend ces
|
||||||
|
* colonnes nullable (workflow 2 temps) : l'obligation « une pesée a été
|
||||||
|
* effectuée » est donc portée côté front (règle front-only, ERP-101). Renvoie
|
||||||
|
* les `propertyPath` manquants (ex. `['emptyWeight', 'emptyDsd']`), prêts à
|
||||||
|
* être posés en erreur inline via `useFormErrors.setError`.
|
||||||
|
*/
|
||||||
|
function missingWeighingFields(which: 'empty' | 'full'): string[] {
|
||||||
|
const block = which === 'empty' ? empty : full
|
||||||
|
const missing: string[] = []
|
||||||
|
if (block.weight === null) missing.push(`${which}Weight`)
|
||||||
|
if (block.dsd === null) missing.push(`${which}Dsd`)
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applique une lecture de pesée (bascule/manuelle) à un bloc. La pesée étant
|
||||||
|
* effectuée À CET INSTANT, on (ré)horodate le bloc à maintenant : la date/heure
|
||||||
|
* du ticket reflète le moment réel de la pesée validée, pas l'ouverture du
|
||||||
|
* formulaire (RG-5.07).
|
||||||
|
*/
|
||||||
|
function applyReading(
|
||||||
|
block: WeighingBlockState,
|
||||||
|
reading: { weight: number, dsd: number, mode: WeighbridgeMode },
|
||||||
|
): void {
|
||||||
|
block.date = nowIsoDateTime()
|
||||||
|
block.weight = reading.weight
|
||||||
|
block.dsd = reading.dsd
|
||||||
|
block.mode = reading.mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Partie « contrepartie » du payload (FK en IRI ou libellé libre). */
|
||||||
|
function counterpartyPayload(): Record<string, unknown> {
|
||||||
|
switch (counterpartyType.value) {
|
||||||
|
case 'CLIENT': return { client: clientIri.value }
|
||||||
|
case 'FOURNISSEUR': return { supplier: supplierIri.value }
|
||||||
|
case 'AUTRE': return { otherLabel: otherLabel.value || null }
|
||||||
|
default: return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrepartie d'un BROUILLON : on n'envoie le type QUE si son champ associé est
|
||||||
|
* renseigné. Un type sans son champ (l'opérateur a ouvert le menu avant de
|
||||||
|
* choisir) est une contrepartie incohérente que le back devrait retirer (sinon
|
||||||
|
* les CHECK chk_wt_*_branch lèvent une 500). On évite donc de l'émettre côté
|
||||||
|
* front. La cohérence reste exigée à la validation : `buildValidatePayload()`
|
||||||
|
* envoie toujours le type, pour déclencher la 422 métier sur le champ manquant.
|
||||||
|
*/
|
||||||
|
function draftCounterpartyPayload(): Record<string, unknown> {
|
||||||
|
switch (counterpartyType.value) {
|
||||||
|
case 'CLIENT':
|
||||||
|
return clientIri.value ? { counterpartyType: 'CLIENT', client: clientIri.value } : {}
|
||||||
|
case 'FOURNISSEUR':
|
||||||
|
return supplierIri.value ? { counterpartyType: 'FOURNISSEUR', supplier: supplierIri.value } : {}
|
||||||
|
case 'AUTRE':
|
||||||
|
return otherLabel.value && otherLabel.value.trim() !== ''
|
||||||
|
? { counterpartyType: 'AUTRE', otherLabel: otherLabel.value }
|
||||||
|
: {}
|
||||||
|
default:
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champs d'un bloc de pesée, UNIQUEMENT s'il a été pesé (poids renseigné) — on
|
||||||
|
* n'envoie pas la date par défaut d'un bloc vierge (sinon le back stockerait une
|
||||||
|
* date de pesée sans poids). Noms de clés alignés sur les `propertyPath` back.
|
||||||
|
*/
|
||||||
|
function blockPayload(prefix: 'empty' | 'full', block: WeighingBlockState): Record<string, unknown> {
|
||||||
|
if (block.weight === null) return {}
|
||||||
|
return {
|
||||||
|
[`${prefix}Date`]: block.date,
|
||||||
|
[`${prefix}Weight`]: block.weight,
|
||||||
|
[`${prefix}Dsd`]: block.dsd,
|
||||||
|
[`${prefix}Mode`]: block.mode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload de BROUILLON (POST création / PATCH mise à jour, ERP-193) : l'état
|
||||||
|
* courant complet (4 champs du haut + pesées effectuées). Aucun champ n'est
|
||||||
|
* requis ici (le back valide en mode relâché) — une pesée s'enregistre sans
|
||||||
|
* contrepartie ni immatriculation. Numéro/site/net attribués serveur.
|
||||||
|
*/
|
||||||
|
function buildDraftPayload(): Record<string, unknown> {
|
||||||
|
return compact({
|
||||||
|
...draftCounterpartyPayload(),
|
||||||
|
immatriculation: immatriculation.value || null,
|
||||||
|
plateFreeFormat: plateFreeFormat.value,
|
||||||
|
...blockPayload('empty', empty),
|
||||||
|
...blockPayload('full', full),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pré-remplit le formulaire à partir du détail d'un ticket existant (écran
|
||||||
|
* Modification, ERP-190). Le numéro et le site sont immuables (RG-5.09) →
|
||||||
|
* non repris dans l'état éditable (affichés en lecture seule par l'écran).
|
||||||
|
* Les dates ISO du back (datetime + fuseau) sont ramenées au format local
|
||||||
|
* `YYYY-MM-DDTHH:mm:ss` attendu par MalioDateTime (heure conservée).
|
||||||
|
*/
|
||||||
|
function hydrate(detail: WeighingTicketHydration): void {
|
||||||
|
ticketId.value = detail.id
|
||||||
|
status.value = detail.status ?? 'DRAFT'
|
||||||
|
counterpartyType.value = detail.counterpartyType ?? null
|
||||||
|
clientIri.value = detail.client?.['@id'] ?? null
|
||||||
|
supplierIri.value = detail.supplier?.['@id'] ?? null
|
||||||
|
otherLabel.value = detail.otherLabel ?? null
|
||||||
|
immatriculation.value = detail.immatriculation ?? null
|
||||||
|
plateFreeFormat.value = detail.plateFreeFormat ?? false
|
||||||
|
|
||||||
|
empty.date = toLocalIsoDateTime(detail.emptyDate) ?? now
|
||||||
|
empty.weight = detail.emptyWeight ?? null
|
||||||
|
empty.dsd = detail.emptyDsd ?? null
|
||||||
|
empty.mode = detail.emptyMode ?? null
|
||||||
|
|
||||||
|
full.date = toLocalIsoDateTime(detail.fullDate) ?? now
|
||||||
|
full.weight = detail.fullWeight ?? null
|
||||||
|
full.dsd = detail.fullDsd ?? null
|
||||||
|
full.mode = detail.fullMode ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload de VALIDATION (PATCH /weighing_tickets/{id}/validate, ERP-193) : les
|
||||||
|
* 4 champs du haut (contrepartie + immatriculation + « Tout format »). Les pesées
|
||||||
|
* sont déjà persistées par les enregistrements brouillon ; le back rejoue ici la
|
||||||
|
* validation stricte (groupe `finalize` : 3 champs requis + 2 pesées) et attribue
|
||||||
|
* le numéro. Les `propertyPath` des 422 sont mappés inline par useFormErrors.
|
||||||
|
*/
|
||||||
|
function buildValidatePayload(): Record<string, unknown> {
|
||||||
|
return compact({
|
||||||
|
counterpartyType: counterpartyType.value,
|
||||||
|
...counterpartyPayload(),
|
||||||
|
immatriculation: immatriculation.value || null,
|
||||||
|
plateFreeFormat: plateFreeFormat.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// contrepartie
|
||||||
|
counterpartyType,
|
||||||
|
counterpartyField,
|
||||||
|
clientIri,
|
||||||
|
supplierIri,
|
||||||
|
otherLabel,
|
||||||
|
setCounterpartyType,
|
||||||
|
// véhicule partagé
|
||||||
|
immatriculation,
|
||||||
|
plateFreeFormat,
|
||||||
|
// pesées
|
||||||
|
empty,
|
||||||
|
full,
|
||||||
|
applyReading,
|
||||||
|
missingWeighingFields,
|
||||||
|
// workflow
|
||||||
|
ticketId,
|
||||||
|
status,
|
||||||
|
hydrate,
|
||||||
|
buildDraftPayload,
|
||||||
|
buildValidatePayload,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Référentiels alimentant les selects de contrepartie de l'écran « Ticket de
|
||||||
|
* pesée » (M5, ERP-189) : liste des clients (M1) et des fournisseurs (M2).
|
||||||
|
*
|
||||||
|
* Collections récupérées en entier via l'échappatoire `?pagination=false`
|
||||||
|
* (référentiels de quelques dizaines d'entrées), avec l'en-tête
|
||||||
|
* `Accept: application/ld+json` imposé par API Platform 4 pour obtenir
|
||||||
|
* l'enveloppe Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`) —
|
||||||
|
* renvoyée telle quelle dans le payload POST/PATCH (relation ManyToOne).
|
||||||
|
*
|
||||||
|
* Miroir de `useClientReferentials` (M1). État 100 % local à l'instance.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Option au format attendu par MalioSelect ({ label, value }). */
|
||||||
|
export interface RefOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PartyMember {
|
||||||
|
'@id': string
|
||||||
|
companyName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||||
|
|
||||||
|
export function useWeighingTicketReferentials() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const clients = ref<RefOption[]>([])
|
||||||
|
const suppliers = ref<RefOption[]>([])
|
||||||
|
|
||||||
|
/** Récupère une collection complète (pagination désactivée) en Hydra. */
|
||||||
|
async function fetchAll(url: string): Promise<PartyMember[]> {
|
||||||
|
const res = await api.get<{ member?: PartyMember[] }>(
|
||||||
|
url,
|
||||||
|
{ pagination: 'false' },
|
||||||
|
{ headers: LD_JSON_HEADERS, toast: false },
|
||||||
|
)
|
||||||
|
return res.member ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge en parallèle clients + fournisseurs (résilient : un référentiel en
|
||||||
|
* échec — ex. 403 selon le rôle — laisse simplement son select vide sans
|
||||||
|
* faire échouer l'autre).
|
||||||
|
*/
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
await Promise.allSettled([
|
||||||
|
fetchAll('/clients').then((list) => {
|
||||||
|
clients.value = list.map(c => ({ value: c['@id'], label: c.companyName }))
|
||||||
|
}),
|
||||||
|
fetchAll('/suppliers').then((list) => {
|
||||||
|
suppliers.value = list.map(s => ({ value: s['@id'], label: s.companyName }))
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return { clients, suppliers, load }
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||||
|
import type { WeighingTicketStatus } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue MINIMALE d'une contrepartie embarquee (Client M1 ou Fournisseur M2) dans la
|
||||||
|
* LISTE des tickets de pesee. Seul `companyName` alimente les colonnes
|
||||||
|
* « Client » / « Fournisseur » ; l'objet sort embarque (`client:read` /
|
||||||
|
* `supplier:read`) ou est carrement absent du JSON quand null (`skip_null_values`,
|
||||||
|
* spec-back § 4.0.bis) — d'ou le `?? null` systematique cote page.
|
||||||
|
*/
|
||||||
|
export interface WeighingTicketParty {
|
||||||
|
id: number
|
||||||
|
companyName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue MINIMALE d'un ticket de pesee pour la datatable (M5, ERP-188). Volontairement
|
||||||
|
* partielle : seuls les champs des colonnes (docx p.3) + l'id (navigation) sont
|
||||||
|
* types. Le detail complet (pesees vide/plein, immatriculation, site, DSD) releve
|
||||||
|
* de l'ecran Modification (ERP-190) — hors perimetre de cet ecran.
|
||||||
|
*
|
||||||
|
* Contrepartie mutuellement exclusive (RG-5.03) : un seul de `client` / `supplier`
|
||||||
|
* / `otherLabel` est renseigne ; les deux autres sont omis du JSON (null).
|
||||||
|
* `displayDate` = getter serveur `fullDate ?? emptyDate` (spec-back § 4.0).
|
||||||
|
* `netWeight` = plein − vide en kg (RG-5.05).
|
||||||
|
*/
|
||||||
|
export interface WeighingTicket {
|
||||||
|
id: number
|
||||||
|
/** Cycle de vie : DRAFT (« En attente ») ou VALIDATED (« Terminée ») — ERP-193. */
|
||||||
|
status: WeighingTicketStatus
|
||||||
|
/** Numero metier `{siteCode}-TP-{NNNN}` — null tant que brouillon (RG-5.02). */
|
||||||
|
number: string | null
|
||||||
|
/** Embarque uniquement si contrepartie = Client (RG-5.03), sinon absent. */
|
||||||
|
client: WeighingTicketParty | null
|
||||||
|
/** Embarque uniquement si contrepartie = Fournisseur (RG-5.03), sinon absent. */
|
||||||
|
supplier: WeighingTicketParty | null
|
||||||
|
/** Libelle libre si contrepartie = Autre (RG-5.03), sinon absent. */
|
||||||
|
otherLabel: string | null
|
||||||
|
/** Date ISO du ticket (`fullDate ?? emptyDate`) — colonne « Date ». */
|
||||||
|
displayDate: string | null
|
||||||
|
/** Poids net en kg (= plein − vide, RG-5.05) — colonne « Poids ». */
|
||||||
|
netWeight: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtres de la liste des tickets de pesee, branches sur les query params de
|
||||||
|
* `GET /api/weighing_tickets` (spec-back § 4.1). La liste est par ailleurs
|
||||||
|
* cloisonnee par site courant cote back (`SiteScopedQueryExtension`, § 2.3) — le
|
||||||
|
* front n'a pas a envoyer le site.
|
||||||
|
*/
|
||||||
|
export interface WeighingTicketFilters {
|
||||||
|
search?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste des tickets de pesee (M5, ERP-188) — simple enveloppe de
|
||||||
|
* `usePaginatedList<WeighingTicket>` sur la ressource `/weighing_tickets`
|
||||||
|
* (URL API en snake_case ; la route Nuxt reste `/weighing-tickets`). Pagination
|
||||||
|
* serveur obligatoire (regle ABSOLUE n°13), etat 100 % local (regle ABSOLUE n°6).
|
||||||
|
*
|
||||||
|
* Miroir de `useCarriersRepository` (M4). Volontairement PAR INSTANCE (pas de
|
||||||
|
* singleton) : l'etat tableau est propre a l'ecran et meurt avec lui.
|
||||||
|
*/
|
||||||
|
export function useWeighingTicketsRepository() {
|
||||||
|
// Defaut 25 items/page (au lieu de 10) : la liste des tickets de pesee est
|
||||||
|
// consultee en volume. 25 fait partie des options [10, 25, 50] et reste sous le
|
||||||
|
// max serveur (50). L'utilisateur peut toujours basculer via le selecteur.
|
||||||
|
return usePaginatedList<WeighingTicket, WeighingTicketFilters>({
|
||||||
|
url: '/weighing_tickets',
|
||||||
|
defaultItemsPerPage: 25,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export default defineNuxtConfig({})
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, ref, reactive, Suspense } from 'vue'
|
||||||
|
|
||||||
|
// ── Mocks des composables modules (le form RÉEL est conservé pour vérifier le
|
||||||
|
// pré-remplissage via hydrate). ─────────────────────────────────────────────
|
||||||
|
const mockFetchTicket = vi.hoisted(() => vi.fn())
|
||||||
|
const mockPatch = vi.hoisted(() => vi.fn())
|
||||||
|
const mockPush = vi.hoisted(() => vi.fn())
|
||||||
|
const mockOpen = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.mock('~/modules/logistique/composables/useWeighingTicket', () => ({
|
||||||
|
useWeighingTicket: () => ({ fetchTicket: mockFetchTicket }),
|
||||||
|
}))
|
||||||
|
vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({
|
||||||
|
useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: vi.fn().mockResolvedValue(undefined) }),
|
||||||
|
}))
|
||||||
|
vi.mock('~/modules/logistique/composables/useWeighbridge', () => ({
|
||||||
|
useWeighbridge: () => ({ triggerAuto: vi.fn(), triggerManual: vi.fn(), extractWeighbridgeError: () => 'err' }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
vi.stubGlobal('useHead', () => undefined)
|
||||||
|
vi.stubGlobal('useApi', () => ({ get: vi.fn(), post: vi.fn(), patch: mockPatch }))
|
||||||
|
vi.stubGlobal('useRoute', () => ({ params: { id: '9' } }))
|
||||||
|
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||||
|
vi.stubGlobal('usePermissions', () => ({ can: () => true }))
|
||||||
|
vi.stubGlobal('navigateTo', vi.fn())
|
||||||
|
vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), setError: vi.fn(), clearErrors: vi.fn(), handleApiError: vi.fn() }))
|
||||||
|
globalThis.open = mockOpen
|
||||||
|
|
||||||
|
const EditPage = (await import('../weighing-tickets/[id]/edit.vue')).default
|
||||||
|
|
||||||
|
// ── Stubs de composants ──────────────────────────────────────────────────────
|
||||||
|
const ButtonStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||||
|
emits: ['click'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const InputStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, modelValue: { default: null } },
|
||||||
|
setup(props) {
|
||||||
|
return () => h('input', { 'data-label': props.label, 'value': props.modelValue as string })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// WeighingBlock stubbé (Date/Poids/DSD + boutons) — la contrepartie vit désormais
|
||||||
|
// dans les 4 champs du haut, hors bloc (ERP-193).
|
||||||
|
const BlockStub = defineComponent({
|
||||||
|
setup() { return () => h('div', { 'data-testid': 'block' }) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const ModalStub = defineComponent({
|
||||||
|
props: { modelValue: { type: Boolean, default: false } },
|
||||||
|
setup(_, { slots }) { return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()]) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const stubs = {
|
||||||
|
MalioButtonIcon: ButtonStub,
|
||||||
|
MalioButton: ButtonStub,
|
||||||
|
MalioInputText: InputStub,
|
||||||
|
MalioInputNumber: InputStub,
|
||||||
|
MalioSelect: InputStub,
|
||||||
|
MalioDateTime: InputStub,
|
||||||
|
MalioCheckbox: InputStub,
|
||||||
|
MalioModal: ModalStub,
|
||||||
|
WeighingBlock: BlockStub,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monte la page (setup async : top-level await) via Suspense.
|
||||||
|
async function mountPage() {
|
||||||
|
const wrapper = mount(defineComponent({
|
||||||
|
components: { EditPage },
|
||||||
|
setup: () => () => h(Suspense, null, { default: () => h(EditPage) }),
|
||||||
|
}), { global: { stubs } })
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
const DETAIL = {
|
||||||
|
id: 9,
|
||||||
|
status: 'VALIDATED',
|
||||||
|
number: '86-TP-0001',
|
||||||
|
site: { id: 1, name: 'Chatellerault', code: '86' },
|
||||||
|
counterpartyType: 'CLIENT',
|
||||||
|
client: { '@id': '/api/clients/629', companyName: 'NÉGOCE MÉTAUX ATLANTIQUE' },
|
||||||
|
immatriculation: 'AB-123-CD',
|
||||||
|
plateFreeFormat: false,
|
||||||
|
emptyDate: '2026-06-17T09:00:00+02:00', emptyWeight: 7150, emptyDsd: 1, emptyMode: 'AUTO',
|
||||||
|
fullDate: '2026-06-17T09:12:00+02:00', fullWeight: 14300, fullDsd: 2, fullMode: 'AUTO',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Écran Modification ticket de pesée (page /weighing-tickets/{id}/edit)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetchTicket.mockReset().mockResolvedValue({ ...DETAIL })
|
||||||
|
mockPatch.mockReset().mockResolvedValue({})
|
||||||
|
mockPush.mockReset()
|
||||||
|
mockOpen.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charge le ticket au montage (pré-remplissage via hydrate)', async () => {
|
||||||
|
await mountPage()
|
||||||
|
expect(mockFetchTicket).toHaveBeenCalledWith('9')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ticket validé : action principale « Enregistrer » + « Imprimer » (pas « Valider »)', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
// DETAIL.status = VALIDATED → l'action principale s'intitule « Enregistrer ».
|
||||||
|
expect(wrapper.find('[data-label="logistique.weighingTickets.form.save"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-label="logistique.weighingTickets.form.print"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('« Imprimer » ouvre le bon de pesée PDF servi par le back (RG-5.08)', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
await wrapper.find('[data-label="logistique.weighingTickets.form.print"]').trigger('click')
|
||||||
|
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/9/print.pdf', '_blank')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('« Enregistrer » : PATCH brouillon puis PATCH /validate, retour à la liste', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
await wrapper.find('[data-label="logistique.weighingTickets.form.save"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
// 1. Persistance de l'état courant (brouillon) avec les 2 pesées.
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/weighing_tickets/9',
|
||||||
|
expect.objectContaining({ counterpartyType: 'CLIENT', client: '/api/clients/629', fullWeight: 14300 }),
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
// 2. Validation (back autoritaire) — ne porte que les 4 champs du haut.
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/weighing_tickets/9/validate',
|
||||||
|
expect.objectContaining({ counterpartyType: 'CLIENT', immatriculation: 'AB-123-CD' }),
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
// « Enregistrer » ouvre aussi le bon de pesée PDF (RG-5.08).
|
||||||
|
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/9/print.pdf', '_blank')
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, ref, reactive, Suspense } from 'vue'
|
||||||
|
|
||||||
|
// ── Mocks des composables modules (le form RÉEL est conservé). ────────────────
|
||||||
|
const mockPost = vi.hoisted(() => vi.fn())
|
||||||
|
const mockPatch = vi.hoisted(() => vi.fn())
|
||||||
|
const mockPush = vi.hoisted(() => vi.fn())
|
||||||
|
const mockOpen = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({
|
||||||
|
useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: vi.fn().mockResolvedValue(undefined) }),
|
||||||
|
}))
|
||||||
|
vi.mock('~/modules/logistique/composables/useWeighbridge', () => ({
|
||||||
|
useWeighbridge: () => ({ triggerAuto: vi.fn(), triggerManual: vi.fn(), extractWeighbridgeError: () => 'err' }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ── Auto-imports Nuxt stubbés globalement ───────────────────────────────────
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
vi.stubGlobal('useHead', () => undefined)
|
||||||
|
vi.stubGlobal('useApi', () => ({ get: vi.fn(), post: mockPost, patch: mockPatch }))
|
||||||
|
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||||
|
vi.stubGlobal('usePermissions', () => ({ can: () => true }))
|
||||||
|
vi.stubGlobal('navigateTo', vi.fn())
|
||||||
|
vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), setError: vi.fn(), clearErrors: vi.fn(), handleApiError: vi.fn() }))
|
||||||
|
globalThis.open = mockOpen
|
||||||
|
|
||||||
|
const NewPage = (await import('../weighing-tickets/new.vue')).default
|
||||||
|
|
||||||
|
const ButtonStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||||
|
emits: ['click'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const InputStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, modelValue: { default: null } },
|
||||||
|
setup(props) { return () => h('input', { 'data-label': props.label, 'value': props.modelValue as string }) },
|
||||||
|
})
|
||||||
|
const BlockStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'block' }) } })
|
||||||
|
const ModalStub = defineComponent({
|
||||||
|
props: { modelValue: { type: Boolean, default: false } },
|
||||||
|
setup(_, { slots }) { return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()]) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const stubs = {
|
||||||
|
MalioButtonIcon: ButtonStub,
|
||||||
|
MalioButton: ButtonStub,
|
||||||
|
MalioInputText: InputStub,
|
||||||
|
MalioSelect: InputStub,
|
||||||
|
MalioDateTime: InputStub,
|
||||||
|
MalioCheckbox: InputStub,
|
||||||
|
MalioModal: ModalStub,
|
||||||
|
WeighingBlock: BlockStub,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountPage() {
|
||||||
|
const wrapper = mount(defineComponent({
|
||||||
|
components: { NewPage },
|
||||||
|
setup: () => () => h(Suspense, null, { default: () => h(NewPage) }),
|
||||||
|
}), { global: { stubs } })
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Écran Ajouter ticket de pesée (page /weighing-tickets/new)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPost.mockReset().mockResolvedValue({ id: 42 })
|
||||||
|
mockPatch.mockReset().mockResolvedValue({})
|
||||||
|
mockPush.mockReset()
|
||||||
|
mockOpen.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('un seul bouton « Valider » (pas de « Enregistrer » séparé)', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
expect(wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-label="logistique.weighingTickets.form.save"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('« Valider » : POST brouillon (création) puis PATCH /validate, PDF + retour liste', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
await wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// 1. Création du brouillon (POST) → récupère l'id.
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/weighing_tickets',
|
||||||
|
expect.any(Object),
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
// 2. Validation (back autoritaire) sur l'id retourné.
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/weighing_tickets/42/validate',
|
||||||
|
expect.any(Object),
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
// 3. Ouverture du bon de pesée PDF + retour à la liste.
|
||||||
|
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/42/print.pdf', '_blank')
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, ref } from 'vue'
|
||||||
|
|
||||||
|
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||||
|
// La page ne les importe pas (auto-import) : on les expose en globals pour le
|
||||||
|
// runtime de test (happy-dom). Meme philosophie que les specs M1→M4.
|
||||||
|
const mockPush = vi.hoisted(() => vi.fn())
|
||||||
|
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||||
|
const mockCan = vi.hoisted(() => vi.fn())
|
||||||
|
const mockFetch = vi.hoisted(() => vi.fn())
|
||||||
|
const mockReset = vi.hoisted(() => vi.fn())
|
||||||
|
const mockToastError = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
vi.stubGlobal('useHead', () => undefined)
|
||||||
|
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||||
|
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||||
|
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
|
||||||
|
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
||||||
|
// Site courant (switcher global) : ref pilotable pour simuler un changement de site.
|
||||||
|
const currentSiteRef = ref<{ id: number } | null>(null)
|
||||||
|
vi.stubGlobal('useCurrentSite', () => ({ currentSite: currentSiteRef }))
|
||||||
|
|
||||||
|
// Le repository est lui aussi un auto-import : on controle les items renvoyes.
|
||||||
|
// Contrepartie CLIENT (RG-5.03) → supplier / otherLabel absents (skip_null_values).
|
||||||
|
vi.stubGlobal('useWeighingTicketsRepository', () => ({
|
||||||
|
items: ref([
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
number: '86-TP-0001',
|
||||||
|
client: { id: 629, companyName: 'NÉGOCE MÉTAUX ATLANTIQUE' },
|
||||||
|
supplier: null,
|
||||||
|
otherLabel: null,
|
||||||
|
displayDate: '2026-06-17T09:12:00+02:00',
|
||||||
|
netWeight: 7150,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
totalItems: ref(1),
|
||||||
|
currentPage: ref(1),
|
||||||
|
itemsPerPage: ref(10),
|
||||||
|
itemsPerPageOptions: ref([10, 25, 50]),
|
||||||
|
fetch: mockFetch,
|
||||||
|
goToPage: vi.fn(),
|
||||||
|
setItemsPerPage: vi.fn(),
|
||||||
|
setFilters: vi.fn(),
|
||||||
|
reset: mockReset,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
|
||||||
|
// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse).
|
||||||
|
globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake')
|
||||||
|
globalThis.URL.revokeObjectURL = vi.fn()
|
||||||
|
|
||||||
|
// Import APRES les stubs (la page resout les auto-imports au top-level du module).
|
||||||
|
const WeighingTicketsIndex = (await import('../weighing-tickets/index.vue')).default
|
||||||
|
|
||||||
|
// ── Stubs de composants ──────────────────────────────────────────────────────
|
||||||
|
const ButtonStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||||
|
emits: ['click'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Capture les `items` (rows) passes par la page : on rend chaque ligne avec ses
|
||||||
|
// cellules formatees (date / poids) pour pouvoir asserter le mapping des colonnes.
|
||||||
|
const capturedRows = ref<Array<Record<string, unknown>>>([])
|
||||||
|
const DataTableStub = defineComponent({
|
||||||
|
props: { items: { type: Array, default: () => [] } },
|
||||||
|
emits: ['row-click', 'update:page', 'update:per-page'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => {
|
||||||
|
capturedRows.value = props.items as Array<Record<string, unknown>>
|
||||||
|
return h('div', { 'data-testid': 'datatable' },
|
||||||
|
(props.items as Array<Record<string, unknown>>).map(it =>
|
||||||
|
h('tr', { 'data-row-id': it.id as number, onClick: () => emit('row-click', it) }, [
|
||||||
|
h('td', { 'data-cell': 'displayDate' }, it.displayDate as string),
|
||||||
|
h('td', { 'data-cell': 'netWeight' }, it.netWeight as string),
|
||||||
|
h('td', { 'data-cell': 'client' }, it.client as string),
|
||||||
|
h('td', { 'data-cell': 'supplier' }, it.supplier as string),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const PageHeaderStub = defineComponent({
|
||||||
|
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Suivi des wrappers montés pour les démonter entre tests : sans cela, les
|
||||||
|
// watchers sur la ref module-level `currentSiteRef` (site courant) fuiteraient
|
||||||
|
// d'un test à l'autre et se déclencheraient en double.
|
||||||
|
const mountedWrappers: VueWrapper[] = []
|
||||||
|
|
||||||
|
function mountPage() {
|
||||||
|
const wrapper = mount(WeighingTicketsIndex, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
PageHeader: PageHeaderStub,
|
||||||
|
MalioButton: ButtonStub,
|
||||||
|
MalioDataTable: DataTableStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
mountedWrappers.push(wrapper)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Liste des tickets de pesée (page /weighing-tickets)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPush.mockReset()
|
||||||
|
mockApiGet.mockReset().mockResolvedValue(new Blob())
|
||||||
|
mockCan.mockReset().mockReturnValue(true)
|
||||||
|
mockFetch.mockReset()
|
||||||
|
mockReset.mockReset()
|
||||||
|
mockToastError.mockReset()
|
||||||
|
capturedRows.value = []
|
||||||
|
currentSiteRef.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Démonte les composants montés → libère leurs watchers (site courant).
|
||||||
|
while (mountedWrappers.length > 0) {
|
||||||
|
mountedWrappers.pop()?.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charge la liste au montage', async () => {
|
||||||
|
mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockFetch).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recharge la liste (page 1) quand le site courant change', async () => {
|
||||||
|
mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockReset).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
// Simule un switch de site via le switcher global.
|
||||||
|
currentSiteRef.value = { id: 2 }
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockReset).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formate la date au format JJ-MM-AAAA', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-cell="displayDate"]').text()).toBe('17-06-2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formate le poids net en kg avec separateur de milliers', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-cell="netWeight"]').text()).toBe('7 150 Kg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe la contrepartie Client (supplier vide car contrepartie ≠ Fournisseur)', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-cell="client"]').text()).toBe('NÉGOCE MÉTAUX ATLANTIQUE')
|
||||||
|
expect(wrapper.find('[data-cell="supplier"]').text()).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche « + Ajouter » uniquement avec la permission manage', async () => {
|
||||||
|
mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.manage')
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
|
||||||
|
mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.view')
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigue vers la modification au clic sur une ligne', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('tr[data-row-id="9"]').trigger('click')
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets/9/edit')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appelle l\'export XLSX sur /weighing_tickets/export.xlsx en blob', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('[data-label="logistique.weighingTickets.export"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockApiGet).toHaveBeenCalledWith(
|
||||||
|
'/weighing_tickets/export.xlsx',
|
||||||
|
expect.any(Object),
|
||||||
|
expect.objectContaining({ responseType: 'blob', toast: false }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- En-tête : retour vers la liste + titre. -->
|
||||||
|
<div class="flex items-center gap-3 pt-11">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
:title="t('logistique.weighingTickets.form.back')"
|
||||||
|
v-bind="{ ariaLabel: t('logistique.weighingTickets.form.back') }"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
|
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- États de chargement / introuvable. -->
|
||||||
|
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('logistique.weighingTickets.edit.loading') }}</p>
|
||||||
|
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('logistique.weighingTickets.edit.notFound') }}</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Form à plat, pleine largeur (sans box-shadow) : un filet noir 1px
|
||||||
|
sépare chacun des 3 blocs (divide-y). -->
|
||||||
|
<div class="mt-[48px] flex flex-col divide-y divide-black">
|
||||||
|
<!-- ── 4 champs du haut : contrepartie + immatriculation + « Tout
|
||||||
|
format » (ERP-193, hors blocs de pesée). 1er bloc : pas de
|
||||||
|
padding-top (marge titre→form = mt-[48px] standard). ───────── -->
|
||||||
|
<div class="pb-[20px]">
|
||||||
|
<div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="form.counterpartyType.value"
|
||||||
|
:options="counterpartyOptions"
|
||||||
|
:label="t('logistique.weighingTickets.form.counterparty.type')"
|
||||||
|
:required="true"
|
||||||
|
empty-option-label=""
|
||||||
|
:error="errors.counterpartyType"
|
||||||
|
@update:model-value="onCounterpartyTypeChange"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-if="form.counterpartyField.value === 'supplier'"
|
||||||
|
:model-value="form.supplierIri.value"
|
||||||
|
:options="referentials.suppliers.value"
|
||||||
|
:label="t('logistique.weighingTickets.form.counterparty.supplier')"
|
||||||
|
:required="true"
|
||||||
|
empty-option-label=""
|
||||||
|
:error="errors.supplier"
|
||||||
|
@update:model-value="(v: string | number | null) => form.supplierIri.value = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-else-if="form.counterpartyField.value === 'client'"
|
||||||
|
:model-value="form.clientIri.value"
|
||||||
|
:options="referentials.clients.value"
|
||||||
|
:label="t('logistique.weighingTickets.form.counterparty.client')"
|
||||||
|
:required="true"
|
||||||
|
empty-option-label=""
|
||||||
|
:error="errors.client"
|
||||||
|
@update:model-value="(v: string | number | null) => form.clientIri.value = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-else-if="form.counterpartyField.value === 'other'"
|
||||||
|
:model-value="form.otherLabel.value"
|
||||||
|
:label="t('logistique.weighingTickets.form.counterparty.other')"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.otherLabel"
|
||||||
|
@update:model-value="(v: string | null) => form.otherLabel.value = v"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Pas de cellule vide sans type sélectionné : immat et « Tout
|
||||||
|
format » se collent au type ; le champ conditionnel les
|
||||||
|
décale une fois un type choisi. -->
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="form.immatriculation.value"
|
||||||
|
:mask="form.plateFreeFormat.value ? FREE_PLATE_MASK : PLATE_MASK"
|
||||||
|
:label="t('logistique.weighingTickets.form.immatriculation')"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.immatriculation"
|
||||||
|
@update:model-value="(v: string | null) => form.immatriculation.value = v"
|
||||||
|
/>
|
||||||
|
<MalioCheckbox
|
||||||
|
id="plate-free-format"
|
||||||
|
:model-value="form.plateFreeFormat.value"
|
||||||
|
:label="t('logistique.weighingTickets.form.plateFreeFormat')"
|
||||||
|
group-class="self-center"
|
||||||
|
@update:model-value="(v: boolean) => form.plateFreeFormat.value = v"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Bloc « Poids à vide » ──────────────────────────────────── -->
|
||||||
|
<WeighingBlock
|
||||||
|
class="py-[20px]"
|
||||||
|
block-id="empty"
|
||||||
|
:title="t('logistique.weighingTickets.form.emptyBlock')"
|
||||||
|
:block="form.empty"
|
||||||
|
:errors="emptyBlockErrors"
|
||||||
|
@update:block="(field, value) => updateBlock('empty', field, value)"
|
||||||
|
@request-auto="openAuto('empty')"
|
||||||
|
@request-manual="openManual('empty')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- ── Bloc « Poids à plein » (dernier bloc : pas de padding-bottom,
|
||||||
|
pour ne pas écarter le bouton). ──────────────────────────── -->
|
||||||
|
<WeighingBlock
|
||||||
|
class="pt-[20px]"
|
||||||
|
block-id="full"
|
||||||
|
:title="t('logistique.weighingTickets.form.fullBlock')"
|
||||||
|
:block="form.full"
|
||||||
|
:errors="fullBlockErrors"
|
||||||
|
@update:block="(field, value) => updateBlock('full', field, value)"
|
||||||
|
@request-auto="openAuto('full')"
|
||||||
|
@request-manual="openManual('full')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bas d'écran : « Imprimer » (ouvre le PDF back) + action principale
|
||||||
|
(« Valider » si brouillon, « Enregistrer » si déjà validé). -->
|
||||||
|
<div class="mt-12 flex justify-center gap-6">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:printer-outline"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('logistique.weighingTickets.form.print')"
|
||||||
|
@click="printTicket"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="primaryLabel"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="submitPrimary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────-->
|
||||||
|
<MalioModal v-model="autoModal.open" modal-class="max-w-md" footer-class="justify-center pb-6">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
|
||||||
|
</template>
|
||||||
|
<p v-if="autoModal.error" class="text-m-danger">{{ autoModal.error }}</p>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('logistique.weighingTickets.form.weighbridge.validate')"
|
||||||
|
:disabled="autoModal.loading"
|
||||||
|
@click="confirmAuto"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
|
||||||
|
<!-- ── Modal « Pesée manuelle » ────────────────────────────────────────-->
|
||||||
|
<MalioModal
|
||||||
|
v-model="manualModal.open"
|
||||||
|
modal-class="max-w-md"
|
||||||
|
header-class="mx-7 px-0 pt-6 pb-3 border-b border-black"
|
||||||
|
body-class="px-7 pt-9"
|
||||||
|
footer-class="px-7 justify-center pb-6"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold uppercase">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
|
||||||
|
</template>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="manualModal.weight"
|
||||||
|
:mask="NUMERIC_MASK"
|
||||||
|
:label="t('logistique.weighingTickets.form.manual.weight')"
|
||||||
|
:required="true"
|
||||||
|
:error="manualModal.errors.weight"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="manualModal.dsd"
|
||||||
|
:mask="NUMERIC_MASK"
|
||||||
|
:label="t('logistique.weighingTickets.form.manual.dsd')"
|
||||||
|
:required="true"
|
||||||
|
:error="manualModal.errors.dsd"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('logistique.weighingTickets.form.manual.save')"
|
||||||
|
:disabled="manualModal.loading"
|
||||||
|
@click="confirmManual"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||||
|
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
|
||||||
|
import { useWeighingTicket } from '~/modules/logistique/composables/useWeighingTicket'
|
||||||
|
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
|
||||||
|
import { NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
|
||||||
|
import { mapViolationsToRecord } from '~/shared/utils/api'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const api = useApi()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
// Modification réservée à `manage` (Admin / Bureau / Usine) — sinon retour liste.
|
||||||
|
if (!can('logistique.weighing_tickets.manage')) {
|
||||||
|
await navigateTo('/weighing-tickets')
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketId = route.params.id as string
|
||||||
|
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
const weighbridge = useWeighbridge()
|
||||||
|
const referentials = useWeighingTicketReferentials()
|
||||||
|
const { fetchTicket } = useWeighingTicket()
|
||||||
|
const { errors, clearErrors, handleApiError } = useFormErrors()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
// Numéro immuable (RG-5.09), rappelé dans le titre — vide tant que brouillon.
|
||||||
|
const ticketNumber = ref<string>('')
|
||||||
|
|
||||||
|
const headerTitle = computed(() =>
|
||||||
|
ticketNumber.value
|
||||||
|
? t('logistique.weighingTickets.edit.title', { number: ticketNumber.value })
|
||||||
|
: t('logistique.weighingTickets.edit.titleFallback'),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Libellé de l'action principale : « Valider » pour un brouillon (finalisation),
|
||||||
|
// « Enregistrer » pour un ticket déjà validé (mise à jour, ERP-193).
|
||||||
|
const isValidated = computed(() => form.status.value === 'VALIDATED')
|
||||||
|
const primaryLabel = computed(() =>
|
||||||
|
isValidated.value
|
||||||
|
? t('logistique.weighingTickets.form.save')
|
||||||
|
: t('logistique.weighingTickets.form.validate'),
|
||||||
|
)
|
||||||
|
|
||||||
|
useHead({ title: t('logistique.weighingTickets.edit.titleFallback') })
|
||||||
|
|
||||||
|
/** Retour vers la liste (flèche d'en-tête). */
|
||||||
|
function goBack(): void {
|
||||||
|
router.push('/weighing-tickets')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contrepartie (RG-5.03) — ordre maquette : Fournisseur / Client / Autre. ───
|
||||||
|
const counterpartyOptions = computed<RefOption[]>(() => [
|
||||||
|
{ value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') },
|
||||||
|
{ value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') },
|
||||||
|
{ value: 'AUTRE', label: t('logistique.weighingTickets.form.counterparty.other') },
|
||||||
|
])
|
||||||
|
|
||||||
|
function onCounterpartyTypeChange(value: string | number | null): void {
|
||||||
|
const type = (value === null || value === '') ? null : (String(value) as 'CLIENT' | 'FOURNISSEUR' | 'AUTRE')
|
||||||
|
form.setCounterpartyType(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Erreurs par bloc (mapping propertyPath back → champs du composant) ────────
|
||||||
|
const emptyBlockErrors = computed<Record<string, string>>(() => ({
|
||||||
|
date: errors.emptyDate,
|
||||||
|
weight: errors.emptyWeight,
|
||||||
|
dsd: errors.emptyDsd,
|
||||||
|
}))
|
||||||
|
const fullBlockErrors = computed<Record<string, string>>(() => ({
|
||||||
|
date: errors.fullDate,
|
||||||
|
weight: errors.fullWeight,
|
||||||
|
dsd: errors.fullDsd,
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** Mute un champ d'un bloc de pesée (état centralisé dans le form). */
|
||||||
|
function updateBlock(target: 'empty' | 'full', field: keyof WeighingBlockState, value: unknown): void {
|
||||||
|
(form[target] as Record<string, unknown>)[field as string] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modal pesée bascule (AUTO) ────────────────────────────────────────────────
|
||||||
|
const autoModal = reactive({
|
||||||
|
open: false,
|
||||||
|
error: '',
|
||||||
|
loading: false,
|
||||||
|
target: 'empty' as 'empty' | 'full',
|
||||||
|
})
|
||||||
|
|
||||||
|
function openAuto(target: 'empty' | 'full'): void {
|
||||||
|
autoModal.target = target
|
||||||
|
autoModal.error = ''
|
||||||
|
autoModal.open = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Déclenche la pesée bascule puis enregistre le brouillon (ERP-193). */
|
||||||
|
async function confirmAuto(): Promise<void> {
|
||||||
|
if (autoModal.loading) return
|
||||||
|
autoModal.loading = true
|
||||||
|
autoModal.error = ''
|
||||||
|
try {
|
||||||
|
const reading = await weighbridge.triggerAuto()
|
||||||
|
form.applyReading(form[autoModal.target], reading)
|
||||||
|
autoModal.open = false
|
||||||
|
await saveDraft()
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
autoModal.error = weighbridge.extractWeighbridgeError(e)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
autoModal.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modal pesée manuelle (MANUAL) ─────────────────────────────────────────────
|
||||||
|
const manualModal = reactive({
|
||||||
|
open: false,
|
||||||
|
loading: false,
|
||||||
|
target: 'empty' as 'empty' | 'full',
|
||||||
|
weight: null as string | null,
|
||||||
|
dsd: null as string | null,
|
||||||
|
errors: {} as Record<string, string>,
|
||||||
|
})
|
||||||
|
|
||||||
|
function openManual(target: 'empty' | 'full'): void {
|
||||||
|
manualModal.target = target
|
||||||
|
manualModal.weight = null
|
||||||
|
manualModal.dsd = null
|
||||||
|
manualModal.errors = {}
|
||||||
|
manualModal.open = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valide la saisie manuelle (poids + DSD), remplit le bloc puis enregistre le brouillon. */
|
||||||
|
async function confirmManual(): Promise<void> {
|
||||||
|
if (manualModal.loading) return
|
||||||
|
manualModal.errors = {}
|
||||||
|
|
||||||
|
const weight = manualModal.weight === null || manualModal.weight === '' ? null : Number(manualModal.weight)
|
||||||
|
const dsd = manualModal.dsd === null || manualModal.dsd === '' ? null : Number(manualModal.dsd)
|
||||||
|
if (weight === null || Number.isNaN(weight)) {
|
||||||
|
manualModal.errors = { ...manualModal.errors, weight: t('logistique.weighingTickets.form.manual.weightRequired') }
|
||||||
|
}
|
||||||
|
if (dsd === null || Number.isNaN(dsd)) {
|
||||||
|
manualModal.errors = { ...manualModal.errors, dsd: t('logistique.weighingTickets.form.manual.dsdRequired') }
|
||||||
|
}
|
||||||
|
if (Object.keys(manualModal.errors).length > 0) return
|
||||||
|
|
||||||
|
manualModal.loading = true
|
||||||
|
try {
|
||||||
|
const reading = await weighbridge.triggerManual(weight as number, dsd as number)
|
||||||
|
form.applyReading(form[manualModal.target], reading)
|
||||||
|
manualModal.open = false
|
||||||
|
await saveDraft()
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
// 422 de pesée (poids/DSD ≤ 0, Assert\Positive) → erreur sous le BON champ
|
||||||
|
// (le propertyPath back `weight`/`dsd` = nom du champ de la modale). Sinon
|
||||||
|
// (503 pont indispo, réseau) → message générique sous le champ Poids.
|
||||||
|
const violations = mapViolationsToRecord((e as { response?: { _data?: unknown } })?.response?._data)
|
||||||
|
manualModal.errors = Object.keys(violations).length > 0
|
||||||
|
? violations
|
||||||
|
: { weight: weighbridge.extractWeighbridgeError(e) }
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
manualModal.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Persistance / impression ──────────────────────────────────────────────────
|
||||||
|
/** Enregistre l'état courant en BROUILLON (PATCH). False sur erreur (422 inline). */
|
||||||
|
async function saveDraft(): Promise<boolean> {
|
||||||
|
clearErrors()
|
||||||
|
try {
|
||||||
|
await api.patch(`/weighing_tickets/${ticketId}`, form.buildDraftPayload(), { toast: false })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
handleApiError(e, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action principale : persiste l'état courant puis finalise/re-valide via
|
||||||
|
* PATCH /validate (back autoritaire : 3 champs du haut + 2 pesées). Ouvre le bon de
|
||||||
|
* pesée PDF (RG-5.08) — aussi bien à la validation d'un brouillon qu'à
|
||||||
|
* l'enregistrement d'un ticket déjà validé. Retour à la liste au succès.
|
||||||
|
*/
|
||||||
|
async function submitPrimary(): Promise<void> {
|
||||||
|
if (saving.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (!(await saveDraft())) return
|
||||||
|
|
||||||
|
await api.patch(`/weighing_tickets/${ticketId}/validate`, form.buildValidatePayload(), { toast: false })
|
||||||
|
window.open(`/api/weighing_tickets/${ticketId}/print.pdf`, '_blank')
|
||||||
|
router.push('/weighing-tickets')
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
handleApiError(e, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* « Imprimer » : ouvre le bon de pesée PDF servi par le back (Twig, ERP-192).
|
||||||
|
* Le front ne dessine AUCUN gabarit — il ouvre seulement l'URL (RG-5.08).
|
||||||
|
*/
|
||||||
|
function printTicket(): void {
|
||||||
|
window.open(`/api/weighing_tickets/${ticketId}/print.pdf`, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
referentials.load().catch(() => {})
|
||||||
|
try {
|
||||||
|
const detail = await fetchTicket(ticketId)
|
||||||
|
ticketNumber.value = detail.number ?? ''
|
||||||
|
form.hydrate(detail)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
error.value = true
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PageHeader>
|
||||||
|
{{ t('logistique.weighingTickets.title') }}
|
||||||
|
<template #actions>
|
||||||
|
<MalioButton
|
||||||
|
v-if="canManage"
|
||||||
|
variant="secondary"
|
||||||
|
:label="t('logistique.weighingTickets.add')"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
@click="goToCreate"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<!-- Datatable branchee sur usePaginatedList via useWeighingTicketsRepository :
|
||||||
|
pagination serveur (defaut 10), tri number DESC par defaut (cote back),
|
||||||
|
liste cloisonnee par site courant (spec-back § 2.3). Etat 100 % local
|
||||||
|
(regle ABSOLUE n°6). -->
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="rows"
|
||||||
|
:total-items="totalItems"
|
||||||
|
:page="currentPage"
|
||||||
|
:per-page="itemsPerPage"
|
||||||
|
:per-page-options="itemsPerPageOptions"
|
||||||
|
row-clickable
|
||||||
|
:empty-message="t('logistique.weighingTickets.empty')"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
@update:page="goToPage"
|
||||||
|
@update:per-page="setItemsPerPage"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex justify-center mt-4">
|
||||||
|
<MalioButton
|
||||||
|
v-if="canView"
|
||||||
|
variant="primary"
|
||||||
|
:label="t('logistique.weighingTickets.export')"
|
||||||
|
:disabled="exporting"
|
||||||
|
@click="exportXlsx"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { formatDateFr, formatWeightKg } from '~/modules/logistique/utils/weighingTicketFormat'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const api = useApi()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
// Site courant (switcher global) : la liste est cloisonnée par site côté back
|
||||||
|
// (spec-back § 2.3). Le front n'envoie PAS le site (résolu serveur) — il se
|
||||||
|
// contente de recharger quand le site change pour refléter le bon périmètre.
|
||||||
|
const { currentSite } = useCurrentSite()
|
||||||
|
|
||||||
|
useHead({ title: t('logistique.weighingTickets.title') })
|
||||||
|
|
||||||
|
// Bouton « + Ajouter » reserve a `manage` (Admin / Bureau / Usine). « Exporter »
|
||||||
|
// suit `view`. Compta et Commerciale n'ont aucun acces (item sidebar masque cote
|
||||||
|
// back) — spec-front § Acces.
|
||||||
|
const canManage = computed(() => can('logistique.weighing_tickets.manage'))
|
||||||
|
const canView = computed(() => can('logistique.weighing_tickets.view'))
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: tickets,
|
||||||
|
totalItems,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
fetch: loadTickets,
|
||||||
|
goToPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
reset: reloadFromFirstPage,
|
||||||
|
} = useWeighingTicketsRepository()
|
||||||
|
|
||||||
|
// Mappe les tickets en objets « plats » formates pour MalioDataTable (items typees
|
||||||
|
// Record<string, unknown>[]). La contrepartie est mutuellement exclusive (RG-5.03) :
|
||||||
|
// une seule des colonnes client / supplier / otherLabel est renseignee, les autres
|
||||||
|
// restent vides. Date et poids sont formates ici (cf. helpers ci-dessous).
|
||||||
|
const rows = computed(() => tickets.value.map(ticket => ({
|
||||||
|
id: ticket.id,
|
||||||
|
// Numéro vide tant que brouillon (attribué à la validation, ERP-193).
|
||||||
|
number: ticket.number ?? '',
|
||||||
|
client: ticket.client?.companyName ?? '',
|
||||||
|
supplier: ticket.supplier?.companyName ?? '',
|
||||||
|
otherLabel: ticket.otherLabel ?? '',
|
||||||
|
displayDate: formatDateFr(ticket.displayDate),
|
||||||
|
netWeight: formatWeightKg(ticket.netWeight),
|
||||||
|
status: t(ticket.status === 'VALIDATED'
|
||||||
|
? 'logistique.weighingTickets.status.validated'
|
||||||
|
: 'logistique.weighingTickets.status.draft'),
|
||||||
|
})))
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'number', label: t('logistique.weighingTickets.column.number') },
|
||||||
|
{ key: 'client', label: t('logistique.weighingTickets.column.client') },
|
||||||
|
{ key: 'supplier', label: t('logistique.weighingTickets.column.supplier') },
|
||||||
|
{ key: 'otherLabel', label: t('logistique.weighingTickets.column.other') },
|
||||||
|
{ key: 'displayDate', label: t('logistique.weighingTickets.column.date') },
|
||||||
|
{ key: 'netWeight', label: t('logistique.weighingTickets.column.weight') },
|
||||||
|
{ key: 'status', label: t('logistique.weighingTickets.column.status') },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Clic sur une ligne → ecran Modification (pas de consultation separee, spec § Navigation). */
|
||||||
|
function onRowClick(item: Record<string, unknown>): void {
|
||||||
|
router.push(`/weighing-tickets/${item.id}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToCreate(): void {
|
||||||
|
router.push('/weighing-tickets/new')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export XLSX ─────────────────────────────────────────────────────────────
|
||||||
|
// Exporte toute la liste (site courant applique cote back, spec-back § 4.5).
|
||||||
|
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 pattern M2/M3/M4).
|
||||||
|
const blob = await api.get<Blob>('/weighing_tickets/export.xlsx', {}, {
|
||||||
|
responseType: 'blob',
|
||||||
|
toast: false,
|
||||||
|
} as unknown as Parameters<typeof api.get>[2])
|
||||||
|
|
||||||
|
triggerDownload(blob, 'tickets-pesee.xlsx')
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
toast.error({
|
||||||
|
title: t('logistique.weighingTickets.toast.error'),
|
||||||
|
message: t('logistique.weighingTickets.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changement de site courant → recharge la liste en page 1 (nouveau périmètre).
|
||||||
|
// usePaginatedList ne passe pas par useAsyncData : le refreshNuxtData() de
|
||||||
|
// switchSite ne l'atteint pas, d'où ce watcher explicite. On compare l'id pour
|
||||||
|
// ignorer l'hydratation initiale (même site) et les ré-affectations sans réel
|
||||||
|
// changement.
|
||||||
|
watch(() => currentSite.value?.id, (id, previousId) => {
|
||||||
|
if (id !== previousId) {
|
||||||
|
reloadFromFirstPage()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(loadTickets)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- En-tête : retour vers la liste + titre. -->
|
||||||
|
<div class="flex items-center gap-3 pt-11">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
:title="t('logistique.weighingTickets.form.back')"
|
||||||
|
v-bind="{ ariaLabel: t('logistique.weighingTickets.form.back') }"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
|
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('logistique.weighingTickets.form.addTitle') }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form à plat, pleine largeur (sans box-shadow) : un filet noir 1px
|
||||||
|
sépare chacun des 3 blocs (divide-y). -->
|
||||||
|
<div class="mt-[48px] flex flex-col divide-y divide-black">
|
||||||
|
<!-- ── 4 champs du haut : contrepartie (type + champ conditionnel),
|
||||||
|
immatriculation, « Tout format » (ERP-193, hors blocs de pesée).
|
||||||
|
1er bloc : pas de padding-top (marge titre→form = mt-[48px] standard). ── -->
|
||||||
|
<div class="pb-[20px]">
|
||||||
|
<div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="form.counterpartyType.value"
|
||||||
|
:options="counterpartyOptions"
|
||||||
|
:label="t('logistique.weighingTickets.form.counterparty.type')"
|
||||||
|
:required="true"
|
||||||
|
empty-option-label=""
|
||||||
|
:error="errors.counterpartyType"
|
||||||
|
@update:model-value="onCounterpartyTypeChange"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-if="form.counterpartyField.value === 'supplier'"
|
||||||
|
:model-value="form.supplierIri.value"
|
||||||
|
:options="referentials.suppliers.value"
|
||||||
|
:label="t('logistique.weighingTickets.form.counterparty.supplier')"
|
||||||
|
:required="true"
|
||||||
|
empty-option-label=""
|
||||||
|
:error="errors.supplier"
|
||||||
|
@update:model-value="(v: string | number | null) => form.supplierIri.value = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-else-if="form.counterpartyField.value === 'client'"
|
||||||
|
:model-value="form.clientIri.value"
|
||||||
|
:options="referentials.clients.value"
|
||||||
|
:label="t('logistique.weighingTickets.form.counterparty.client')"
|
||||||
|
:required="true"
|
||||||
|
empty-option-label=""
|
||||||
|
:error="errors.client"
|
||||||
|
@update:model-value="(v: string | number | null) => form.clientIri.value = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-else-if="form.counterpartyField.value === 'other'"
|
||||||
|
:model-value="form.otherLabel.value"
|
||||||
|
:label="t('logistique.weighingTickets.form.counterparty.other')"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.otherLabel"
|
||||||
|
@update:model-value="(v: string | null) => form.otherLabel.value = v"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Pas de cellule vide quand aucun type n'est choisi : immat et
|
||||||
|
« Tout format » se collent au type, et le champ conditionnel
|
||||||
|
les décale une fois un type sélectionné. -->
|
||||||
|
<!-- Immatriculation : masque XX-000-XX (plaque FR SIV) ; en « Tout
|
||||||
|
format », masque élargi. Partagée par les 2 pesées (RG-5.01). -->
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="form.immatriculation.value"
|
||||||
|
:mask="form.plateFreeFormat.value ? FREE_PLATE_MASK : PLATE_MASK"
|
||||||
|
:label="t('logistique.weighingTickets.form.immatriculation')"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.immatriculation"
|
||||||
|
@update:model-value="(v: string | null) => form.immatriculation.value = v"
|
||||||
|
/>
|
||||||
|
<MalioCheckbox
|
||||||
|
id="plate-free-format"
|
||||||
|
:model-value="form.plateFreeFormat.value"
|
||||||
|
:label="t('logistique.weighingTickets.form.plateFreeFormat')"
|
||||||
|
group-class="self-center"
|
||||||
|
@update:model-value="(v: boolean) => form.plateFreeFormat.value = v"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Bloc « Poids à vide » ──────────────────────────────────────── -->
|
||||||
|
<WeighingBlock
|
||||||
|
class="py-[20px]"
|
||||||
|
block-id="empty"
|
||||||
|
:title="t('logistique.weighingTickets.form.emptyBlock')"
|
||||||
|
:block="form.empty"
|
||||||
|
:errors="emptyBlockErrors"
|
||||||
|
@update:block="(field, value) => updateBlock('empty', field, value)"
|
||||||
|
@request-auto="openAuto('empty')"
|
||||||
|
@request-manual="openManual('empty')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- ── Bloc « Poids à plein » (dernier bloc : pas de padding-bottom,
|
||||||
|
pour ne pas écarter le bouton « Valider »). ───────────────────── -->
|
||||||
|
<WeighingBlock
|
||||||
|
class="pt-[20px]"
|
||||||
|
block-id="full"
|
||||||
|
:title="t('logistique.weighingTickets.form.fullBlock')"
|
||||||
|
:block="form.full"
|
||||||
|
:errors="fullBlockErrors"
|
||||||
|
@update:block="(field, value) => updateBlock('full', field, value)"
|
||||||
|
@request-auto="openAuto('full')"
|
||||||
|
@request-manual="openManual('full')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- « Valider » : persiste l'état courant (brouillon) puis finalise (3 champs
|
||||||
|
du haut + 2 pesées, validation back autoritaire) et ouvre le bon de
|
||||||
|
pesée PDF (RG-5.08, ERP-193). Toujours actif : les 422 s'affichent inline. -->
|
||||||
|
<div class="mt-12 flex justify-center">
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('logistique.weighingTickets.form.validate')"
|
||||||
|
:disabled="validating"
|
||||||
|
@click="submitValidate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────-->
|
||||||
|
<MalioModal v-model="autoModal.open" modal-class="max-w-md" footer-class="justify-center pb-6">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
|
||||||
|
</template>
|
||||||
|
<p v-if="autoModal.error" class="text-m-danger">{{ autoModal.error }}</p>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('logistique.weighingTickets.form.weighbridge.validate')"
|
||||||
|
:disabled="autoModal.loading"
|
||||||
|
@click="confirmAuto"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
|
||||||
|
<!-- ── Modal « Pesée manuelle » ────────────────────────────────────────-->
|
||||||
|
<MalioModal
|
||||||
|
v-model="manualModal.open"
|
||||||
|
modal-class="max-w-md"
|
||||||
|
header-class="mx-7 px-0 pt-6 pb-3 border-b border-black"
|
||||||
|
body-class="px-7 pt-9"
|
||||||
|
footer-class="px-7 justify-center pb-6"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold uppercase">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
|
||||||
|
</template>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="manualModal.weight"
|
||||||
|
:mask="NUMERIC_MASK"
|
||||||
|
:label="t('logistique.weighingTickets.form.manual.weight')"
|
||||||
|
:required="true"
|
||||||
|
:error="manualModal.errors.weight"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="manualModal.dsd"
|
||||||
|
:mask="NUMERIC_MASK"
|
||||||
|
:label="t('logistique.weighingTickets.form.manual.dsd')"
|
||||||
|
:required="true"
|
||||||
|
:error="manualModal.errors.dsd"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('logistique.weighingTickets.form.manual.save')"
|
||||||
|
:disabled="manualModal.loading"
|
||||||
|
@click="confirmManual"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||||
|
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
|
||||||
|
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
|
||||||
|
import { NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
|
||||||
|
import { mapViolationsToRecord } from '~/shared/utils/api'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const api = useApi()
|
||||||
|
const router = useRouter()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
useHead({ title: t('logistique.weighingTickets.form.addTitle') })
|
||||||
|
|
||||||
|
// Création réservée à `manage` (Admin / Bureau / Usine) — sinon retour à la liste.
|
||||||
|
if (!can('logistique.weighing_tickets.manage')) {
|
||||||
|
await navigateTo('/weighing-tickets')
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
const weighbridge = useWeighbridge()
|
||||||
|
const referentials = useWeighingTicketReferentials()
|
||||||
|
const { errors, clearErrors, handleApiError } = useFormErrors()
|
||||||
|
|
||||||
|
const validating = ref(false)
|
||||||
|
|
||||||
|
/** Retour vers la liste (flèche d'en-tête). */
|
||||||
|
function goBack(): void {
|
||||||
|
router.push('/weighing-tickets')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contrepartie (RG-5.03) — ordre maquette : Fournisseur / Client / Autre. ───
|
||||||
|
const counterpartyOptions = computed<RefOption[]>(() => [
|
||||||
|
{ value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') },
|
||||||
|
{ value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') },
|
||||||
|
{ value: 'AUTRE', label: t('logistique.weighingTickets.form.counterparty.other') },
|
||||||
|
])
|
||||||
|
|
||||||
|
function onCounterpartyTypeChange(value: string | number | null): void {
|
||||||
|
const type = (value === null || value === '') ? null : (String(value) as 'CLIENT' | 'FOURNISSEUR' | 'AUTRE')
|
||||||
|
form.setCounterpartyType(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Erreurs par bloc (mapping propertyPath back → champs du composant) ────────
|
||||||
|
const emptyBlockErrors = computed<Record<string, string>>(() => ({
|
||||||
|
date: errors.emptyDate,
|
||||||
|
weight: errors.emptyWeight,
|
||||||
|
dsd: errors.emptyDsd,
|
||||||
|
}))
|
||||||
|
const fullBlockErrors = computed<Record<string, string>>(() => ({
|
||||||
|
date: errors.fullDate,
|
||||||
|
weight: errors.fullWeight,
|
||||||
|
dsd: errors.fullDsd,
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** Mute un champ d'un bloc de pesée (état centralisé dans le form). */
|
||||||
|
function updateBlock(target: 'empty' | 'full', field: keyof WeighingBlockState, value: unknown): void {
|
||||||
|
(form[target] as Record<string, unknown>)[field as string] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modal pesée bascule (AUTO) ────────────────────────────────────────────────
|
||||||
|
const autoModal = reactive({
|
||||||
|
open: false,
|
||||||
|
error: '',
|
||||||
|
loading: false,
|
||||||
|
target: 'empty' as 'empty' | 'full',
|
||||||
|
})
|
||||||
|
|
||||||
|
function openAuto(target: 'empty' | 'full'): void {
|
||||||
|
autoModal.target = target
|
||||||
|
autoModal.error = ''
|
||||||
|
autoModal.open = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Déclenche la pesée bascule puis enregistre le brouillon (ERP-193). */
|
||||||
|
async function confirmAuto(): Promise<void> {
|
||||||
|
if (autoModal.loading) return
|
||||||
|
autoModal.loading = true
|
||||||
|
autoModal.error = ''
|
||||||
|
try {
|
||||||
|
const reading = await weighbridge.triggerAuto()
|
||||||
|
form.applyReading(form[autoModal.target], reading)
|
||||||
|
autoModal.open = false
|
||||||
|
await saveDraft()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
autoModal.error = weighbridge.extractWeighbridgeError(error)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
autoModal.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modal pesée manuelle (MANUAL) ─────────────────────────────────────────────
|
||||||
|
const manualModal = reactive({
|
||||||
|
open: false,
|
||||||
|
loading: false,
|
||||||
|
target: 'empty' as 'empty' | 'full',
|
||||||
|
weight: null as string | null,
|
||||||
|
dsd: null as string | null,
|
||||||
|
errors: {} as Record<string, string>,
|
||||||
|
})
|
||||||
|
|
||||||
|
function openManual(target: 'empty' | 'full'): void {
|
||||||
|
manualModal.target = target
|
||||||
|
manualModal.weight = null
|
||||||
|
manualModal.dsd = null
|
||||||
|
manualModal.errors = {}
|
||||||
|
manualModal.open = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valide la saisie manuelle (poids + DSD), remplit le bloc puis enregistre le brouillon. */
|
||||||
|
async function confirmManual(): Promise<void> {
|
||||||
|
if (manualModal.loading) return
|
||||||
|
manualModal.errors = {}
|
||||||
|
|
||||||
|
const weight = manualModal.weight === null || manualModal.weight === '' ? null : Number(manualModal.weight)
|
||||||
|
const dsd = manualModal.dsd === null || manualModal.dsd === '' ? null : Number(manualModal.dsd)
|
||||||
|
if (weight === null || Number.isNaN(weight)) {
|
||||||
|
manualModal.errors = { ...manualModal.errors, weight: t('logistique.weighingTickets.form.manual.weightRequired') }
|
||||||
|
}
|
||||||
|
if (dsd === null || Number.isNaN(dsd)) {
|
||||||
|
manualModal.errors = { ...manualModal.errors, dsd: t('logistique.weighingTickets.form.manual.dsdRequired') }
|
||||||
|
}
|
||||||
|
if (Object.keys(manualModal.errors).length > 0) return
|
||||||
|
|
||||||
|
manualModal.loading = true
|
||||||
|
try {
|
||||||
|
const reading = await weighbridge.triggerManual(weight as number, dsd as number)
|
||||||
|
form.applyReading(form[manualModal.target], reading)
|
||||||
|
manualModal.open = false
|
||||||
|
await saveDraft()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
// 422 de pesée (poids/DSD ≤ 0, Assert\Positive) → erreur sous le BON champ
|
||||||
|
// (le propertyPath back `weight`/`dsd` = nom du champ de la modale). Sinon
|
||||||
|
// (503 pont indispo, réseau) → message générique sous le champ Poids.
|
||||||
|
const violations = mapViolationsToRecord((error as { response?: { _data?: unknown } })?.response?._data)
|
||||||
|
manualModal.errors = Object.keys(violations).length > 0
|
||||||
|
? violations
|
||||||
|
: { weight: weighbridge.extractWeighbridgeError(error) }
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
manualModal.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Persistance ──────────────────────────────────────────────────────────────
|
||||||
|
interface TicketResponse { id: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enregistre l'état courant en BROUILLON (ERP-193) : POST si le ticket n'existe pas
|
||||||
|
* encore (1ʳᵉ pesée enregistrée), PATCH ensuite. Renvoie false sur erreur (422
|
||||||
|
* mappée inline, ex. format d'immatriculation).
|
||||||
|
*/
|
||||||
|
async function saveDraft(): Promise<boolean> {
|
||||||
|
clearErrors()
|
||||||
|
try {
|
||||||
|
if (form.ticketId.value === null) {
|
||||||
|
const created = await api.post<TicketResponse>('/weighing_tickets', form.buildDraftPayload(), {
|
||||||
|
headers: { Accept: 'application/ld+json' },
|
||||||
|
toast: false,
|
||||||
|
})
|
||||||
|
form.ticketId.value = created.id
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await api.patch(`/weighing_tickets/${form.ticketId.value}`, form.buildDraftPayload(), { toast: false })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
handleApiError(error, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* « Valider » : persiste l'état courant puis finalise via PATCH /validate. La
|
||||||
|
* validation stricte (3 champs du haut + 2 pesées) est portée par le back ; les 422
|
||||||
|
* remontent inline. Succès → ouverture du bon de pesée PDF + retour à la liste.
|
||||||
|
*/
|
||||||
|
async function submitValidate(): Promise<void> {
|
||||||
|
if (validating.value) return
|
||||||
|
validating.value = true
|
||||||
|
try {
|
||||||
|
if (!(await saveDraft())) return
|
||||||
|
|
||||||
|
await api.patch(`/weighing_tickets/${form.ticketId.value}/validate`, form.buildValidatePayload(), { toast: false })
|
||||||
|
window.open(`/api/weighing_tickets/${form.ticketId.value}/print.pdf`, '_blank')
|
||||||
|
router.push('/weighing-tickets')
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
handleApiError(error, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
validating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
referentials.load().catch(() => {})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { formatDateFr, formatWeightKg, formatPlate } from '../weighingTicketFormat'
|
||||||
|
|
||||||
|
describe('weighingTicketFormat', () => {
|
||||||
|
// ── Date JJ-MM-AAAA ───────────────────────────────────────────────────────
|
||||||
|
describe('formatDateFr', () => {
|
||||||
|
it('formate un datetime ISO en JJ-MM-AAAA', () => {
|
||||||
|
expect(formatDateFr('2026-06-17T09:12:00+02:00')).toBe('17-06-2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zéro-pad le jour et le mois', () => {
|
||||||
|
expect(formatDateFr('2026-01-05T00:00:00Z')).toBe('05-01-2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne une chaîne vide si absente ou invalide', () => {
|
||||||
|
expect(formatDateFr(null)).toBe('')
|
||||||
|
expect(formatDateFr(undefined)).toBe('')
|
||||||
|
expect(formatDateFr('pas-une-date')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Poids « X XXX Kg » ────────────────────────────────────────────────────
|
||||||
|
describe('formatWeightKg', () => {
|
||||||
|
it('ajoute un séparateur de milliers (espace) et le suffixe Kg', () => {
|
||||||
|
expect(formatWeightKg(7150)).toBe('7 150 Kg')
|
||||||
|
expect(formatWeightKg(14300)).toBe('14 300 Kg')
|
||||||
|
expect(formatWeightKg(1000000)).toBe('1 000 000 Kg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gère les petits nombres sans séparateur', () => {
|
||||||
|
expect(formatWeightKg(0)).toBe('0 Kg')
|
||||||
|
expect(formatWeightKg(999)).toBe('999 Kg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne une chaîne vide si le poids est absent', () => {
|
||||||
|
expect(formatWeightKg(null)).toBe('')
|
||||||
|
expect(formatWeightKg(undefined)).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Immatriculation UPPER ─────────────────────────────────────────────────
|
||||||
|
describe('formatPlate', () => {
|
||||||
|
it('met en majuscules et trim', () => {
|
||||||
|
expect(formatPlate(' ab-123-cd ')).toBe('AB-123-CD')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne une chaîne vide si absente', () => {
|
||||||
|
expect(formatPlate(null)).toBe('')
|
||||||
|
expect(formatPlate('')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { MaskInputOptions } from 'maska'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Masques de saisie du module « Tickets de pesée » (M5). Partagés entre le
|
||||||
|
* composant de bloc (`WeighingBlock`) et les modales de pesée (écrans Ajouter /
|
||||||
|
* Modifier). La validation de format reste autoritaire côté serveur (RG-5.01).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Masque « chiffres uniquement » (longueur libre) — Poids et DSD. Verrouille la
|
||||||
|
* saisie sur des entiers.
|
||||||
|
*/
|
||||||
|
export const NUMERIC_MASK: MaskInputOptions = {
|
||||||
|
mask: 'D',
|
||||||
|
tokens: { D: { pattern: /[0-9]/, multiple: true } },
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Masque plaque FR SIV `XX-000-XX` : 2 lettres, 3 chiffres, 2 lettres, majuscules
|
||||||
|
* forcées. Utilisé quand « Tout format » n'est pas coché (RG-5.01).
|
||||||
|
*/
|
||||||
|
export const PLATE_MASK: MaskInputOptions = {
|
||||||
|
mask: 'AA-###-AA',
|
||||||
|
tokens: { A: { pattern: /[A-Za-z]/, transform: (c: string) => c.toUpperCase() } },
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Masque « Tout format » (RG-5.01) : plaques anciennes / étrangères / engins. On
|
||||||
|
* autorise lettres, chiffres, espace et tiret, en MAJUSCULES, longueur libre —
|
||||||
|
* mais on filtre tout le reste (accents, ponctuation, symboles : « &é"'(_ç… »).
|
||||||
|
* Pattern maska charset du projet (cf. shared/utils/textSanitize) : `preProcess`
|
||||||
|
* retire d'abord les caractères hors charset (le token `multiple` glouton
|
||||||
|
* s'arrêterait sinon au 1er invalide), puis le token laisse passer le reste.
|
||||||
|
*/
|
||||||
|
export const FREE_PLATE_MASK: MaskInputOptions = {
|
||||||
|
mask: 'P',
|
||||||
|
tokens: { P: { pattern: /[A-Z0-9 -]/, multiple: true } },
|
||||||
|
preProcess: (value: string) => value.toUpperCase().replace(/[^A-Z0-9 -]/g, ''),
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Filtres d'affichage du module « Tickets de pesée » (M5, ERP-191). Helpers PURS
|
||||||
|
* et testables, partagés par la liste et les écrans. Le serveur reste l'autorité
|
||||||
|
* de normalisation (spec-front § Règles de formatage) : ces helpers ne font que
|
||||||
|
* mettre en forme la valeur déjà normalisée renvoyée par l'API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Date courte française `JJ-MM-AAAA` (spec M5) : helper partagé inter-modules
|
||||||
|
// (mutualisé avec les répertoires M1→M4). Re-exporté ici pour les écrans M5.
|
||||||
|
export { formatDateFr } from '~/shared/utils/date'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poids en kg avec séparateur de milliers (espace) + suffixe « Kg »
|
||||||
|
* (spec-front : « 7 150 Kg »). Chaîne vide si le poids est absent (ticket dont la
|
||||||
|
* pesée à plein n'est pas finalisée). Groupement manuel (espace ASCII) pour un
|
||||||
|
* rendu déterministe, indépendant de l'ICU de l'environnement.
|
||||||
|
*/
|
||||||
|
export function formatWeightKg(value: number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const grouped = String(Math.round(value)).replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
|
||||||
|
return `${grouped} Kg`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immatriculation en MAJUSCULES (cohérent avec la normalisation serveur RG-5.01 :
|
||||||
|
* trim + UPPER). Chaîne vide si absente.
|
||||||
|
*/
|
||||||
|
export function formatPlate(value: string | null | undefined): string {
|
||||||
|
return value ? value.trim().toUpperCase() : ''
|
||||||
|
}
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
@click="emit('update:modelValue', false)"
|
@click="emit('update:modelValue', false)"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
:label="t('common.save')"
|
:label="isEditMode ? t('common.save') : t('common.validate')"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
button-class="w-m-btn-action"
|
button-class="w-m-btn-action"
|
||||||
:disabled="saving || !isValidHex"
|
:disabled="saving || !isValidHex"
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
|
||||||
|
(pas de bordure sous le dernier bloc). -->
|
||||||
|
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
|
||||||
|
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
|
||||||
|
<!-- Suppression : modal de confirmation cote parent. -->
|
||||||
|
<MalioButtonIcon
|
||||||
|
v-if="removable && !readonly && !disabled"
|
||||||
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
|
button-class="p-0"
|
||||||
|
v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }"
|
||||||
|
@click="$emit('remove')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grille 4 colonnes des champs de l'adresse. -->
|
||||||
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-if="!hideEmpty || isFilled(model.siteIris)"
|
||||||
|
:model-value="model.siteIris"
|
||||||
|
:options="siteOptions"
|
||||||
|
:label="t('technique.providers.form.address.sites')"
|
||||||
|
:display-tag="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
:error="errors?.sites"
|
||||||
|
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-if="!hideEmpty || isFilled(model.contactIris)"
|
||||||
|
:model-value="model.contactIris"
|
||||||
|
:options="contactOptions"
|
||||||
|
:label="t('technique.providers.form.address.contacts')"
|
||||||
|
:display-tag="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioSelect
|
||||||
|
v-if="!hideEmpty || isFilled(model.country)"
|
||||||
|
:model-value="model.country"
|
||||||
|
:options="countryOptions"
|
||||||
|
:label="t('technique.providers.form.address.country')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioInputText
|
||||||
|
v-if="!hideEmpty || isFilled(model.postalCode)"
|
||||||
|
:model-value="model.postalCode"
|
||||||
|
:label="t('technique.providers.form.address.postalCode')"
|
||||||
|
:mask="POSTAL_CODE_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
:error="errors?.postalCode"
|
||||||
|
@update:model-value="onPostalCodeChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
|
||||||
|
<MalioSelect
|
||||||
|
v-if="!degraded && (!hideEmpty || isFilled(model.city))"
|
||||||
|
:model-value="model.city"
|
||||||
|
:options="cityOptions"
|
||||||
|
:label="t('technique.providers.form.address.city')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
:error="errors?.city"
|
||||||
|
@update:model-value="onCityChange"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-else-if="degraded && (!hideEmpty || isFilled(model.city))"
|
||||||
|
:model-value="model.city"
|
||||||
|
:label="t('technique.providers.form.address.city')"
|
||||||
|
:mask="ADDRESS_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
: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 v-if="!hideEmpty || isFilled(model.street)" class="col-span-2">
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-if="!readonly && !disabled"
|
||||||
|
:model-value="model.street"
|
||||||
|
:options="addressOptions"
|
||||||
|
:loading="addressLoading"
|
||||||
|
:min-search-length="3"
|
||||||
|
:label="t('technique.providers.form.address.street')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
: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"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
:error="errors?.street"
|
||||||
|
@update:model-value="(v: string) => update('street', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.streetComplement"
|
||||||
|
:label="t('technique.providers.form.address.streetComplement')"
|
||||||
|
:mask="ADDRESS_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors?.streetComplement"
|
||||||
|
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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'
|
||||||
|
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
|
||||||
|
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||||
|
|
||||||
|
// Masque code postal FR : 5 chiffres.
|
||||||
|
const POSTAL_CODE_MASK = '#####'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Brouillon de l'adresse (v-model). */
|
||||||
|
modelValue: ProviderAddressFormDraft
|
||||||
|
/** Titre du bloc (ex: « Adresse 1 »). */
|
||||||
|
title: string
|
||||||
|
/** Sites Starseed disponibles. */
|
||||||
|
siteOptions: RefOption[]
|
||||||
|
/** Contacts deja saisis, rattachables a l'adresse. */
|
||||||
|
contactOptions: RefOption[]
|
||||||
|
/** Pays disponibles (France par defaut). */
|
||||||
|
countryOptions: RefOption[]
|
||||||
|
removable?: boolean
|
||||||
|
/** Dernier bloc de la liste : supprime le filet de separation bas. */
|
||||||
|
last?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||||
|
disabled?: boolean
|
||||||
|
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||||
|
hideEmpty?: 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[] = []
|
||||||
|
|
||||||
|
// Filtrage des caracteres parasites : porte par le mask ADDRESS_MASK (maska) sur
|
||||||
|
// les champs texte editables (complement, ville en mode degrade). La voie en
|
||||||
|
// autocomplete (BAN) et la ville en select ne sont pas masquees (le back valide
|
||||||
|
// via Assert\Regex).
|
||||||
|
|
||||||
|
/** 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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus
|
||||||
|
* incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur.
|
||||||
|
* En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset
|
||||||
|
* a chaque frappe).
|
||||||
|
*/
|
||||||
|
function onCityChange(value: string | number | null): void {
|
||||||
|
const next = value === null ? null : String(value)
|
||||||
|
if (next === (props.modelValue.city ?? null)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
banAddressOptions.value = []
|
||||||
|
lastAddressSuggestions = []
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
city: next,
|
||||||
|
street: null,
|
||||||
|
streetComplement: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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> {
|
||||||
|
const digits = (value ?? '').replace(/\D/g, '')
|
||||||
|
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
|
||||||
|
|
||||||
|
// CP complet (5 chiffres) et reellement modifie → ville, adresse et complement
|
||||||
|
// deviennent incoherents avec le nouveau code postal : on les vide pour forcer
|
||||||
|
// une re-saisie coherente (on n'efface pas pendant une correction partielle).
|
||||||
|
if (digits.length === 5 && digits !== previousDigits) {
|
||||||
|
banAddressOptions.value = []
|
||||||
|
lastAddressSuggestions = []
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
postalCode: value,
|
||||||
|
city: null,
|
||||||
|
street: null,
|
||||||
|
streetComplement: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
update('postalCode', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,144 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
|
||||||
|
(pas de bordure sous le dernier bloc). -->
|
||||||
|
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
|
||||||
|
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
|
||||||
|
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||||
|
non supprimable (1er bloc) ou en lecture seule. -->
|
||||||
|
<MalioButtonIcon
|
||||||
|
v-if="removable && !readonly && !disabled"
|
||||||
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
|
button-class="p-0"
|
||||||
|
v-bind="{ ariaLabel: t('technique.providers.form.contact.remove') }"
|
||||||
|
@click="$emit('remove')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grille 4 colonnes des champs du contact. -->
|
||||||
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText
|
||||||
|
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||||
|
:model-value="model.lastName"
|
||||||
|
:label="t('technique.providers.form.contact.lastName')"
|
||||||
|
:mask="PERSON_NAME_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors?.lastName"
|
||||||
|
@update:model-value="(v: string) => update('lastName', v)"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||||
|
:model-value="model.firstName"
|
||||||
|
:label="t('technique.providers.form.contact.firstName')"
|
||||||
|
:mask="PERSON_NAME_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
: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 v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.jobTitle"
|
||||||
|
:label="t('technique.providers.form.contact.jobTitle')"
|
||||||
|
:mask="FREE_TEXT_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors?.jobTitle"
|
||||||
|
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<MalioInputEmail
|
||||||
|
v-if="!hideEmpty || isFilled(model.email)"
|
||||||
|
:model-value="model.email"
|
||||||
|
:label="t('technique.providers.form.contact.email')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:lowercase="true"
|
||||||
|
:error="errors?.email"
|
||||||
|
@update:model-value="(v: string) => update('email', v)"
|
||||||
|
/>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||||
|
:model-value="model.phonePrimary"
|
||||||
|
:label="t('technique.providers.form.contact.phonePrimary')"
|
||||||
|
:mask="PHONE_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
: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 && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||||
|
:model-value="model.phoneSecondary"
|
||||||
|
:label="t('technique.providers.form.contact.phoneSecondary')"
|
||||||
|
:mask="PHONE_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors?.phoneSecondary"
|
||||||
|
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm'
|
||||||
|
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
|
||||||
|
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||||
|
|
||||||
|
// 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
|
||||||
|
/** Titre du bloc (ex: « Contact 1 »). */
|
||||||
|
title: string
|
||||||
|
/** Affiche l'icone de suppression (1er bloc non supprimable). */
|
||||||
|
removable?: boolean
|
||||||
|
/** Dernier bloc de la liste : supprime le filet de separation bas. */
|
||||||
|
last?: boolean
|
||||||
|
/** Bloc en lecture seule (onglet valide). */
|
||||||
|
readonly?: boolean
|
||||||
|
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||||
|
disabled?: boolean
|
||||||
|
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||||
|
hideEmpty?: 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)
|
||||||
|
|
||||||
|
// Filtrage des caracteres parasites : porte par les masks maska sur les champs
|
||||||
|
// (PERSON_NAME_MASK / FREE_TEXT_MASK), filtrage natif au focus/curseur. L'email n'a
|
||||||
|
// pas de mask (ERP-101 : validation de format via Assert\Email + erreur inline).
|
||||||
|
|
||||||
|
/** 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,154 @@
|
|||||||
|
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 },
|
||||||
|
title: 'Adresse 1',
|
||||||
|
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 l\'erreur serveur sur sites (RG-3.05)', () => {
|
||||||
|
const wrapper = mountBlock({}, {
|
||||||
|
sites: 'Au moins un site est obligatoire.',
|
||||||
|
})
|
||||||
|
const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
|
||||||
|
const sitesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.sites')
|
||||||
|
|
||||||
|
expect(sitesField?.attributes('error')).toBe('Au moins un site 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,56 @@
|
|||||||
|
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(),
|
||||||
|
title: 'Contact 1',
|
||||||
|
...(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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -19,8 +19,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
|
|
||||||
const mockPost = vi.hoisted(() => vi.fn())
|
const mockPost = vi.hoisted(() => vi.fn())
|
||||||
const mockPatch = vi.hoisted(() => vi.fn())
|
const mockPatch = vi.hoisted(() => vi.fn())
|
||||||
// Permission accounting.view pilotable par test (presence de l'onglet Comptabilite).
|
// Permissions comptables pilotables par test (presence/edition de l'onglet Comptabilite).
|
||||||
const permState = vi.hoisted(() => ({ accountingView: false }))
|
const permState = vi.hoisted(() => ({ accountingView: false, accountingManage: false }))
|
||||||
|
|
||||||
vi.stubGlobal('useApi', () => ({
|
vi.stubGlobal('useApi', () => ({
|
||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
@@ -37,29 +37,46 @@ vi.stubGlobal('useToast', () => ({
|
|||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
}))
|
}))
|
||||||
vi.stubGlobal('usePermissions', () => ({
|
vi.stubGlobal('usePermissions', () => ({
|
||||||
can: (perm: string) => perm === 'technique.providers.accounting.view' ? permState.accountingView : true,
|
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 { 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 SITE_86 = '/api/sites/1'
|
||||||
const CAT_MAINT = '/api/categories/7'
|
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', () => {
|
describe('useProviderForm', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockPost.mockReset()
|
mockPost.mockReset()
|
||||||
mockPatch.mockReset()
|
mockPatch.mockReset()
|
||||||
permState.accountingView = false
|
permState.accountingView = false
|
||||||
|
permState.accountingManage = false
|
||||||
})
|
})
|
||||||
|
|
||||||
it('RG-3.03/RG-3.09 (front) : bloque le POST si aucun site / aucune categorie', async () => {
|
it('front : formulaire principal vide -> erreurs sur nom + site + categorie, pas de POST', async () => {
|
||||||
const form = useProviderForm()
|
const form = useProviderForm()
|
||||||
form.main.companyName = 'Maintenance Pro'
|
|
||||||
|
|
||||||
const created = await form.submitMain()
|
const created = await form.submitMain()
|
||||||
|
|
||||||
expect(created).toBe(false)
|
expect(created).toBe(false)
|
||||||
expect(mockPost).not.toHaveBeenCalled()
|
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.sites).toBe('technique.providers.form.errors.siteRequired')
|
||||||
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
|
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
|
||||||
expect(form.mainLocked.value).toBe(false)
|
expect(form.mainLocked.value).toBe(false)
|
||||||
@@ -105,18 +122,17 @@ describe('useProviderForm', () => {
|
|||||||
expect(form.unlockedIndex.value).toBe(0)
|
expect(form.unlockedIndex.value).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('omet companyName vide du payload (laisse la 422 NotBlank back mordre)', async () => {
|
it('front : nom vide/espaces -> erreur inline sur companyName, pas de POST', async () => {
|
||||||
mockPost.mockResolvedValueOnce({ id: 1, companyName: null })
|
|
||||||
const form = useProviderForm()
|
const form = useProviderForm()
|
||||||
form.main.companyName = ' '
|
form.main.companyName = ' '
|
||||||
form.main.categoryIris = [CAT_MAINT]
|
form.main.categoryIris = [CAT_MAINT]
|
||||||
form.main.siteIris = [SITE_86]
|
form.main.siteIris = [SITE_86]
|
||||||
|
|
||||||
await form.submitMain()
|
const created = await form.submitMain()
|
||||||
|
|
||||||
const body = (mockPost.mock.calls[0] ?? [])[1] as Record<string, unknown>
|
expect(created).toBe(false)
|
||||||
expect(body).not.toHaveProperty('companyName')
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
expect(body).toEqual({ categories: [CAT_MAINT], sites: [SITE_86] })
|
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 () => {
|
it('409 doublon (RG-3.10) : erreur inline dediee sur companyName, pas de verrouillage', async () => {
|
||||||
@@ -190,3 +206,445 @@ describe('useProviderForm', () => {
|
|||||||
expect(mockPatch).toHaveBeenCalledWith('/providers/9', { siren: '123456789' }, { toast: false })
|
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 + scalaires requis). */
|
||||||
|
function fillValidAddress(form: ProviderForm, index = 0): void {
|
||||||
|
const a = addressAt(form, index)
|
||||||
|
a.siteIris = [SITE_86]
|
||||||
|
a.postalCode = '86100'
|
||||||
|
a.city = 'Châtellerault'
|
||||||
|
a.street = '1 rue du Test'
|
||||||
|
}
|
||||||
|
|
||||||
|
it('RG-3.05 : « + Nouvelle adresse » desactive tant que le site manque', () => {
|
||||||
|
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(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], 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
|||||||
* - l'enveloppe Hydra (member / totalItems) est consommee
|
* - l'enveloppe Hydra (member / totalItems) est consommee
|
||||||
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
|
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
|
||||||
* renvoie un tableau plat sans pagination)
|
* renvoie un tableau plat sans pagination)
|
||||||
* - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `includeArchived` n'est envoye
|
* - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `archivedOnly` n'est envoye
|
||||||
* tant que l'utilisateur ne coche pas le filtre (le back masque alors les
|
* tant que l'utilisateur ne coche pas le filtre (le back masque alors les
|
||||||
* archives) ; le filtre `includeArchived` est bien transmis une fois applique.
|
* archives) ; le filtre `archivedOnly` est bien transmis une fois applique.
|
||||||
*/
|
*/
|
||||||
describe('useProvidersRepository', () => {
|
describe('useProvidersRepository', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -44,7 +44,7 @@ describe('useProvidersRepository', () => {
|
|||||||
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
||||||
const [url, query, opts] = mockApiGet.mock.calls[0]
|
const [url, query, opts] = mockApiGet.mock.calls[0]
|
||||||
expect(url).toBe('/providers')
|
expect(url).toBe('/providers')
|
||||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
|
expect(query).toMatchObject({ page: 1, itemsPerPage: 25 })
|
||||||
expect(opts).toMatchObject({
|
expect(opts).toMatchObject({
|
||||||
toast: false,
|
toast: false,
|
||||||
headers: { Accept: 'application/ld+json' },
|
headers: { Accept: 'application/ld+json' },
|
||||||
@@ -53,26 +53,26 @@ describe('useProvidersRepository', () => {
|
|||||||
expect(repo.totalItems.value).toBe(1)
|
expect(repo.totalItems.value).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('exclut les archives par defaut : aucun includeArchived au premier fetch', async () => {
|
it('exclut les archives par defaut : aucun archivedOnly au premier fetch', async () => {
|
||||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||||
const repo = useProvidersRepository()
|
const repo = useProvidersRepository()
|
||||||
|
|
||||||
await repo.fetch()
|
await repo.fetch()
|
||||||
|
|
||||||
const query = mockApiGet.mock.calls[0][1] as Record<string, unknown>
|
const query = mockApiGet.mock.calls[0][1] as Record<string, unknown>
|
||||||
expect(query.includeArchived).toBeUndefined()
|
expect(query.archivedOnly).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('transmet includeArchived une fois le filtre applique (retour page 1)', async () => {
|
it('transmet archivedOnly une fois le filtre applique (retour page 1)', async () => {
|
||||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||||
const repo = useProvidersRepository()
|
const repo = useProvidersRepository()
|
||||||
await repo.fetch()
|
await repo.fetch()
|
||||||
|
|
||||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||||
await repo.setFilters({ includeArchived: true })
|
await repo.setFilters({ archivedOnly: true })
|
||||||
|
|
||||||
expect(repo.currentPage.value).toBe(1)
|
expect(repo.currentPage.value).toBe(1)
|
||||||
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||||
expect(query.includeArchived).toBe(true)
|
expect(query.archivedOnly).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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,38 @@
|
|||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, reactive, ref, type Ref } from 'vue'
|
||||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||||
|
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
|
||||||
|
import { removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||||
import {
|
import {
|
||||||
|
emptyProviderAccounting,
|
||||||
|
emptyProviderAddress,
|
||||||
|
emptyProviderContact,
|
||||||
emptyProviderMain,
|
emptyProviderMain,
|
||||||
|
emptyProviderRib,
|
||||||
|
type ProviderAccountingDraft,
|
||||||
|
type ProviderAddressFormDraft,
|
||||||
|
type ProviderAddressResponse,
|
||||||
|
type ProviderContactFormDraft,
|
||||||
|
type ProviderContactResponse,
|
||||||
type ProviderMainDraft,
|
type ProviderMainDraft,
|
||||||
type ProviderMainResponse,
|
type ProviderMainResponse,
|
||||||
|
type ProviderRibFormDraft,
|
||||||
|
type ProviderRibResponse,
|
||||||
} from '~/modules/technique/types/providerForm'
|
} 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) —
|
* Workflow de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) —
|
||||||
@@ -46,6 +74,21 @@ export function useProviderForm() {
|
|||||||
// Erreurs de validation par champ (ERP-101) du formulaire principal.
|
// Erreurs de validation par champ (ERP-101) du formulaire principal.
|
||||||
const mainErrors = useFormErrors()
|
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'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toast de succès après suppression serveur confirmée d'une sous-ressource. */
|
||||||
|
function notifyRemovalSuccess(): void {
|
||||||
|
toast.success({ title: t('success.title'), message: t('success.deleted') })
|
||||||
|
}
|
||||||
|
|
||||||
// ── Etat du prestataire cree ────────────────────────────────────────────
|
// ── Etat du prestataire cree ────────────────────────────────────────────
|
||||||
const providerId = ref<number | null>(null)
|
const providerId = ref<number | null>(null)
|
||||||
const mainLocked = ref(false)
|
const mainLocked = ref(false)
|
||||||
@@ -57,6 +100,7 @@ export function useProviderForm() {
|
|||||||
|
|
||||||
// ── Onglets : ordre + gating progressif ───────────────────────────────────
|
// ── Onglets : ordre + gating progressif ───────────────────────────────────
|
||||||
const canAccountingView = computed(() => can('technique.providers.accounting.view'))
|
const canAccountingView = computed(() => can('technique.providers.accounting.view'))
|
||||||
|
const canAccountingManage = computed(() => can('technique.providers.accounting.manage'))
|
||||||
const tabKeys = computed(() => buildProviderCreateTabKeys(canAccountingView.value))
|
const tabKeys = computed(() => buildProviderCreateTabKeys(canAccountingView.value))
|
||||||
|
|
||||||
// Index du dernier onglet deverrouille (-1 tant que le prestataire n'est pas cree).
|
// Index du dernier onglet deverrouille (-1 tant que le prestataire n'est pas cree).
|
||||||
@@ -64,6 +108,9 @@ export function useProviderForm() {
|
|||||||
const activeTab = ref<string>('contact')
|
const activeTab = ref<string>('contact')
|
||||||
// Onglets valides (passent en lecture seule).
|
// Onglets valides (passent en lecture seule).
|
||||||
const validated = reactive<Record<string, boolean>>({})
|
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 {
|
function isValidated(key: string): boolean {
|
||||||
return validated[key] === true
|
return validated[key] === true
|
||||||
@@ -81,6 +128,10 @@ export function useProviderForm() {
|
|||||||
*/
|
*/
|
||||||
function validateMainFront(): boolean {
|
function validateMainFront(): boolean {
|
||||||
let valid = true
|
let valid = true
|
||||||
|
if (!main.companyName?.trim()) {
|
||||||
|
mainErrors.setError('companyName', t('technique.providers.form.errors.nameRequired'))
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
if (main.siteIris.length === 0) {
|
if (main.siteIris.length === 0) {
|
||||||
mainErrors.setError('sites', t('technique.providers.form.errors.siteRequired'))
|
mainErrors.setError('sites', t('technique.providers.form.errors.siteRequired'))
|
||||||
valid = false
|
valid = false
|
||||||
@@ -165,12 +216,55 @@ export function useProviderForm() {
|
|||||||
await api.patch(`/providers/${providerId.value}`, payload, { toast: false })
|
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
|
* 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
|
* l'onglet suivant. Retourne true si c'etait le dernier onglet du flux
|
||||||
* (creation terminee), false sinon.
|
* (creation terminee), false sinon.
|
||||||
*/
|
*/
|
||||||
function completeTab(key: string): boolean {
|
function completeTab(key: string): boolean {
|
||||||
|
// En modification : navigation libre, l'onglet reste editable apres validation.
|
||||||
|
if (editMode.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
validated[key] = true
|
validated[key] = true
|
||||||
const index = tabIndex(key)
|
const index = tabIndex(key)
|
||||||
const next = tabKeys.value[index + 1]
|
const next = tabKeys.value[index + 1]
|
||||||
@@ -182,6 +276,329 @@ export function useProviderForm() {
|
|||||||
return false
|
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,
|
||||||
|
onSuccess: notifyRemovalSuccess,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
onSuccess: notifyRemovalSuccess,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
onSuccess: notifyRemovalSuccess,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
return {
|
||||||
// etat
|
// etat
|
||||||
main,
|
main,
|
||||||
@@ -192,16 +609,45 @@ export function useProviderForm() {
|
|||||||
mainErrors,
|
mainErrors,
|
||||||
// onglets
|
// onglets
|
||||||
canAccountingView,
|
canAccountingView,
|
||||||
|
canAccountingManage,
|
||||||
tabKeys,
|
tabKeys,
|
||||||
activeTab,
|
activeTab,
|
||||||
unlockedIndex,
|
unlockedIndex,
|
||||||
validated,
|
validated,
|
||||||
|
editMode,
|
||||||
isValidated,
|
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
|
// actions
|
||||||
validateMainFront,
|
validateMainFront,
|
||||||
buildMainPayload,
|
buildMainPayload,
|
||||||
submitMain,
|
submitMain,
|
||||||
|
updateMain,
|
||||||
patchProvider,
|
patchProvider,
|
||||||
completeTab,
|
completeTab,
|
||||||
|
submitRows,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,20 @@ export interface RefOption {
|
|||||||
label: 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 {
|
interface HydraMember {
|
||||||
'@id': string
|
'@id': string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ReferentialMember extends HydraMember {
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
interface CategoryMember extends HydraMember {
|
interface CategoryMember extends HydraMember {
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
@@ -42,6 +52,11 @@ interface SiteMember extends HydraMember {
|
|||||||
postalCode: string
|
postalCode: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CountryMember extends HydraMember {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||||
|
|
||||||
export function useProviderReferentials() {
|
export function useProviderReferentials() {
|
||||||
@@ -49,6 +64,12 @@ export function useProviderReferentials() {
|
|||||||
|
|
||||||
const categories = ref<RefOption[]>([])
|
const categories = ref<RefOption[]>([])
|
||||||
const sites = 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. */
|
/** Recupere une collection complete (pagination desactivee) en Hydra. */
|
||||||
async function fetchAll<T extends HydraMember>(
|
async function fetchAll<T extends HydraMember>(
|
||||||
@@ -74,12 +95,42 @@ export function useProviderReferentials() {
|
|||||||
// du code postal du site), ex: 86100 -> « 86 », 17400 -> « 17 ».
|
// du code postal du site), ex: 86100 -> « 86 », 17400 -> « 17 ».
|
||||||
fetchAll<SiteMember>('/sites')
|
fetchAll<SiteMember>('/sites')
|
||||||
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
|
.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 {
|
return {
|
||||||
categories,
|
categories,
|
||||||
sites,
|
sites,
|
||||||
|
countries,
|
||||||
|
tvaModes,
|
||||||
|
paymentDelays,
|
||||||
|
paymentTypes,
|
||||||
|
banks,
|
||||||
loadMain,
|
loadMain,
|
||||||
|
loadAccounting,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,10 +45,11 @@ export interface Provider {
|
|||||||
* sur la ressource `/providers` (pagination serveur obligatoire ; jamais de
|
* sur la ressource `/providers` (pagination serveur obligatoire ; jamais de
|
||||||
* chargement integral en memoire). Miroir de `useSuppliersRepository` (M2).
|
* chargement integral en memoire). Miroir de `useSuppliersRepository` (M2).
|
||||||
*
|
*
|
||||||
* Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes
|
* Les filtres (recherche, categories, sites, archives) sont pilotes par la page
|
||||||
* par la page via `setFilters` du composable partage — la remise en page 1 est
|
* via `setFilters` du composable partage — la remise en page 1 est garantie. Par
|
||||||
* garantie. Par defaut, aucun `includeArchived` n'est envoye : le back masque
|
* defaut, aucun `archivedOnly` n'est envoye : le back masque donc les prestataires
|
||||||
* donc les prestataires archives (exclusion par defaut, spec-back § 2.11).
|
* archives (exclusion par defaut, spec-back § 2.11). Cocher « Voir les archivés »
|
||||||
|
* envoie `archivedOnly=true` → seules les archives sont listees (aligne sur Client).
|
||||||
*
|
*
|
||||||
* Le cloisonnement par site est applique AUTOMATIQUEMENT cote back (§ 2.13) en
|
* Le cloisonnement par site est applique AUTOMATIQUEMENT cote back (§ 2.13) en
|
||||||
* fonction de l'utilisateur — rien a filtrer cote front.
|
* fonction de l'utilisateur — rien a filtrer cote front.
|
||||||
@@ -58,5 +59,6 @@ export interface Provider {
|
|||||||
* `usePaginatedList`. Aucun reset au logout a gerer.
|
* `usePaginatedList`. Aucun reset au logout a gerer.
|
||||||
*/
|
*/
|
||||||
export function useProvidersRepository() {
|
export function useProvidersRepository() {
|
||||||
return usePaginatedList<Provider>({ url: '/providers' })
|
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193).
|
||||||
|
return usePaginatedList<Provider>({ url: '/providers', defaultItemsPerPage: 25 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,561 @@
|
|||||||
|
<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"
|
||||||
|
:title="t('technique.providers.edit.back')"
|
||||||
|
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')"
|
||||||
|
:mask="FREE_TEXT_MASK"
|
||||||
|
:required="true"
|
||||||
|
:disabled="businessReadonly"
|
||||||
|
:error="mainErrors.errors.companyName"
|
||||||
|
/>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="main.categoryIris"
|
||||||
|
:options="referentials.categories.value"
|
||||||
|
:label="t('technique.providers.form.main.categories')"
|
||||||
|
:display-tag="true"
|
||||||
|
:disabled="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"
|
||||||
|
:disabled="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"
|
||||||
|
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
|
||||||
|
:removable="isRowRemovable(contacts, index)"
|
||||||
|
:last="index === contacts.length - 1"
|
||||||
|
:disabled="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"
|
||||||
|
:title="t('technique.providers.form.address.title', { n: index + 1 })"
|
||||||
|
:last="index === addresses.length - 1"
|
||||||
|
:site-options="referentials.sites.value"
|
||||||
|
:contact-options="contactOptions"
|
||||||
|
:country-options="countryOptions"
|
||||||
|
:removable="isRowRemovable(addresses, index)"
|
||||||
|
:disabled="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">
|
||||||
|
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
|
||||||
|
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
|
||||||
|
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.infoTitle') }}</h2>
|
||||||
|
<div class="mt-6 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"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
:required="true"
|
||||||
|
:error="accountingErrors.errors.siren"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="accounting.accountNumber"
|
||||||
|
:label="t('technique.providers.form.accounting.accountNumber')"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
:required="true"
|
||||||
|
:error="accountingErrors.errors.accountNumber"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="accounting.tvaModeIri"
|
||||||
|
:options="referentials.tvaModes.value"
|
||||||
|
:label="t('technique.providers.form.accounting.tvaMode')"
|
||||||
|
:disabled="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')"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
:required="true"
|
||||||
|
:error="accountingErrors.errors.nTva"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="accounting.paymentDelayIri"
|
||||||
|
:options="referentials.paymentDelays.value"
|
||||||
|
:label="t('technique.providers.form.accounting.paymentDelay')"
|
||||||
|
:disabled="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')"
|
||||||
|
:disabled="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')"
|
||||||
|
:disabled="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).
|
||||||
|
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
|
||||||
|
<div
|
||||||
|
v-for="(rib, index) in visibleRibs"
|
||||||
|
:key="index"
|
||||||
|
class="pb-[20px]"
|
||||||
|
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
|
||||||
|
>
|
||||||
|
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
|
||||||
|
<MalioButtonIcon
|
||||||
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
|
button-class="p-0"
|
||||||
|
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
|
||||||
|
@click="askRemoveRib(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="rib.label"
|
||||||
|
:label="t('technique.providers.form.accounting.ribLabel')"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
:required="true"
|
||||||
|
:error="ribErrors[index]?.label"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="rib.bic"
|
||||||
|
:label="t('technique.providers.form.accounting.ribBic')"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
:required="true"
|
||||||
|
:error="ribErrors[index]?.bic"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="rib.iban"
|
||||||
|
:label="t('technique.providers.form.accounting.ribIban')"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
|
:disabled="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'
|
||||||
|
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
|
||||||
|
|
||||||
|
// 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,337 @@
|
|||||||
|
<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"
|
||||||
|
:title="t('technique.providers.consultation.back')"
|
||||||
|
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="danger"
|
||||||
|
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
|
||||||
|
v-if="isFilled(provider.companyName)"
|
||||||
|
:model-value="provider.companyName"
|
||||||
|
:label="t('technique.providers.form.main.companyName')"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-if="isFilled(mainCategoryIris)"
|
||||||
|
:model-value="mainCategoryIris"
|
||||||
|
:options="mainCategoryOptions"
|
||||||
|
:label="t('technique.providers.form.main.categories')"
|
||||||
|
:display-tag="true"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-if="isFilled(mainSiteIris)"
|
||||||
|
:model-value="mainSiteIris"
|
||||||
|
:options="mainSiteOptions"
|
||||||
|
:label="t('technique.providers.form.main.sites')"
|
||||||
|
:display-tag="true"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Onglets (navigation libre, tout en lecture seule) ──────────── -->
|
||||||
|
<!-- ERP-193 : barre masquee s'il ne reste aucun onglet non vide. -->
|
||||||
|
<MalioTabList v-if="visibleTabKeys.length" 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"
|
||||||
|
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
|
||||||
|
:last="index === contacts.length - 1"
|
||||||
|
disabled
|
||||||
|
hide-empty
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
:title="t('technique.providers.form.address.title', { n: index + 1 })"
|
||||||
|
:last="index === addressViews.length - 1"
|
||||||
|
:site-options="view.siteOptions"
|
||||||
|
:contact-options="contactOptions"
|
||||||
|
:country-options="countryOptionsFor(view.draft.country)"
|
||||||
|
disabled
|
||||||
|
hide-empty
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- ERP-193 : les onglets « a venir » (Rapports / Echanges) ne sont
|
||||||
|
plus rendus en consultation (masquage des onglets vides). -->
|
||||||
|
|
||||||
|
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
|
||||||
|
<template v-if="canAccountingView" #accounting>
|
||||||
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
|
||||||
|
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
|
||||||
|
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.infoTitle') }}</h2>
|
||||||
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText v-if="isFilled(accounting.siren)" :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" disabled />
|
||||||
|
<MalioInputText v-if="isFilled(accounting.accountNumber)" :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" disabled />
|
||||||
|
<MalioSelect v-if="isFilled(accounting.tvaModeIri)" :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" disabled empty-option-label="" />
|
||||||
|
<MalioInputText v-if="isFilled(accounting.nTva)" :model-value="accounting.nTva" :label="t('technique.providers.form.accounting.nTva')" disabled />
|
||||||
|
<MalioSelect v-if="isFilled(accounting.paymentDelayIri)" :model-value="accounting.paymentDelayIri" :options="paymentDelayOptions" :label="t('technique.providers.form.accounting.paymentDelay')" disabled empty-option-label="" />
|
||||||
|
<MalioSelect v-if="isFilled(accounting.paymentTypeIri)" :model-value="accounting.paymentTypeIri" :options="paymentTypeOptions" :label="t('technique.providers.form.accounting.paymentType')" disabled empty-option-label="" />
|
||||||
|
<MalioSelect v-if="isBankRequired && isFilled(accounting.bankIri)" :model-value="accounting.bankIri" :options="bankOptions" :label="t('technique.providers.form.accounting.bank')" disabled empty-option-label="" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Blocs RIB (uniquement si type de reglement = LCR).
|
||||||
|
Titre « RIB N », filet de separation sauf sous le dernier. -->
|
||||||
|
<div
|
||||||
|
v-for="(rib, index) in visibleRibs"
|
||||||
|
:key="index"
|
||||||
|
class="pb-[20px]"
|
||||||
|
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
|
||||||
|
>
|
||||||
|
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
|
||||||
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText v-if="isFilled(rib.label)" :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" disabled />
|
||||||
|
<MalioInputText v-if="isFilled(rib.bic)" :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" disabled />
|
||||||
|
<MalioInputText v-if="isFilled(rib.iban)" :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" disabled />
|
||||||
|
</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, watch } from 'vue'
|
||||||
|
import { useProvider } from '~/modules/technique/composables/useProvider'
|
||||||
|
import {
|
||||||
|
canEditProvider,
|
||||||
|
categoryOptionsOf,
|
||||||
|
contactOptionsOf,
|
||||||
|
irisOf,
|
||||||
|
mapAccountingDraft,
|
||||||
|
mapAddressToDraft,
|
||||||
|
mapContactToDraft,
|
||||||
|
mapRibToDraft,
|
||||||
|
paymentTypeCodeOf,
|
||||||
|
providerConsultationVisibleTabs,
|
||||||
|
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'
|
||||||
|
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||||
|
|
||||||
|
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 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',
|
||||||
|
}
|
||||||
|
// ERP-193 (retour metier) : on masque les coquilles (Rapports / Echanges) ET
|
||||||
|
// tout onglet de donnees vide. La liste depend donc du payload charge.
|
||||||
|
const visibleTabKeys = computed(() => providerConsultationVisibleTabs(provider.value, {
|
||||||
|
canAccountingView: canAccountingView.value,
|
||||||
|
}))
|
||||||
|
const tabs = computed(() => visibleTabKeys.value.map(
|
||||||
|
key => ({ key, label: t(`technique.providers.tab.${key}`), icon: TAB_ICONS[key] }),
|
||||||
|
))
|
||||||
|
|
||||||
|
// Onglet initial : vide tant que le prestataire n'est pas charge, puis premier
|
||||||
|
// onglet visible. Un watcher recale si l'onglet courant disparait.
|
||||||
|
const activeTab = ref('')
|
||||||
|
watch(visibleTabKeys, (keys) => {
|
||||||
|
if (keys.length === 0) {
|
||||||
|
activeTab.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!keys.includes(activeTab.value)) {
|
||||||
|
activeTab.value = keys[0]
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// ── 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 embarques).
|
||||||
|
const addressViews = computed(() => {
|
||||||
|
const views = (provider.value?.addresses ?? []).map(address => ({
|
||||||
|
draft: mapAddressToDraft(address),
|
||||||
|
siteOptions: siteOptionsOf(address.sites),
|
||||||
|
}))
|
||||||
|
return views.length > 0
|
||||||
|
? views
|
||||||
|
: [{ draft: emptyProviderAddress(), siteOptions: [] }]
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 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>
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
@update:page="goToPage"
|
@update:page="goToPage"
|
||||||
@update:per-page="setItemsPerPage"
|
@update:per-page="setItemsPerPage"
|
||||||
>
|
>
|
||||||
<!-- Categories : libelles (name) separes par une virgule. -->
|
<!-- Categories : libelles (name) separes par une virgule, aligne sur le client (ERP-193). -->
|
||||||
<template #cell-categories="{ item }">
|
<template #cell-categories="{ item }">
|
||||||
{{ formatCategories(item) }}
|
{{ formatCategories(item) }}
|
||||||
</template>
|
</template>
|
||||||
@@ -129,13 +129,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</MalioAccordionItem>
|
</MalioAccordionItem>
|
||||||
|
|
||||||
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). -->
|
<!-- Statut : bool unique. Coche = archives uniquement, sinon actifs. -->
|
||||||
<MalioAccordionItem :title="t('technique.providers.filters.status')" value="status">
|
<MalioAccordionItem :title="t('technique.providers.filters.status')" value="status">
|
||||||
<MalioCheckbox
|
<MalioCheckbox
|
||||||
id="filter-include-archived"
|
id="filter-archived-only"
|
||||||
:label="t('technique.providers.filters.includeArchived')"
|
:label="t('technique.providers.filters.archivedOnly')"
|
||||||
:model-value="draftIncludeArchived"
|
:model-value="draftArchivedOnly"
|
||||||
@update:model-value="(val: boolean) => draftIncludeArchived = val"
|
@update:model-value="(val: boolean) => draftArchivedOnly = val"
|
||||||
/>
|
/>
|
||||||
</MalioAccordionItem>
|
</MalioAccordionItem>
|
||||||
</MalioAccordion>
|
</MalioAccordion>
|
||||||
@@ -210,7 +210,7 @@ const columns = [
|
|||||||
{ key: 'lastActivity', label: t('technique.providers.column.lastActivity') },
|
{ key: 'lastActivity', label: t('technique.providers.column.lastActivity') },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Libelles des categories du prestataire, separes par une virgule (name). */
|
/** Libelles (name) des categories du prestataire, separes par une virgule (aligne sur le client, ERP-193). */
|
||||||
function formatCategories(item: Record<string, unknown>): string {
|
function formatCategories(item: Record<string, unknown>): string {
|
||||||
const categories = (item.categories as Provider['categories']) ?? []
|
const categories = (item.categories as Provider['categories']) ?? []
|
||||||
return categories.map(c => c.name).join(', ')
|
return categories.map(c => c.name).join(', ')
|
||||||
@@ -258,12 +258,12 @@ const filterDrawerOpen = ref(false)
|
|||||||
const draftSearch = ref('')
|
const draftSearch = ref('')
|
||||||
const draftCategoryCodes = ref<string[]>([])
|
const draftCategoryCodes = ref<string[]>([])
|
||||||
const draftSiteIds = ref<string[]>([])
|
const draftSiteIds = ref<string[]>([])
|
||||||
const draftIncludeArchived = ref(false)
|
const draftArchivedOnly = ref(false)
|
||||||
|
|
||||||
const appliedSearch = ref('')
|
const appliedSearch = ref('')
|
||||||
const appliedCategoryCodes = ref<string[]>([])
|
const appliedCategoryCodes = ref<string[]>([])
|
||||||
const appliedSiteIds = ref<string[]>([])
|
const appliedSiteIds = ref<string[]>([])
|
||||||
const appliedIncludeArchived = ref(false)
|
const appliedArchivedOnly = ref(false)
|
||||||
|
|
||||||
// Options des selects multi, chargees une fois (referentiels courts).
|
// Options des selects multi, chargees une fois (referentiels courts).
|
||||||
const categoryOptions = ref<FilterOption[]>([])
|
const categoryOptions = ref<FilterOption[]>([])
|
||||||
@@ -274,7 +274,7 @@ const activeFilterCount = computed(() => {
|
|||||||
if (appliedSearch.value.trim() !== '') count++
|
if (appliedSearch.value.trim() !== '') count++
|
||||||
if (appliedCategoryCodes.value.length > 0) count++
|
if (appliedCategoryCodes.value.length > 0) count++
|
||||||
if (appliedSiteIds.value.length > 0) count++
|
if (appliedSiteIds.value.length > 0) count++
|
||||||
if (appliedIncludeArchived.value) count++
|
if (appliedArchivedOnly.value) count++
|
||||||
return count
|
return count
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -289,7 +289,7 @@ function openFilters(): void {
|
|||||||
draftSearch.value = appliedSearch.value
|
draftSearch.value = appliedSearch.value
|
||||||
draftCategoryCodes.value = [...appliedCategoryCodes.value]
|
draftCategoryCodes.value = [...appliedCategoryCodes.value]
|
||||||
draftSiteIds.value = [...appliedSiteIds.value]
|
draftSiteIds.value = [...appliedSiteIds.value]
|
||||||
draftIncludeArchived.value = appliedIncludeArchived.value
|
draftArchivedOnly.value = appliedArchivedOnly.value
|
||||||
filterDrawerOpen.value = true
|
filterDrawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +315,7 @@ function buildFilterPayload(): Record<string, string | string[] | boolean> {
|
|||||||
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
||||||
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
|
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
|
||||||
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
|
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
|
||||||
if (appliedIncludeArchived.value) payload.includeArchived = true
|
if (appliedArchivedOnly.value) payload.archivedOnly = true
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,7 +325,7 @@ function applyFilters(): void {
|
|||||||
appliedSearch.value = draftSearch.value.trim()
|
appliedSearch.value = draftSearch.value.trim()
|
||||||
appliedCategoryCodes.value = [...draftCategoryCodes.value]
|
appliedCategoryCodes.value = [...draftCategoryCodes.value]
|
||||||
appliedSiteIds.value = [...draftSiteIds.value]
|
appliedSiteIds.value = [...draftSiteIds.value]
|
||||||
appliedIncludeArchived.value = draftIncludeArchived.value
|
appliedArchivedOnly.value = draftArchivedOnly.value
|
||||||
|
|
||||||
setFilters(buildFilterPayload(), { replace: true })
|
setFilters(buildFilterPayload(), { replace: true })
|
||||||
filterDrawerOpen.value = false
|
filterDrawerOpen.value = false
|
||||||
@@ -337,12 +337,12 @@ function resetFilters(): void {
|
|||||||
draftSearch.value = ''
|
draftSearch.value = ''
|
||||||
draftCategoryCodes.value = []
|
draftCategoryCodes.value = []
|
||||||
draftSiteIds.value = []
|
draftSiteIds.value = []
|
||||||
draftIncludeArchived.value = false
|
draftArchivedOnly.value = false
|
||||||
|
|
||||||
appliedSearch.value = ''
|
appliedSearch.value = ''
|
||||||
appliedCategoryCodes.value = []
|
appliedCategoryCodes.value = []
|
||||||
appliedSiteIds.value = []
|
appliedSiteIds.value = []
|
||||||
appliedIncludeArchived.value = false
|
appliedArchivedOnly.value = false
|
||||||
|
|
||||||
setFilters({}, { replace: true })
|
setFilters({}, { replace: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
icon="mdi:arrow-left-bold"
|
icon="mdi:arrow-left-bold"
|
||||||
icon-size="24"
|
icon-size="24"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
:title="t('technique.providers.form.back')"
|
||||||
v-bind="{ ariaLabel: t('technique.providers.form.back') }"
|
v-bind="{ ariaLabel: t('technique.providers.form.back') }"
|
||||||
@click="goBack"
|
@click="goBack"
|
||||||
/>
|
/>
|
||||||
@@ -21,8 +22,9 @@
|
|||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="main.companyName"
|
v-model="main.companyName"
|
||||||
:label="t('technique.providers.form.main.companyName')"
|
:label="t('technique.providers.form.main.companyName')"
|
||||||
|
:mask="FREE_TEXT_MASK"
|
||||||
:required="true"
|
:required="true"
|
||||||
:readonly="mainLocked"
|
:disabled="mainLocked"
|
||||||
:error="mainErrors.errors.companyName"
|
:error="mainErrors.errors.companyName"
|
||||||
/>
|
/>
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox
|
||||||
@@ -30,7 +32,7 @@
|
|||||||
:options="referentials.categories.value"
|
:options="referentials.categories.value"
|
||||||
:label="t('technique.providers.form.main.categories')"
|
:label="t('technique.providers.form.main.categories')"
|
||||||
:display-tag="true"
|
:display-tag="true"
|
||||||
:readonly="mainLocked"
|
:disabled="mainLocked"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="mainErrors.errors.categories"
|
:error="mainErrors.errors.categories"
|
||||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||||
@@ -40,7 +42,7 @@
|
|||||||
:options="referentials.sites.value"
|
:options="referentials.sites.value"
|
||||||
:label="t('technique.providers.form.main.sites')"
|
:label="t('technique.providers.form.main.sites')"
|
||||||
:display-tag="true"
|
:display-tag="true"
|
||||||
:readonly="mainLocked"
|
:disabled="mainLocked"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="mainErrors.errors.sites"
|
:error="mainErrors.errors.sites"
|
||||||
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
|
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
|
||||||
@@ -57,23 +59,273 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Onglets a validation incrementale ─────────────────────────────
|
<!-- ── Onglets a validation incrementale ─────────────────────────────
|
||||||
Le contenu des onglets (Contact / Adresse / Comptabilite) arrive aux
|
Onglet Contact actif (ERP-142) ; Adresse / Comptabilite arrivent aux
|
||||||
tickets ERP-142 → 144 : placeholders « A venir » pour l'instant. -->
|
tickets ERP-143 / 144 : placeholders « A venir » pour l'instant. -->
|
||||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||||
<template #contact><ComingSoonPlaceholder /></template>
|
<!-- Onglet Contact : saisie multi-contacts (blocs ajoutables). -->
|
||||||
<template #address><ComingSoonPlaceholder /></template>
|
<template #contact>
|
||||||
<template v-if="canAccountingView" #accounting><ComingSoonPlaceholder /></template>
|
<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"
|
||||||
|
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
|
||||||
|
:removable="isRowRemovable(contacts, index)"
|
||||||
|
:last="index === contacts.length - 1"
|
||||||
|
:disabled="isValidated('contact')"
|
||||||
|
:errors="contactErrors[index]"
|
||||||
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
|
@remove="askRemoveContact(index)"
|
||||||
|
/>
|
||||||
|
<!-- Masque tant que le prestataire n'est pas cree : Contact etant
|
||||||
|
l'onglet actif par defaut, ses actions (Ajouter / Valider) ne
|
||||||
|
doivent pas apparaitre a cote du Valider du formulaire principal
|
||||||
|
(ERP-193). -->
|
||||||
|
<div v-if="!isValidated('contact') && providerId !== null" 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"
|
||||||
|
@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"
|
||||||
|
:title="t('technique.providers.form.address.title', { n: index + 1 })"
|
||||||
|
:last="index === addresses.length - 1"
|
||||||
|
:site-options="referentials.sites.value"
|
||||||
|
:contact-options="contactOptions"
|
||||||
|
:country-options="countryOptions"
|
||||||
|
:removable="isRowRemovable(addresses, index)"
|
||||||
|
:disabled="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">
|
||||||
|
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
|
||||||
|
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
|
||||||
|
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.infoTitle') }}</h2>
|
||||||
|
<div class="mt-6 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"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
:required="true"
|
||||||
|
:error="accountingErrors.errors.siren"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="accounting.accountNumber"
|
||||||
|
:label="t('technique.providers.form.accounting.accountNumber')"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
:required="true"
|
||||||
|
:error="accountingErrors.errors.accountNumber"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="accounting.tvaModeIri"
|
||||||
|
:options="referentials.tvaModes.value"
|
||||||
|
:label="t('technique.providers.form.accounting.tvaMode')"
|
||||||
|
:disabled="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')"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
:required="true"
|
||||||
|
:error="accountingErrors.errors.nTva"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="accounting.paymentDelayIri"
|
||||||
|
:options="referentials.paymentDelays.value"
|
||||||
|
:label="t('technique.providers.form.accounting.paymentDelay')"
|
||||||
|
:disabled="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')"
|
||||||
|
:disabled="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')"
|
||||||
|
:disabled="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).
|
||||||
|
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
|
||||||
|
<div
|
||||||
|
v-for="(rib, index) in visibleRibs"
|
||||||
|
:key="index"
|
||||||
|
class="pb-[20px]"
|
||||||
|
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
|
||||||
|
>
|
||||||
|
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
|
||||||
|
<MalioButtonIcon
|
||||||
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
|
button-class="p-0"
|
||||||
|
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
|
||||||
|
@click="askRemoveRib(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="rib.label"
|
||||||
|
:label="t('technique.providers.form.accounting.ribLabel')"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
:required="true"
|
||||||
|
:error="ribErrors[index]?.label"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="rib.bic"
|
||||||
|
:label="t('technique.providers.form.accounting.ribBic')"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
:required="true"
|
||||||
|
:error="ribErrors[index]?.bic"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="rib.iban"
|
||||||
|
:label="t('technique.providers.form.accounting.ribIban')"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
|
:disabled="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>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { useProviderReferentials } from '~/modules/technique/composables/useProviderReferentials'
|
import { useProviderReferentials, type RefOption } from '~/modules/technique/composables/useProviderReferentials'
|
||||||
import { useProviderForm } from '~/modules/technique/composables/useProviderForm'
|
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'
|
||||||
|
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
|
||||||
|
|
||||||
|
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
||||||
|
const SIREN_MASK = '#########'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
const { can } = usePermissions()
|
const { can } = usePermissions()
|
||||||
|
|
||||||
useHead({ title: t('technique.providers.form.title') })
|
useHead({ title: t('technique.providers.form.title') })
|
||||||
@@ -89,6 +341,7 @@ const referentials = useProviderReferentials()
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
main,
|
main,
|
||||||
|
providerId,
|
||||||
mainLocked,
|
mainLocked,
|
||||||
mainSubmitting,
|
mainSubmitting,
|
||||||
mainErrors,
|
mainErrors,
|
||||||
@@ -97,6 +350,30 @@ const {
|
|||||||
activeTab,
|
activeTab,
|
||||||
unlockedIndex,
|
unlockedIndex,
|
||||||
submitMain,
|
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()
|
} = useProviderForm()
|
||||||
|
|
||||||
/** Retour vers le repertoire prestataires (fleche d'en-tete). */
|
/** Retour vers le repertoire prestataires (fleche d'en-tete). */
|
||||||
@@ -104,6 +381,155 @@ function goBack(): void {
|
|||||||
router.push('/providers')
|
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.
|
// Icone (Iconify) affichee dans l'onglet, par cle.
|
||||||
const TAB_ICONS: Record<string, string> = {
|
const TAB_ICONS: Record<string, string> = {
|
||||||
contact: 'mdi:account-box-plus-outline',
|
contact: 'mdi:account-box-plus-outline',
|
||||||
@@ -123,5 +549,9 @@ const tabs = computed(() => tabKeys.value.map((key, index) => ({
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Echec du chargement des referentiels non bloquant : les selects restent vides.
|
// Echec du chargement des referentiels non bloquant : les selects restent vides.
|
||||||
referentials.loadMain().catch(() => {})
|
referentials.loadMain().catch(() => {})
|
||||||
|
// Referentiels comptables charges uniquement si l'onglet est accessible.
|
||||||
|
if (canAccountingView.value) {
|
||||||
|
referentials.loadAccounting().catch(() => {})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -39,3 +39,136 @@ export interface ProviderMainResponse {
|
|||||||
/** Nom renvoye normalise (UPPERCASE) par le serveur, reaffiche en lecture seule. */
|
/** Nom renvoye normalise (UPPERCASE) par le serveur, reaffiche en lecture seule. */
|
||||||
companyName: string | null
|
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 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,
|
||||||
|
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,64 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
describe('isProviderAddressValid (RG-3.05)', () => {
|
||||||
|
it('false sans site', () => {
|
||||||
|
const address = { ...emptyProviderAddress() }
|
||||||
|
expect(isProviderAddressValid(address)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('true avec au moins un site', () => {
|
||||||
|
const address = { ...emptyProviderAddress(), siteIris: [SITE] }
|
||||||
|
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],
|
||||||
|
contactIris: ['/api/provider_contacts/9'],
|
||||||
|
})
|
||||||
|
expect(payload).toEqual({
|
||||||
|
country: 'France',
|
||||||
|
postalCode: '86100',
|
||||||
|
city: 'Châtellerault',
|
||||||
|
street: '1 rue du Test',
|
||||||
|
streetComplement: null,
|
||||||
|
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],
|
||||||
|
})
|
||||||
|
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,212 @@
|
|||||||
|
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,
|
||||||
|
hasAccountingData,
|
||||||
|
iriOf,
|
||||||
|
irisOf,
|
||||||
|
mapAccountingDraft,
|
||||||
|
mapAddressToDraft,
|
||||||
|
mapContactToDraft,
|
||||||
|
mapRibToDraft,
|
||||||
|
paymentTypeCodeOf,
|
||||||
|
providerConsultationVisibleTabs,
|
||||||
|
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 / 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' }],
|
||||||
|
contacts: [{ '@id': '/api/provider_contacts/5' }, '/api/provider_contacts/6'],
|
||||||
|
})
|
||||||
|
expect(draft.siteIris).toEqual(['/api/sites/1'])
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hasAccountingData (prestataire)', () => {
|
||||||
|
it('faux sans champ comptable ni RIB', () => {
|
||||||
|
expect(hasAccountingData({ '@id': '/api/providers/1', id: 1 })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vrai avec un champ comptable ou un RIB', () => {
|
||||||
|
expect(hasAccountingData({ '@id': '/api/providers/1', id: 1, siren: '123456789' })).toBe(true)
|
||||||
|
expect(hasAccountingData({
|
||||||
|
'@id': '/api/providers/1', id: 1,
|
||||||
|
ribs: [{ '@id': '/api/provider_ribs/1', id: 1, iban: 'FR76...' }],
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('providerConsultationVisibleTabs', () => {
|
||||||
|
it('retourne [] tant que le prestataire n\'est pas charge', () => {
|
||||||
|
expect(providerConsultationVisibleTabs(null, { canAccountingView: true })).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masque les coquilles (reports/exchanges) et les onglets vides', () => {
|
||||||
|
expect(providerConsultationVisibleTabs(
|
||||||
|
{ '@id': '/api/providers/1', id: 1, companyName: 'ACME' },
|
||||||
|
{ canAccountingView: true },
|
||||||
|
)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche contacts/address/accounting dans l\'ordre (pas d\'onglet information)', () => {
|
||||||
|
const provider = {
|
||||||
|
'@id': '/api/providers/1', id: 1,
|
||||||
|
contacts: [{ '@id': '/api/provider_contacts/1', id: 1 }],
|
||||||
|
addresses: [{ '@id': '/api/provider_addresses/1', id: 1 }],
|
||||||
|
siren: '123456789',
|
||||||
|
}
|
||||||
|
expect(providerConsultationVisibleTabs(provider, { canAccountingView: true }))
|
||||||
|
.toEqual(['contacts', 'address', 'accounting'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masque Comptabilite sans le droit accounting.view', () => {
|
||||||
|
expect(providerConsultationVisibleTabs(
|
||||||
|
{ '@id': '/api/providers/1', id: 1, siren: '123456789' },
|
||||||
|
{ canAccountingView: false },
|
||||||
|
)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user