Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9645caabd | |||
| eb94204c55 | |||
| 58d0c499d4 | |||
| 2b1071bedb | |||
| ec648ff2ff | |||
| fced2c2cfd | |||
| a6b8e7145e | |||
| f619a6969d | |||
| 64c3b9b6ec | |||
| ce0e274743 | |||
| f12a378126 | |||
| 04008f97a9 | |||
| 086be7b4f0 | |||
| f6c556ca1b | |||
| 4207a4ae12 | |||
| fdd4394e99 | |||
| 8085f30077 | |||
| 817975e0b7 | |||
| efded9fd40 | |||
| 2e50a760c6 | |||
| 49e5e5548e | |||
| fd430bc123 | |||
| a6b48b1dd1 | |||
| 97f2402ae4 | |||
| faafd99ef8 |
@@ -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",
|
||||||
|
|||||||
Generated
+446
-1
@@ -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": "b029c1484227c926d39dfd3ae5cb0699",
|
"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",
|
||||||
@@ -8779,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",
|
||||||
|
|||||||
+31
-20
@@ -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).
|
||||||
[
|
[
|
||||||
@@ -78,25 +98,6 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
// 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.
|
|
||||||
// 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:scale',
|
|
||||||
'items' => [
|
|
||||||
[
|
|
||||||
'label' => 'sidebar.logistique.weighing_tickets',
|
|
||||||
'to' => '/weighing-tickets',
|
|
||||||
'icon' => 'mdi:scale',
|
|
||||||
'module' => 'logistique',
|
|
||||||
'permission' => 'logistique.weighing_tickets.view',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
// Section "Administration" : regroupe toutes les pages de configuration
|
// Section "Administration" : regroupe toutes les pages de configuration
|
||||||
// applicative (RBAC, users, sites, audit log).
|
// applicative (RBAC, users, sites, audit log).
|
||||||
//
|
//
|
||||||
@@ -133,6 +134,16 @@ return [
|
|||||||
'module' => 'transport',
|
'module' => 'transport',
|
||||||
'permission' => 'transport.carriers.view',
|
'permission' => 'transport.carriers.view',
|
||||||
],
|
],
|
||||||
|
// Catalogue produit (M6, ERP-197). Place juste sous le repertoire
|
||||||
|
// transporteurs (DECISION Matthieu 24/06). Admin-only : gate par
|
||||||
|
// `catalog.products.view` et son module owner `catalog`.
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.catalog.products',
|
||||||
|
'to' => '/admin/products',
|
||||||
|
'icon' => 'mdi:package-variant-closed',
|
||||||
|
'module' => 'catalog',
|
||||||
|
'permission' => 'catalog.products.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.147'
|
app.version: '0.1.155'
|
||||||
|
|||||||
@@ -0,0 +1,698 @@
|
|||||||
|
---
|
||||||
|
# === IDENTITÉ ===
|
||||||
|
module: M6
|
||||||
|
nom: "Catalogue produit"
|
||||||
|
ecran: produits
|
||||||
|
owner_spec: Matthieu
|
||||||
|
backup_spec: Tristan
|
||||||
|
version: V0.1
|
||||||
|
date_redaction: 2026-06-24
|
||||||
|
# Historique :
|
||||||
|
# V0.1 (2026-06-24) — Spec back initiale. Restitution + précisions back du docx fonctionnel
|
||||||
|
# « M6-produit-V0 » (V0, 15/06/2026, validation client en attente).
|
||||||
|
# Décisions Matthieu (24/06) :
|
||||||
|
# (1) Produit logé dans le module EXISTANT `Catalog` (pas de nouveau module) ;
|
||||||
|
# item sidebar dans la section « Administration », sous « Répertoire transporteurs ».
|
||||||
|
# (2) « Type de stockage » : référentiel minimal `StorageType` créé maintenant (provisoire,
|
||||||
|
# en attendant la liste définitive d'Aurore), seedé avec la liste Figma (node 1503-34285).
|
||||||
|
# (3) « Code produit » = « Numéro » de la liste : MÊME champ, saisi, UNIQUE global (409 doublon).
|
||||||
|
# (4) « État du produit » : Achat / Vendu / Autre, multi-select, AU MOINS 1 requis
|
||||||
|
# (corrige l'incohérence « Autre » vs « Aucun » du docx).
|
||||||
|
# (5) PÉRIMÈTRE V0 = CRUD produit classique uniquement. Les onglets « Fournisseurs » et
|
||||||
|
# « Clients » sont des PLACEHOLDERS « en cours de développement » (dépendent d'un module
|
||||||
|
# Contrat inexistant) — hors périmètre, tracés HP-M6-01.
|
||||||
|
|
||||||
|
# === LIENS ===
|
||||||
|
spec_front: ./spec-front.md
|
||||||
|
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1503-34285&p=f&m=dev"
|
||||||
|
trace_fonctionnelle: "uploads/M6-produit-V0.pdf (V0, 15/06/2026, validation client en attente)"
|
||||||
|
|
||||||
|
# === LIEN LESSTIME ===
|
||||||
|
lesstime_project_id: 6
|
||||||
|
lesstime_taskgroup_id: 36 # M6 — Catalogue produit (ERP-197 → ERP-207)
|
||||||
|
statut_global: pret_a_dev
|
||||||
|
|
||||||
|
# === DÉPENDANCES AMONT ===
|
||||||
|
depend_de:
|
||||||
|
- Catalog # Module hôte (REQUIRED). Category + CategoryType (ajout du type PRODUIT) + nouveau StorageType + Product
|
||||||
|
- Sites # Site (relation ManyToMany product↔site, RG-6.04)
|
||||||
|
- Core # User, Role, Permission, Audit, JWT
|
||||||
|
- Shared # TimestampableBlamableTrait + Subscriber (ERP-52) + CategoryInterface
|
||||||
|
---
|
||||||
|
|
||||||
|
# Spec back — Module 6 : Catalogue produit
|
||||||
|
|
||||||
|
## 1. Contexte
|
||||||
|
|
||||||
|
Cette spec **complète et précise** la [spec front V0.1](./spec-front.md) (docx `M6-produit-V0`, V0 du 15/06/2026, **validation client en attente**) 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-6.01 → RG-6.10), tests, hors-périmètre.
|
||||||
|
|
||||||
|
**Module cible** : module **EXISTANT `Catalog`** (`src/Module/Catalog/`) — DÉCISION Matthieu (24/06). Le docx parle d'un « Module 7 — Catalogue produit » rattaché à « l'Administration », mais le projet possède déjà un module `Catalog` (`ID = 'catalog'`, `REQUIRED = true`) qui porte `Category` / `CategoryType`. « Catalogue produit » y a sa place naturelle : on **n'ajoute pas de module**, on ajoute l'entité `Product` (+ le référentiel `StorageType`) au module `Catalog`. L'item de menu vit dans la section **Administration** de la sidebar, **sous « Répertoire transporteurs »** (cf. § 5.3).
|
||||||
|
|
||||||
|
> **RETEX obligatoire (M1→M5)** : ~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 M6. On réutilise le pattern Provider/Processor + normalisation serveur + Timestampable/Blamable + audit i18n + soft delete posé aux modules précédents, et la taxonomie `Category` codée (ERP-78).
|
||||||
|
|
||||||
|
**Dépendances déjà en place sur `develop`** :
|
||||||
|
- `Catalog` → `Category` (taxonomie codée, soft delete, `CategoryInterface`) + `CategoryType` (référentiel statique, types CLIENT / FOURNISSEUR / PRESTATAIRE seedés). Le type **`PRODUIT` n'est PAS encore seedé** — le M6 l'ajoute (§ 2.5).
|
||||||
|
- `Sites` → 3 sites Châtellerault (`code` 86) / Saint-Jean (17) / Pommevic (82) ; `Site.code` déjà mappé ; `SiteInterface`.
|
||||||
|
- `Shared` → `TimestampableBlamableTrait` + `Subscriber` (ERP-52).
|
||||||
|
- `Core` → User, Role, Permission, Audit, JWT.
|
||||||
|
|
||||||
|
## 1.bis Remise en question du docx (incohérences relevées + résolutions)
|
||||||
|
|
||||||
|
> Le docx V0 est volontairement léger. Voici les points ambigus ou contradictoires relevés à la relecture, et la décision retenue (validée Matthieu 24/06). **Toute la spec qui suit applique ces décisions.**
|
||||||
|
|
||||||
|
| # | Point du docx | Problème | Décision retenue |
|
||||||
|
|---|---|---|---|
|
||||||
|
| C1 | « Module 7 — Catalogue produit » / « Module Administration » | Le projet n'a pas de « Module 7 » ni de module « Administration » ; un module `Catalog` existe déjà. | **Produit logé dans `Catalog`** ; item sidebar dans la **section Administration**, sous « Répertoire transporteurs » (§ 2.1 / § 5.3). |
|
||||||
|
| C2 | Colonnes liste = `Nom`, `Numéro`, `Catégorie` ; formulaire = `Nom`, `Code produit`, `Catégorie` | « Numéro » (liste) vs « Code produit » (formulaire) : 2 noms pour quoi ? | **Même champ** : `code` (= « Numéro » = « Code produit »), **saisi**, **unique global**, 409 sur doublon (RG-6.01). La colonne liste « Numéro » affiche `code`. |
|
||||||
|
| C3 | « État du produit » : Multi-select **obligatoire**, valeurs *Achat / Vendu / Autre* | Les onglets parlent de *Achat / Vendu / **Aucun*** → « Autre » ≠ « Aucun ». Et « obligatoire » + « Aucun » se contredisent. | **Enum `PURCHASE` / `SALE` / `OTHER`**, multi-select, **≥ 1 obligatoire** (RG-6.02). « Aucun » des onglets = « ni Achat ni Vendu » (donc `OTHER` seul). |
|
||||||
|
| C4 | « Type de stockage » : « *liste fournie par Aurore* en fonction des sites » | Aucun référentiel de stockage n'existe en base ; la vraie liste est en attente. | **Référentiel minimal `StorageType` créé maintenant** (provisoire, **plat**), seedé avec la liste Figma (node 1503-34285) ; multi-select listant **tous** les types (plus de filtrage par site — décision 26/06, § 2.4). La disponibilité par site relèvera du futur module **Stockage**. À re-seeder quand Aurore livre la liste définitive (HP-M6-02). |
|
||||||
|
| C5 | « Catégorie produit » : « Liste des catégories produit » | Le type `PRODUIT` n'est pas seedé ; aucune catégorie produit n'existe. | Le M6 **seede le `CategoryType` PRODUIT** + quelques `Category` produit, et le select est **filtré `?typeCode=PRODUIT`** (RG-6.05, § 2.5). |
|
||||||
|
| C6 | « Fabriqué » / « Contient de la mélasse » : « apparaît si État = Vendu » | Comportement front only ? Que stocke-t-on si l'état n'est plus Vendu ? | Booléens **conditionnés à `SALE`** : saisis seulement si l'état contient `SALE`, sinon **forcés `false` serveur** (RG-6.03). |
|
||||||
|
| C7 | RBAC : Admin = Tout, tous les autres rôles = Non | Très restrictif (admin-only). Confirmé ? | **Confirmé** : `catalog.products.view` / `.manage` attribués **au seul rôle Admin** (§ 5.2). |
|
||||||
|
| C8 | Onglets « Fournisseurs » / « Clients » (contrats, prestation de triage, contrats TAF) | Référencent une notion de **Contrat** (client/fournisseur) **inexistante** dans le code. | **Hors périmètre V0** : onglets rendus en **placeholder « en cours de développement »** (comme les autres onglets non encore dev). Tracé HP-M6-01 (§ 9). |
|
||||||
|
|
||||||
|
## 2. Décisions d'archi
|
||||||
|
|
||||||
|
### 2.1 Entité `Product` dans le module `Catalog`
|
||||||
|
|
||||||
|
Ajout au module **`Catalog`** (pas de nouveau module — C1) :
|
||||||
|
- Entité racine **`Product`** sous `src/Module/Catalog/Domain/Entity/Product.php`.
|
||||||
|
- Référentiel **`StorageType`** sous `src/Module/Catalog/Domain/Entity/StorageType.php` (§ 2.4).
|
||||||
|
- Permissions `catalog.products.view` / `catalog.products.manage` ajoutées à `CatalogModule::permissions()` (§ 5.1).
|
||||||
|
- Pas de nouveau layer front (le module `catalog` n'a pas de layer dédié — les écrans admin du Catalog vivent dans le shell `frontend/app/` / `frontend/shared/`, comme `/admin/categories`). Route Nuxt : `/admin/products` (cf. spec-front).
|
||||||
|
|
||||||
|
**Référentiels cross-module consommés en relation ORM partagée (PAS d'import de logique)** — comme M2→M5 : `Product` référence `Site` (Sites) via une **relation ORM** (ManyToMany). Donnée de référence partagée, aucun service/repository d'un autre module appelé. `Category` et `StorageType` appartiennent au **même** module `Catalog` → relations internes classiques.
|
||||||
|
|
||||||
|
### 2.2 IDs — convention `INT` (alignée Catalog / Core)
|
||||||
|
|
||||||
|
`Product` et `StorageType` s'alignent sur la convention du module `Catalog` : **`INT GENERATED BY DEFAULT AS IDENTITY`**. Horodatages `TIMESTAMP(0) WITHOUT TIME ZONE` (le `TimestampableBlamableTrait` mappe `datetime_immutable`).
|
||||||
|
|
||||||
|
### 2.3 État du produit — multi-valeur `states` (C3 / RG-6.02)
|
||||||
|
|
||||||
|
`états` est un **multi-select** : un produit peut être à la fois `PURCHASE` et `SALE`. Modélisation : colonne **`states JSONB NOT NULL DEFAULT '[]'`** (tableau de chaînes), valeurs autorisées `PURCHASE` / `SALE` / `OTHER`, **≥ 1** (Callback + CHECK de non-vacuité).
|
||||||
|
|
||||||
|
> **Alternative écartée** : 3 colonnes booléennes (`is_purchase`/`is_sale`/`is_other`). Plus simple à requêter mais s'éloigne de la sémantique « multi-select » et multiplie les colonnes. Le JSONB est retenu pour la fidélité au champ unique du docx ; si un besoin de filtrage SQL fin apparaît (HP), on bascule sur une table de jonction `product_state`.
|
||||||
|
|
||||||
|
Pilotage des champs conditionnels (RG-6.03) : `manufactured` et `containsMolasses` ne sont **saisissables que si `states` contient `SALE`** ; sinon forcés `false` côté serveur (Processor) — pas de state machine.
|
||||||
|
|
||||||
|
### 2.4 Référentiel `StorageType` (C4 / RG-6.06) — PROVISOIRE, référentiel PLAT
|
||||||
|
|
||||||
|
> **Décision Matthieu (24/06)** : créer un **référentiel minimal** en attendant la liste/mapping définitifs d'Aurore. Seed = liste Figma (node 1503-34285).
|
||||||
|
>
|
||||||
|
> **Décision Tristan (26/06)** : `StorageType` devient un **référentiel PLAT** — plus de rattachement aux sites. La disponibilité « tel type sur tel site » relèvera de la **future entité `Stockage`** (module Stockage : un stockage = 1 site + 1 type), dérivée des stockages réels. On **retire** donc la jointure `storage_type_site` et **tout filtrage du multi-select par site** (migration `Version20260626100000` : drop de la jointure + seed idempotent). Le référentiel est aussi seedé **en migration** (prod-safe, comme `payment_type`/`bank`/`country`), la fixture ne servant qu'au re-seed dev après purge.
|
||||||
|
|
||||||
|
- Entité `StorageType` (`Catalog`) : `id`, `code` (slug MAJUSCULE stable, unique), `label` (FR affiché). **Plus de relation `sites`.**
|
||||||
|
- **Seed (10 valeurs, Figma)** : Boisseau, Boisseau dosage, Case, Cellule, Container, Cuve mélasse, Stockage big bag, Stockage palette, Tas, Zone. Seedées en migration (`ON CONFLICT (code) DO NOTHING`) **et** par `StorageTypeFixtures` (dev/test).
|
||||||
|
- Le champ produit « Type de stockage » est un **multi-select listant TOUS les types** : `GET /api/storage_types` (plus de paramètre `?siteId[]=`).
|
||||||
|
- **Provisoire** : codes et libellés sont à revalider/re-seeder à réception de la liste Aurore (HP-M6-02). Référentiel en **lecture seule** au M6 (pas de CRUD admin du StorageType — HP-M6-03).
|
||||||
|
|
||||||
|
### 2.5 Catégorie produit — type `PRODUIT` (C5 / RG-6.05)
|
||||||
|
|
||||||
|
- Le M6 **seede le `CategoryType` `PRODUIT`** (code `PRODUIT`, label « Produit ») : ajout dans **`CategoryTypeFixtures::TYPES`** ET dans une **migration de seed** (miroir dev/prod, comme CLIENT/FOURNISSEUR/PRESTATAIRE — cf. `CategoryTypeFixtures` docblock).
|
||||||
|
- Le M6 seede aussi quelques **`Category` de type PRODUIT** (ex. provisoires : « Céréales », « Oléagineux », « Aliments du bétail », « Engrais ») pour alimenter le select. Codes auto-générés par `CategoryCodeGenerator` (slug MAJUSCULE stable).
|
||||||
|
- `Product.category` = **ManyToOne `Category`** (obligatoire). Le select du formulaire est **filtré `?typeCode=PRODUIT`** (provider Category existant — filtre `typeCode` déjà supporté). Lecture du référentiel via `catalog.categories.read_ref` ou `.view` (déjà en place).
|
||||||
|
|
||||||
|
> **Garde-fou** : on **ne contraint pas** en base que `category` soit de type PRODUIT (le filtrage est applicatif via le select + une validation `#[Assert\Callback]` côté Processor qui rejette une catégorie non-PRODUIT en 422). Justification : éviter un couplage SQL fragile au référentiel type.
|
||||||
|
|
||||||
|
### 2.6 Audit & traces temporelles
|
||||||
|
|
||||||
|
Pattern Starseed standard (miroir M1→M5) :
|
||||||
|
- `#[Auditable]` sur `Product`. Pas de champ sensible (password/token) → pas d'`#[AuditIgnore]`.
|
||||||
|
- Audit des relations (`category`, `sites`, `storageTypes`) tracé automatiquement (ManyToMany inclus).
|
||||||
|
- `Product implements TimestampableInterface, BlamableInterface` + `use TimestampableBlamableTrait` (4 colonnes standard).
|
||||||
|
- **Libellé i18n** (règle ABSOLUE backend — `AuditableEntitiesHaveI18nLabelTest`) : ajouter `audit.entity.catalog_product` dans `frontend/i18n/locales/fr.json` (clé = `strtolower(module)` + `_` + `strtolower(Entity)` = `catalog_product`).
|
||||||
|
- `StorageType` = référentiel **statique** en lecture seule → **pas** de Timestampable/Blamable, **pas** `#[Auditable]` (whitelister dans `EntitiesAreTimestampableBlamableTest::EXCLUDED`, miroir `CategoryType`).
|
||||||
|
|
||||||
|
### 2.7 Soft delete préparé ; pas de Delete exposé au M6
|
||||||
|
|
||||||
|
Le docx M6 ne prévoit **ni archivage ni suppression** (actions = + Ajouter / Exporter / Filtrer). On **n'expose pas** de `Delete`. On prépare néanmoins une colonne `deleted_at` (soft delete technique) **non exposée** (cohérent avec `Category` et le pattern M5). Le provider exclut par défaut les produits soft-deleted.
|
||||||
|
|
||||||
|
## 3. Modèle de données
|
||||||
|
|
||||||
|
### 3.1 Diagramme
|
||||||
|
|
||||||
|
```
|
||||||
|
+------------------+ +------------------------+
|
||||||
|
| site (Sites) | | category_type (Catalog)| + seed type PRODUIT (§ 2.5)
|
||||||
|
+------------------+ +------------------------+
|
||||||
|
^ ^ ^
|
||||||
|
| | | (ManyToMany existant)
|
||||||
|
product_ | | storage_type_ |
|
||||||
|
site | | site +------------------+
|
||||||
|
| | | category | (type PRODUIT)
|
||||||
|
+------------------+ +------------------+ +------------------+
|
||||||
|
| product | | storage_type | ^
|
||||||
|
| id (PK) | | id (PK) | | category_id (FK, NOT NULL)
|
||||||
|
| code (UNIQUE) | | code (UNIQUE) |----------+
|
||||||
|
| name | | label | (product.category ManyToOne)
|
||||||
|
| states (JSONB) | +------------------+
|
||||||
|
| manufactured | ^
|
||||||
|
| contains_molasses| | product_storage_type (ManyToMany)
|
||||||
|
| category_id (FK) |--------+
|
||||||
|
| deleted_at |
|
||||||
|
| created_at/by … |
|
||||||
|
+------------------+
|
||||||
|
^ ^
|
||||||
|
| | product_site (ManyToMany) / product_storage_type (ManyToMany)
|
||||||
|
+---+
|
||||||
|
```
|
||||||
|
|
||||||
|
Tables de jonction : `product_site (product_id, site_id)`, `product_storage_type (product_id, storage_type_id)`. *(La jonction `storage_type_site` initialement créée par ERP-198 a été **supprimée** : `StorageType` est devenu un référentiel plat — migration `Version20260626100000`, décision 26/06, § 2.4.)*
|
||||||
|
|
||||||
|
### 3.2 Migration Doctrine — SQL Postgres (illustratif)
|
||||||
|
|
||||||
|
Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrations/VersionYYYYMMDDHHMMSS.php` (postérieur aux migrations existantes).
|
||||||
|
|
||||||
|
> **Même justification qu'aux M1→M5** : FK cross-module (`user`, `site`, `category`) → le namespace modulaire casserait l'ordre sur `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 `addStandardTimestampableBlamableComments`.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- =====================================================================
|
||||||
|
-- Référentiel des types de stockage (provisoire — § 2.4 / RG-6.06)
|
||||||
|
-- =====================================================================
|
||||||
|
CREATE TABLE storage_type (
|
||||||
|
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
code VARCHAR(40) NOT NULL,
|
||||||
|
label VARCHAR(120) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX uq_storage_type_code ON storage_type (code);
|
||||||
|
|
||||||
|
-- NB : storage_type_site (créée ici par ERP-198) est DROPPÉE par la migration
|
||||||
|
-- Version20260626100000 — StorageType est un référentiel plat (décision 26/06, § 2.4).
|
||||||
|
CREATE TABLE storage_type_site (
|
||||||
|
storage_type_id INT NOT NULL REFERENCES storage_type(id) ON DELETE CASCADE,
|
||||||
|
site_id INT NOT NULL REFERENCES site(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (storage_type_id, site_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Table principale `product`
|
||||||
|
-- =====================================================================
|
||||||
|
CREATE TABLE product (
|
||||||
|
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
code VARCHAR(50) NOT NULL, -- = « Numéro » liste, unique global (RG-6.01)
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
states JSONB NOT NULL DEFAULT '[]'::jsonb, -- PURCHASE|SALE|OTHER, >=1 (RG-6.02)
|
||||||
|
manufactured BOOLEAN NOT NULL DEFAULT FALSE, -- saisi si SALE, sinon false (RG-6.03)
|
||||||
|
contains_molasses BOOLEAN NOT NULL DEFAULT FALSE, -- saisi si SALE, sinon false (RG-6.03)
|
||||||
|
category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT, -- type PRODUIT (RG-6.05)
|
||||||
|
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE, -- soft delete, non exposé (§ 2.7)
|
||||||
|
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_product_states_not_empty CHECK (jsonb_array_length(states) >= 1)
|
||||||
|
);
|
||||||
|
-- Unicité GLOBALE du code parmi les actifs (soft delete toléré) — index partiel.
|
||||||
|
CREATE UNIQUE INDEX uq_product_code_active ON product (code) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX idx_product_category ON product (category_id);
|
||||||
|
CREATE INDEX idx_product_deleted_at ON product (deleted_at);
|
||||||
|
CREATE INDEX idx_product_created_by ON product (created_by);
|
||||||
|
CREATE INDEX idx_product_updated_by ON product (updated_by);
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Jonctions produit ↔ sites / types de stockage
|
||||||
|
-- =====================================================================
|
||||||
|
CREATE TABLE product_site (
|
||||||
|
product_id INT NOT NULL REFERENCES product(id) ON DELETE CASCADE,
|
||||||
|
site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT,
|
||||||
|
PRIMARY KEY (product_id, site_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE product_storage_type (
|
||||||
|
product_id INT NOT NULL REFERENCES product(id) ON DELETE CASCADE,
|
||||||
|
storage_type_id INT NOT NULL REFERENCES storage_type(id) ON DELETE RESTRICT,
|
||||||
|
PRIMARY KEY (product_id, storage_type_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Seed du type de catégorie PRODUIT (§ 2.5) — miroir CategoryTypeFixtures
|
||||||
|
-- =====================================================================
|
||||||
|
INSERT INTO category_type (code, label) VALUES ('PRODUIT', 'Produit')
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2.bis Commentaires SQL obligatoires (échantillon)
|
||||||
|
|
||||||
|
```php
|
||||||
|
$this->addSql("COMMENT ON TABLE product IS 'Produits du catalogue (M6 Catalog) — état Achat/Vendu/Autre, sites de disponibilité, catégorie produit, types de stockage.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN product.code IS 'Code produit (= « Numéro » de la liste), saisi, unique global parmi les actifs (RG-6.01). Index partiel uq_product_code_active.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN product.states IS 'États du produit (JSON) : sous-ensemble non vide de PURCHASE|SALE|OTHER, multi-select (RG-6.02). Pilote les champs conditionnels.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN product.manufactured IS '« Fabriqué » : saisi uniquement si states contient SALE, sinon forcé false serveur (RG-6.03).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN product.contains_molasses IS '« Contient de la mélasse » : saisi uniquement si states contient SALE, sinon forcé false serveur (RG-6.03).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN product.category_id IS 'Catégorie produit (FK category, type PRODUIT) — obligatoire, validée applicativement (RG-6.05).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN product.deleted_at IS 'Horodatage de suppression logique (soft delete) — non exposé au M6 ; la liste exclut les produits supprimés (§ 2.7).'");
|
||||||
|
$this->addSql("COMMENT ON TABLE storage_type IS 'Référentiel des types de stockage (PROVISOIRE, en attente liste Aurore) — Boisseau, Silo, Tas… (RG-6.06).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN storage_type.code IS 'Code stable MAJUSCULE du type de stockage (ex. TAS, CUVE_MELASSE). Unique (uq_storage_type_code).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN storage_type.label IS 'Libellé FR affiché du type de stockage (ex. « Cuve mélasse »).'");
|
||||||
|
// + COMMENT ON COLUMN sur les tables de jonction (product_site, product_storage_type, storage_type_site)
|
||||||
|
$this->addStandardTimestampableBlamableComments($schema, 'product');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Entité `Product` — squelette (extrait)
|
||||||
|
|
||||||
|
Pattern jumeau de `Category` (`#[Auditable]`, `TimestampableBlamableTrait`, soft delete). **Chaque propriété affichée porte un read-group** (RETEX M1).
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\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\Catalog\Infrastructure\ApiPlatform\State\Processor\ProductProcessor;
|
||||||
|
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\ProductProvider;
|
||||||
|
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository;
|
||||||
|
use App\Module\Sites\Domain\Entity\Site; // relation ORM partagée (§ 2.1)
|
||||||
|
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\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(
|
||||||
|
security: "is_granted('catalog.products.view')",
|
||||||
|
normalizationContext: ['groups' => ['product:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||||
|
provider: ProductProvider::class,
|
||||||
|
),
|
||||||
|
new Get(
|
||||||
|
security: "is_granted('catalog.products.view')",
|
||||||
|
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||||
|
provider: ProductProvider::class,
|
||||||
|
),
|
||||||
|
new Post(
|
||||||
|
security: "is_granted('catalog.products.manage')",
|
||||||
|
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||||
|
denormalizationContext: ['groups' => ['product:write']],
|
||||||
|
processor: ProductProcessor::class,
|
||||||
|
),
|
||||||
|
new Patch(
|
||||||
|
security: "is_granted('catalog.products.manage')",
|
||||||
|
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||||
|
denormalizationContext: ['groups' => ['product:write']],
|
||||||
|
provider: ProductProvider::class,
|
||||||
|
processor: ProductProcessor::class,
|
||||||
|
),
|
||||||
|
// Pas de Delete au M6 (docx) ; soft delete préparé non exposé (§ 2.7).
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ORM\Entity(repositoryClass: DoctrineProductRepository::class)]
|
||||||
|
#[ORM\Table(name: 'product')]
|
||||||
|
#[Auditable]
|
||||||
|
class Product implements TimestampableInterface, BlamableInterface
|
||||||
|
{
|
||||||
|
use TimestampableBlamableTrait;
|
||||||
|
|
||||||
|
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||||
|
#[Groups(['product:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
/** Code produit (= « Numéro » liste), unique global, saisi (RG-6.01). */
|
||||||
|
#[ORM\Column(length: 50)]
|
||||||
|
#[Assert\NotBlank(message: 'Le code produit est obligatoire.')]
|
||||||
|
#[Assert\Length(max: 50, maxMessage: 'Le code produit ne peut pas dépasser {{ limit }} caractères.')]
|
||||||
|
#[Groups(['product:read', 'product:write'])]
|
||||||
|
private ?string $code = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
#[Assert\NotBlank(message: 'Le nom du produit est obligatoire.')]
|
||||||
|
#[Assert\Length(max: 255, maxMessage: 'Le nom du produit ne peut pas dépasser {{ limit }} caractères.')]
|
||||||
|
#[Groups(['product:read', 'product:write'])]
|
||||||
|
private ?string $name = null;
|
||||||
|
|
||||||
|
/** États (multi-select) ⊆ {PURCHASE, SALE, OTHER}, ≥ 1 (RG-6.02). */
|
||||||
|
#[ORM\Column(type: 'json')]
|
||||||
|
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état (Achat, Vendu ou Autre).')]
|
||||||
|
#[Assert\All([new Assert\Choice(choices: ['PURCHASE', 'SALE', 'OTHER'], message: 'État de produit invalide.')])]
|
||||||
|
#[Groups(['product:read', 'product:write'])]
|
||||||
|
private array $states = [];
|
||||||
|
|
||||||
|
#[ORM\Column(options: ['default' => false])]
|
||||||
|
#[Groups(['product:read', 'product:write'])]
|
||||||
|
private bool $manufactured = false; // saisi si SALE, sinon false (RG-6.03)
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'contains_molasses', options: ['default' => false])]
|
||||||
|
#[Groups(['product:read', 'product:write'])]
|
||||||
|
private bool $containsMolasses = false; // saisi si SALE, sinon false (RG-6.03)
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Category::class)]
|
||||||
|
#[ORM\JoinColumn(name: 'category_id', nullable: false, onDelete: 'RESTRICT')]
|
||||||
|
#[Assert\NotNull(message: 'La catégorie produit est obligatoire.')]
|
||||||
|
#[Groups(['product:read', 'product:write'])]
|
||||||
|
private ?Category $category = null; // type PRODUIT, validé Callback (RG-6.05)
|
||||||
|
|
||||||
|
/** @var Collection<int, Site> Sites de disponibilité (≥ 1, RG-6.04). */
|
||||||
|
#[ORM\ManyToMany(targetEntity: Site::class)]
|
||||||
|
#[ORM\JoinTable(name: 'product_site')]
|
||||||
|
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un site.')]
|
||||||
|
#[Groups(['product:read', 'product:write'])]
|
||||||
|
private Collection $sites;
|
||||||
|
|
||||||
|
/** @var Collection<int, StorageType> Types de stockage (≥ 1 — RG-6.06, référentiel plat). */
|
||||||
|
#[ORM\ManyToMany(targetEntity: StorageType::class)]
|
||||||
|
#[ORM\JoinTable(name: 'product_storage_type')]
|
||||||
|
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de stockage.')]
|
||||||
|
#[Groups(['product:read', 'product:write'])]
|
||||||
|
private Collection $storageTypes;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $deletedAt = null; // soft delete, non exposé (§ 2.7)
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->sites = new ArrayCollection();
|
||||||
|
$this->storageTypes = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
// RG-6.03 (champs conditionnels SALE) + RG-6.05 (catégorie de type PRODUIT) :
|
||||||
|
// cohérence via #[Assert\Callback] (§ 7). RG-6.06 = simple Assert\Count(min:1)
|
||||||
|
// (référentiel plat, plus de contrainte de disponibilité par site).
|
||||||
|
// ... getters/setters ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠ `Site` appartient au module Sites — on consomme son read-group (`site:read`), **pas de logique inter-module** (§ 2.1). `Category` / `StorageType` sont dans le **même** module `Catalog`.
|
||||||
|
|
||||||
|
## 4. API REST (API Platform)
|
||||||
|
|
||||||
|
### 4.0 Contrat de sérialisation (RETEX M1 — section critique)
|
||||||
|
|
||||||
|
> **Leçon M1→M5** : pour **chaque champ affiché** (liste OU détail), les **3 maillons** : (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) | `product:read` + `category:read` + `site:read` + `storage_type:read` + `default:read` |
|
||||||
|
| `Get` / `Post` / `Patch` (détail) | + `product:item:read` |
|
||||||
|
|
||||||
|
**LISTE — colonne datatable → maillons** (docx p.3 : Nom, Numéro, Catégorie) :
|
||||||
|
|
||||||
|
| Colonne affichée | Propriété (a) | Dans contexte liste (b) | Imbriqué (c) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Nom | `name` ∈ `product:read` | ✅ | — |
|
||||||
|
| Numéro | `code` ∈ `product:read` | ✅ | — |
|
||||||
|
| Catégorie | `category` ∈ `product:read` (embed) | ✅ | `category:read` ✅ (affiche `category.name`) |
|
||||||
|
|
||||||
|
**DÉTAIL — maillons** : `states`, `manufactured`, `containsMolasses` ∈ `product:read` ; `sites` (embed `site:read`) + `storageTypes` (embed `storage_type:read`) ∈ `product:read` (ensembles **bornés** → embed autorisé, ne viole pas la règle n°13). Rien de spécifique en `product:item:read` au-delà des relations (tout le produit tient en liste) — `product:item:read` réservé si on ajoute des champs détail-only ultérieurement.
|
||||||
|
|
||||||
|
### 4.0.bis Réponse JSON de référence (DoD — CAPTURÉ sur l'API réelle, ERP-203)
|
||||||
|
|
||||||
|
> **Definition of Done** (miroir M2→M5) : créer un produit via `POST /api/products`, appeler `GET /api/products` (liste) ET `GET /api/products/{id}` (détail), **coller la réponse JSON réelle** ici. Toute donnée affichée par le front DOIT apparaître dans ce JSON. Pièges re-testés : `category` en **objet embarqué** (pas IRI nu) ; `sites` / `storageTypes` en **tableaux d'objets** (pas tableaux d'IRI) ; `states` en tableau de chaînes ; `manufactured` / `containsMolasses` présents (booléens). `skip_null_values` actif → ne pas présumer la présence des champs null.
|
||||||
|
>
|
||||||
|
> **Capture réelle** (ERP-203) : produit créé par un `POST` réel puis relu, via `ProductSerializationContractTest` (régénérable : `PRODUCT_DOD_DUMP=1` → `/tmp/product-dod-{list,detail}.json`). Valeurs ci-dessous reformatées avec des libellés lisibles ; **les clés sont celles de la réponse réelle**. Écarts notables vs l'esquisse initiale, à connaître côté front :
|
||||||
|
> - La **LISTE porte déjà `sites` + `storageTypes` embarqués** (la propriété `product:read` est dans le contexte liste ET détail) : pas besoin d'un appel détail pour les obtenir.
|
||||||
|
> - `category` embarque **sa collection `categoryTypes`** (utile pour vérifier le type PRODUIT côté front, RG-6.05) **plus ses métadonnées d'audit** (`createdAt`/`updatedAt`/`createdBy`/`updatedBy`).
|
||||||
|
> - `createdBy` / `updatedBy` (produit et catégorie) sortent en **IRI** (`/api/me` pour l'utilisateur courant), pas en objet User embarqué.
|
||||||
|
> - chaque `site` embarque l'**adresse complète** (`street`, `postalCode`, `city`, `color`, `fullAddress` — groupe `site:read`).
|
||||||
|
> - un `StorageType` n'expose que `id` / `code` / `label` (sa relation `sites` n'est pas sérialisée — § 2.4).
|
||||||
|
|
||||||
|
**`GET /api/products` (LISTE)** — enveloppe Hydra AP4 (`member`/`totalItems`/`view`) :
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"@context": "/api/contexts/Product",
|
||||||
|
"@id": "/api/products",
|
||||||
|
"@type": "Collection",
|
||||||
|
"totalItems": 1,
|
||||||
|
"member": [
|
||||||
|
{
|
||||||
|
"@id": "/api/products/34",
|
||||||
|
"@type": "Product",
|
||||||
|
"id": 34,
|
||||||
|
"code": "BLE-TENDRE-01",
|
||||||
|
"name": "Blé tendre",
|
||||||
|
"states": ["PURCHASE", "SALE"],
|
||||||
|
"manufactured": true,
|
||||||
|
"containsMolasses": true,
|
||||||
|
"category": {
|
||||||
|
"@id": "/api/categories/12",
|
||||||
|
"@type": "Category",
|
||||||
|
"id": 12,
|
||||||
|
"name": "Céréales",
|
||||||
|
"code": "CEREALES",
|
||||||
|
"categoryTypes": [
|
||||||
|
{ "@id": "/api/category_types/5", "@type": "CategoryType", "id": 5, "code": "PRODUIT", "label": "Produit" }
|
||||||
|
],
|
||||||
|
"createdAt": "2026-06-25T12:09:27+02:00",
|
||||||
|
"updatedAt": "2026-06-25T12:09:27+02:00",
|
||||||
|
"createdBy": "/api/me",
|
||||||
|
"updatedBy": "/api/me"
|
||||||
|
},
|
||||||
|
"sites": [
|
||||||
|
{
|
||||||
|
"@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-25T11:32:33+02:00",
|
||||||
|
"updatedAt": "2026-06-25T11:32:33+02:00",
|
||||||
|
"fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storageTypes": [
|
||||||
|
{ "@id": "/api/storage_types/9", "@type": "StorageType", "id": 9, "code": "TAS", "label": "Tas" }
|
||||||
|
],
|
||||||
|
"createdAt": "2026-06-25T12:09:28+02:00",
|
||||||
|
"updatedAt": "2026-06-25T12:09:28+02:00",
|
||||||
|
"createdBy": "/api/me",
|
||||||
|
"updatedBy": "/api/me"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"view": { "@id": "/api/products?search=BLE-TENDRE-01", "@type": "PartialCollectionView" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`GET /api/products/34` (DÉTAIL)** — **même structure** que la ligne de liste (les `sites` / `storageTypes` sont déjà embarqués en liste ; `product:item:read` est réservé à d'éventuels champs détail-only ultérieurs) :
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"@context": "/api/contexts/Product",
|
||||||
|
"@id": "/api/products/34",
|
||||||
|
"@type": "Product",
|
||||||
|
"id": 34,
|
||||||
|
"code": "BLE-TENDRE-01",
|
||||||
|
"name": "Blé tendre",
|
||||||
|
"states": ["PURCHASE", "SALE"],
|
||||||
|
"manufactured": true,
|
||||||
|
"containsMolasses": true,
|
||||||
|
"category": {
|
||||||
|
"@id": "/api/categories/12",
|
||||||
|
"@type": "Category",
|
||||||
|
"id": 12,
|
||||||
|
"name": "Céréales",
|
||||||
|
"code": "CEREALES",
|
||||||
|
"categoryTypes": [
|
||||||
|
{ "@id": "/api/category_types/5", "@type": "CategoryType", "id": 5, "code": "PRODUIT", "label": "Produit" }
|
||||||
|
],
|
||||||
|
"createdAt": "2026-06-25T12:09:27+02:00",
|
||||||
|
"updatedAt": "2026-06-25T12:09:27+02:00",
|
||||||
|
"createdBy": "/api/me",
|
||||||
|
"updatedBy": "/api/me"
|
||||||
|
},
|
||||||
|
"sites": [
|
||||||
|
{
|
||||||
|
"@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-25T11:32:33+02:00",
|
||||||
|
"updatedAt": "2026-06-25T11:32:33+02:00",
|
||||||
|
"fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storageTypes": [
|
||||||
|
{ "@id": "/api/storage_types/9", "@type": "StorageType", "id": 9, "code": "TAS", "label": "Tas" }
|
||||||
|
],
|
||||||
|
"createdAt": "2026-06-25T12:09:28+02:00",
|
||||||
|
"updatedAt": "2026-06-25T12:09:28+02:00",
|
||||||
|
"createdBy": "/api/me",
|
||||||
|
"updatedBy": "/api/me"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 Query params (LISTE)
|
||||||
|
|
||||||
|
| Param | Effet |
|
||||||
|
|---|---|
|
||||||
|
| `?page` / `?itemsPerPage` | pagination standard (10 / 25 / 50, défaut 10) |
|
||||||
|
| `?search=` | recherche sur `code` et `name` |
|
||||||
|
| `?categoryId=` ou `?categoryCode=` | filtre par catégorie (drawer « Filtrer », docx p.3) |
|
||||||
|
| `?state=` | filtre par état (PURCHASE / SALE / OTHER) — drawer « Filtrer » |
|
||||||
|
| `?siteId[]=` | filtre par site de disponibilité |
|
||||||
|
| `?order[name]=asc` | tri (défaut : `name ASC`) |
|
||||||
|
|
||||||
|
Pagination obligatoire (règle ABSOLUE n°13) — provider ORM via `ApiPlatform\Doctrine\Orm\Paginator`, jamais d'array brut.
|
||||||
|
|
||||||
|
### 4.2 Référentiel `StorageType` — `GET /api/storage_types`
|
||||||
|
|
||||||
|
- Opérations : `GetCollection` + `Get` (lecture seule au M6 — § 2.4).
|
||||||
|
- Sécurité : `is_granted('catalog.products.view')` (référentiel servant le formulaire produit). *(Si un autre rôle doit lire ce référentiel sans accès produit, ajouter une `read_ref` dédiée — non requis au M6 vu le RBAC admin-only.)*
|
||||||
|
- Référentiel **plat** : renvoie TOUS les types (plus de paramètre `?siteId[]=` — RG-6.06 revue, § 2.4).
|
||||||
|
- **`?pagination=false`** : échappatoire select (référentiel borné ≤ quelques dizaines) — règle frontend.
|
||||||
|
- `normalizationContext: ['storage_type:read']` ; tri `label ASC`.
|
||||||
|
|
||||||
|
### 4.3 `POST /api/products` (création)
|
||||||
|
|
||||||
|
- Le client envoie : `code`, `name`, `states[]`, `manufactured`, `containsMolasses`, `category` (IRI), `sites[]` (IRI), `storageTypes[]` (IRI).
|
||||||
|
- Le **Processor** (`ProductProcessor`) :
|
||||||
|
1. Normalise `code` (trim + UPPER) et `name` (trim) — RG-6.07.
|
||||||
|
2. Valide l'**unicité globale** du `code` parmi les actifs → **409** sur doublon (RG-6.01).
|
||||||
|
3. Force `manufactured` / `containsMolasses` à `false` si `states` ne contient pas `SALE` (RG-6.03).
|
||||||
|
4. Valide que `category` est de type **PRODUIT** (RG-6.05) → 422 sinon. `storageTypes` : `≥ 1` (RG-6.06, référentiel plat — plus de contrainte de disponibilité par site).
|
||||||
|
- Réponse `201` avec le produit complet.
|
||||||
|
|
||||||
|
### 4.4 `PATCH /api/products/{id}` (modification)
|
||||||
|
|
||||||
|
- Mise à jour partielle, mêmes règles. Le **mode strict PATCH** s'applique (RETEX M1) : un champ hors-permission dans le payload = 403 global (ici un seul niveau `manage`, donc surface réduite).
|
||||||
|
- Re-validation unicité `code` (en excluant le produit courant). Re-force des conditionnels (RG-6.03).
|
||||||
|
|
||||||
|
### 4.5 Export — `GET /api/products/export.xlsx`
|
||||||
|
|
||||||
|
- Exporte **toute la liste** des produits (docx : bouton « Exporter » → « Exporte toute la liste des produits »), filtres actifs appliqués.
|
||||||
|
- Colonnes : Numéro (`code`), Nom, États (Achat/Vendu/Autre joints), Catégorie, Sites, Types de stockage, Fabriqué, Contient mélasse.
|
||||||
|
- Génération via le helper XLSX standard projet (skill `xlsx`) — controller dédié (miroir `ClientExportController`) OU provider binaire ; **whitelisté pagination** (`EXCLUDED`) car export complet.
|
||||||
|
|
||||||
|
## 5. RBAC, module & sidebar
|
||||||
|
|
||||||
|
### 5.1 `CatalogModule::permissions()` — ajout
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Ajouts M6 (à insérer dans CatalogModule::permissions()) :
|
||||||
|
['code' => 'catalog.products.view', 'label' => 'Voir les produits'],
|
||||||
|
['code' => 'catalog.products.manage', 'label' => 'Gérer les produits (créer, éditer)'],
|
||||||
|
```
|
||||||
|
Synchronisation : `app:sync-permissions`.
|
||||||
|
|
||||||
|
### 5.2 Matrice rôle → permissions (docx p.3 — admin-only, C7)
|
||||||
|
|
||||||
|
| Rôle | `…products.view` | `…products.manage` |
|
||||||
|
|---|:--:|:--:|
|
||||||
|
| **Admin** | ✅ | ✅ |
|
||||||
|
| **Bureau** | ❌ | ❌ |
|
||||||
|
| **Compta** | ❌ | ❌ |
|
||||||
|
| **Commerciale** | ❌ | ❌ |
|
||||||
|
| **Usine** | ❌ | ❌ |
|
||||||
|
|
||||||
|
> Très restrictif : le Catalogue produit est **admin-only** (docx). Item sidebar masqué pour tous les autres rôles. (Si plus tard Bureau doit consulter, ajouter `catalog.products.view` à son rôle dans les 3 miroirs.)
|
||||||
|
|
||||||
|
### 5.3 Sidebar (`config/sidebar.php`)
|
||||||
|
|
||||||
|
Nouvel item dans la **section « Administration » existante**, placé **juste sous « Répertoire transporteurs »** (`/carriers`) — DÉCISION Matthieu (24/06) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.catalog.products',
|
||||||
|
'to' => '/admin/products',
|
||||||
|
'icon' => 'mdi:package-variant-closed',
|
||||||
|
'module' => 'catalog',
|
||||||
|
'permission' => 'catalog.products.view',
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Règle ABSOLUE n°8 — 3 miroirs RBAC
|
||||||
|
|
||||||
|
Toute permission `catalog.products.*` doit être posée **simultanément** dans :
|
||||||
|
1. `config/sidebar.php` (item + permission ci-dessus),
|
||||||
|
2. `frontend/tests/e2e/_fixtures/personas.ts` (le persona **Admin** gagne `catalog.products.view/manage` + `expectedAdminLinks` ; les personas métier **ne** gagnent **rien**),
|
||||||
|
3. `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` (miroir back du même persona Admin).
|
||||||
|
|
||||||
|
## 6. Normalisation serveur (RG-6.07)
|
||||||
|
|
||||||
|
`ProductFieldNormalizer` (miroir `CategoryProcessor` / `CarrierFieldNormalizer`), appelé par le Processor avant validation :
|
||||||
|
- `code` → trim + UPPER (cohérent avec la stratégie de codes stables du Catalog).
|
||||||
|
- `name` → trim (rejet 422 si vide après trim — RG-6.01/6.02 sur le name de Category, même garde-fou).
|
||||||
|
|
||||||
|
## 7. Règles de gestion (RG)
|
||||||
|
|
||||||
|
| RG | Source | Énoncé |
|
||||||
|
|---|---|---|
|
||||||
|
| **RG-6.01** | docx+back | `code` produit (= « Numéro » liste) obligatoire, **unique global** parmi les actifs, normalisé (trim/UPPER), **409** sur doublon. |
|
||||||
|
| **RG-6.02** | docx+back | `states` = multi-select ⊆ {`PURCHASE`,`SALE`,`OTHER`}, **≥ 1** obligatoire (CHECK non-vide + `Assert\Count(min:1)`). |
|
||||||
|
| **RG-6.03** | docx+back | « Fabriqué » et « Contient de la mélasse » saisissables **uniquement si `states` contient `SALE`** ; sinon forcés `false` serveur. |
|
||||||
|
| **RG-6.04** | docx | `sites` (multi-select) obligatoire, **≥ 1** site. |
|
||||||
|
| **RG-6.05** | docx+back | `category` obligatoire, limitée aux catégories de **type PRODUIT** (select filtré `?typeCode=PRODUIT` + validation Callback 422). |
|
||||||
|
| **RG-6.06** | docx+back | `storageTypes` (multi-select) obligatoire, **≥ 1**. Référentiel `StorageType` **plat** (tous les types, **plus de filtrage par site** — décision 26/06, § 2.4) et **provisoire** (en attente Aurore). |
|
||||||
|
| **RG-6.07** | back | Normalisation serveur : `code` trim+UPPER, `name` trim (§ 6). |
|
||||||
|
| **RG-6.08** | back | « Modification » = même formulaire/mêmes règles que « Ajouter » ; bouton « Valider » → « Enregistrer » (docx p.7). Code & contraintes inchangés. |
|
||||||
|
| **RG-6.09** | back | Liste : exclut par défaut les produits soft-deleted ; pas de Delete exposé (§ 2.7). |
|
||||||
|
| **RG-6.10** | back+front | Onglets « Fournisseurs » / « Clients » = **hors périmètre V0** (placeholder), dépendent du module Contrat inexistant (HP-M6-01). **Front** : (a) NON affichés à l'ajout — n'apparaissent qu'après validation du formulaire principal (écran de modification) ; (b) visibilité conditionnée par l'état (cf. C3, « Aucun » = `OTHER`) : « Fournisseurs » si `PURCHASE` ou `OTHER`, « Clients » si `SALE` ou `OTHER`. |
|
||||||
|
|
||||||
|
Cohérence inter-champs (RG-6.03 / 6.05) implémentée via `#[Assert\Callback]` portant des messages FR + CHECK Postgres (non-vacuité `states`). RG-6.06 : simple `Assert\Count(min:1)` (référentiel plat, plus de validation de disponibilité par site).
|
||||||
|
|
||||||
|
## 8. Tests (PHPUnit) — `make test`
|
||||||
|
|
||||||
|
- **`ProductSerializationContractTest`** : capture JSON liste + détail (DoD § 4.0.bis) ; `category`/`sites`/`storageTypes` embarqués (objets, pas IRI) ; `states` tableau ; booléens présents.
|
||||||
|
- **`ProductCodeUniquenessTest`** : 409 sur doublon de `code` (actifs) ; réutilisation possible d'un code soft-deleted (index partiel).
|
||||||
|
- **`ProductStatesValidationTest`** : ≥ 1 état (RG-6.02) ; valeurs hors enum rejetées.
|
||||||
|
- **`ProductConditionalFieldsTest`** : `manufactured`/`containsMolasses` forcés `false` si pas `SALE` (RG-6.03).
|
||||||
|
- **`ProductCategoryTypeTest`** : 422 si `category` n'est pas de type PRODUIT (RG-6.05).
|
||||||
|
- ~~**`ProductStorageTypeBySiteTest`**~~ : supprimé — `StorageType` est un référentiel plat (plus de disponibilité par site, RG-6.06 revue, § 2.4).
|
||||||
|
- **RBAC** : Admin OK ; Bureau/Compta/Commerciale/Usine → 403 (view + manage).
|
||||||
|
- **Architecture** (déjà en place, ne pas casser) : `ColumnsHaveSqlCommentTest`, `EntitiesAreTimestampableBlamableTest` (whitelister `StorageType`), `AuditableEntitiesHaveI18nLabelTest` (`catalog_product`), `CollectionsArePaginatedTest`, `EntityConstraintsHaveFrenchMessageTest`.
|
||||||
|
|
||||||
|
## 9. Hors périmètre (HP)
|
||||||
|
|
||||||
|
| Réf | Sujet |
|
||||||
|
|---|---|
|
||||||
|
| **HP-M6-01** | **Onglets « Fournisseurs » et « Clients »** du produit (liaison à des **contrats** client/fournisseur, « clients en prestation de triage », « contrats TAF »). Dépend d'un **module Contrat inexistant**. Rendus en **placeholder « en cours de développement »** au M6 (§ 1.bis C8, RG-6.10). À spécifier quand le module Contrat existera. |
|
||||||
|
| **HP-M6-02** | Liste **définitive des types de stockage** (fournie par Aurore). Re-seed du référentiel `StorageType` (§ 2.4). La disponibilité par site relèvera du futur module **Stockage** (un stockage = 1 site + 1 type), pas de ce référentiel. |
|
||||||
|
| **HP-M6-03** | **CRUD admin du référentiel `StorageType`** (création/édition par un admin). Au M6 : lecture seule + seed. |
|
||||||
|
| **HP-M6-04** | Archivage / suppression d'un produit (non prévu au docx — soft delete préparé mais non exposé, § 2.7). |
|
||||||
|
| **HP-M6-05** | Contrainte SQL « `category` de type PRODUIT » au niveau base (au M6 : validation applicative seulement, § 2.5). |
|
||||||
|
|
||||||
|
## 10. Tickets Lesstime (à découper — back en tête)
|
||||||
|
|
||||||
|
| Ordre | Sujet | Tag |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | Permissions `catalog.products.view/manage` + sidebar (item sous Transporteurs) + 3 miroirs RBAC | Backend |
|
||||||
|
| 1 | Migration : `storage_type` (+ jonction site) + `product` (+ jonctions) + seed type PRODUIT + COMMENT | Backend |
|
||||||
|
| 2 | Entités `Product` + `StorageType` + Repositories + contrat sérialisation | Backend |
|
||||||
|
| 3 | `ProductProvider` + `ProductProcessor` (unicité code, RG-6.03/6.05/6.06, normalisation) | Backend |
|
||||||
|
| 4 | Référentiel `StorageType` exposé (`GetCollection` + filtre `?siteId[]`) + seed Figma + catégories PRODUIT | Backend |
|
||||||
|
| 5 | Export XLSX | Backend |
|
||||||
|
| 6 | Tests PHPUnit RG-6.01→6.10 + capture contrat JSON | Backend |
|
||||||
|
| 7 | Page liste `/admin/products` (usePaginatedList) + drawer filtre + export | Frontend |
|
||||||
|
| 8 | Écran Ajouter (champs conditionnels SALE, selects filtrés catégorie/stockage) | Frontend |
|
||||||
|
| 8.bis | Écran **Consultation** (lecture seule) `/admin/products/{id}` : clic sur une ligne → consultation (pas l'édition directe), bouton « Modifier » → édition. **Règle ERP-193 (calque client/fournisseur)** : champs vides + checkbox non cochées masqués, et **onglets vides masqués** → les coquilles Fournisseurs/Clients (placeholder, module Contrat inexistant) ne sont **pas affichées en consultation** (elles restent visibles à l'édition). | Frontend |
|
||||||
|
| 9 | Écran Modification (« Enregistrer ») + onglets **placeholder « en cours de dev »** (Fournisseurs / Clients) | Frontend |
|
||||||
|
| 10 | i18n + libellé audit (`catalog_product`) | Frontend |
|
||||||
|
|
||||||
|
## 📦 Tickets Lesstime générés
|
||||||
|
|
||||||
|
**TaskGroup Lesstime** : **#36 — M6 — Catalogue produit** (projet `ERP / Starseed`, projectId=6) — créé le 24/06/2026, 11 tickets au statut « Prêt à dev ». Back = **Matthieu**, Front = **Tristan**. Chaque ticket porte son prompt d'implémentation `.md` en pièce jointe (dossier `prompts/`).
|
||||||
|
|
||||||
|
| # | ERP | Ticket | Effort | Tag | Assigné |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1.1 | ERP-197 | Permissions catalog.products.* + sidebar + 3 miroirs RBAC | S | Backend | Matthieu |
|
||||||
|
| 1.2 | ERP-198 | Migrer le schéma M6 (storage_type, product, jonctions, type PRODUIT) | M | Backend | Matthieu |
|
||||||
|
| 1.3 | ERP-199 | Entités Product + StorageType + repositories + contrat sérialisation | M | Backend | Matthieu |
|
||||||
|
| 1.4 | ERP-200 | ProductProvider + ProductProcessor (unicité code, RG-6.03/05/06) | L | Backend | Matthieu |
|
||||||
|
| 1.5 | ERP-201 | Exposer le référentiel StorageType + seed Figma + catégories PRODUIT | M | Backend | Matthieu |
|
||||||
|
| 1.6 | ERP-202 | Export XLSX des produits | S | Backend | Matthieu |
|
||||||
|
| 1.7 | ERP-203 | Tests PHPUnit RG-6.01→6.10 + capture du contrat JSON | M | Backend | Matthieu |
|
||||||
|
| 1.8 | ERP-204 | Page liste /admin/products (datatable, filtre, export) | M | Frontend | Tristan |
|
||||||
|
| 1.9 | ERP-205 | Écran Ajouter un produit (champs conditionnels, selects filtrés) | L | Frontend | Tristan |
|
||||||
|
| 1.10 | ERP-206 | Écran Modification + onglets placeholder (Fournisseurs/Clients) | M | Frontend | Tristan |
|
||||||
|
| 1.11 | ERP-207 | i18n + libellé audit catalog_product | S | Frontend | Tristan |
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
# ERP-208 — Fix ticket de pesée — Plan d'implémentation
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development ou superpowers:executing-plans. Étapes en cases à cocher (`- [ ]`).
|
||||||
|
|
||||||
|
**Goal:** Ajouter le nom du tiers dans un cartouche bordé en haut à droite du bon de pesée PDF, et filtrer les listes Client/Fournisseur du formulaire de ticket sur le site courant (avec recharge au changement de site).
|
||||||
|
|
||||||
|
**Architecture:** Le filtre back `?siteId[]=` existe déjà sur `/clients` et `/suppliers` (joint adresses→sites) → point 2 = front uniquement. Point 1 = une méthode entité `getCounterpartyName()` + refonte du header du template Twig en table 2 colonnes (Dompdf = CSS 2.1).
|
||||||
|
|
||||||
|
**Tech Stack:** PHP 8.4 / Symfony / API Platform / Doctrine / Twig + Dompdf ; Nuxt 4 / Vue 3 / Vitest.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- `declare(strict_types=1);` en tête de tout fichier PHP.
|
||||||
|
- Commentaires en **français**, code (noms) en anglais.
|
||||||
|
- Front : `useApi()` uniquement, composants `Malio*`, 4 espaces, TS strict.
|
||||||
|
- Dompdf : **CSS 2.1 uniquement** (pas de flex/grid) → mise en page par tableaux.
|
||||||
|
- **Aucun commit sans demande explicite de Tristan** (les étapes « commit » sont différées en fin de chantier, sur demande).
|
||||||
|
- Vérif finale : `make test` + `make nuxt-test` + `make php-cs-fixer-allow-risky`. Pas d'E2E.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1 : `WeighingTicket::getCounterpartyName()` (back)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Module/Logistique/Domain/Entity/WeighingTicket.php` (ajout méthode près de `getOtherLabel`, ~ligne 449)
|
||||||
|
- Test: `tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php` (create)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `WeighingTicket::getCounterpartyName(): ?string` — companyName du client/fournisseur ou otherLabel selon `counterpartyType`, null sinon. Consommé par le template Twig (Task 2).
|
||||||
|
|
||||||
|
- [ ] **Step 1 : test qui échoue**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Logistique\Domain;
|
||||||
|
|
||||||
|
use App\Module\Commercial\Domain\Entity\Client;
|
||||||
|
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||||
|
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class WeighingTicketCounterpartyNameTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testReturnsClientCompanyNameForClientCounterparty(): void
|
||||||
|
{
|
||||||
|
$client = (new Client())->setCompanyName('Ferme du Pré');
|
||||||
|
$ticket = (new WeighingTicket())->setCounterpartyType('CLIENT')->setClient($client);
|
||||||
|
|
||||||
|
self::assertSame('Ferme du Pré', $ticket->getCounterpartyName());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsSupplierCompanyNameForSupplierCounterparty(): void
|
||||||
|
{
|
||||||
|
$supplier = (new Supplier())->setCompanyName('Coop Sud');
|
||||||
|
$ticket = (new WeighingTicket())->setCounterpartyType('FOURNISSEUR')->setSupplier($supplier);
|
||||||
|
|
||||||
|
self::assertSame('Coop Sud', $ticket->getCounterpartyName());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsOtherLabelForOtherCounterparty(): void
|
||||||
|
{
|
||||||
|
$ticket = (new WeighingTicket())->setCounterpartyType('AUTRE')->setOtherLabel('Particulier');
|
||||||
|
|
||||||
|
self::assertSame('Particulier', $ticket->getCounterpartyName());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsNullWhenNoCounterparty(): void
|
||||||
|
{
|
||||||
|
self::assertNull((new WeighingTicket())->getCounterpartyName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : lancer le test → échec**
|
||||||
|
|
||||||
|
`make test` filtré : `docker exec php-starseed-fpm php bin/phpunit tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php`
|
||||||
|
Attendu : FAIL (`getCounterpartyName` n'existe pas). Vérifier au passage que `Client`/`Supplier` ont bien un constructeur sans argument et `setCompanyName` (sinon adapter l'instanciation du test au pattern existant des entités).
|
||||||
|
|
||||||
|
- [ ] **Step 3 : implémentation minimale**
|
||||||
|
|
||||||
|
Dans `WeighingTicket.php`, après `getOtherLabel()`/`setOtherLabel()` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* Nom du tiers à afficher (bon de pesée PDF, ERP-208) : raison sociale du
|
||||||
|
* client/fournisseur ou libellé libre selon le type de contrepartie (RG-5.03).
|
||||||
|
* Null si aucune contrepartie cohérente (brouillon).
|
||||||
|
*/
|
||||||
|
public function getCounterpartyName(): ?string
|
||||||
|
{
|
||||||
|
return match ($this->counterpartyType) {
|
||||||
|
'CLIENT' => $this->client?->getCompanyName(),
|
||||||
|
'FOURNISSEUR' => $this->supplier?->getCompanyName(),
|
||||||
|
'AUTRE' => $this->otherLabel,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : lancer le test → succès**
|
||||||
|
|
||||||
|
`docker exec php-starseed-fpm php bin/phpunit tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php` → PASS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2 : Cartouche tiers dans le template PDF
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `templates/logistique/weighing_ticket_print.html.twig`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `ticket.counterpartyName` (Task 1).
|
||||||
|
|
||||||
|
- [ ] **Step 1 : ajouter le style du cartouche + header 2 colonnes**
|
||||||
|
|
||||||
|
Dans le `<style>`, ajouter :
|
||||||
|
|
||||||
|
```css
|
||||||
|
.header { width: 100%; border-collapse: collapse; }
|
||||||
|
.header td { vertical-align: top; }
|
||||||
|
.header .h-right { text-align: right; }
|
||||||
|
.party-box { display: inline-block; border: 1px solid #000; padding: 8px 12px; min-width: 160px; text-align: center; font-weight: bold; font-size: 12px; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : remplacer le bloc logo + identité par une table 2 colonnes**
|
||||||
|
|
||||||
|
Remplacer (logo + 3 lignes company) par :
|
||||||
|
|
||||||
|
```twig
|
||||||
|
<table class="header">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if logoSrc %}
|
||||||
|
<div class="logo"><img src="{{ logoSrc }}" alt="LPC LIOT"></div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="company-name">SA LIOT Châtellerault</div>
|
||||||
|
<div class="company-line">Email : lpc.contacts@lpc-liot.fr</div>
|
||||||
|
<div class="company-line">RCS Châtellerault B 339 505 612</div>
|
||||||
|
</td>
|
||||||
|
<td class="h-right">
|
||||||
|
{% if ticket.counterpartyName %}
|
||||||
|
<div class="party-box">{{ ticket.counterpartyName }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
```
|
||||||
|
|
||||||
|
(Le `.title` « Ticket de pesée » et la suite restent inchangés, sous la table.)
|
||||||
|
|
||||||
|
- [ ] **Step 3 : vérifier le rendu PDF**
|
||||||
|
|
||||||
|
Le test existant `WeighingTicketPrintApiTest` doit rester vert :
|
||||||
|
`docker exec php-starseed-fpm php bin/phpunit tests/Module/Logistique/Api/WeighingTicketPrintApiTest.php` → PASS (`%PDF`, content-type, disposition inchangés).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3 : `useWeighingTicketReferentials.load(siteId?)` (front)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/modules/logistique/composables/useWeighingTicketReferentials.ts`
|
||||||
|
- Test: `frontend/modules/logistique/composables/__tests__/useWeighingTicketReferentials.spec.ts` (create)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `load(siteId?: number | null): Promise<void>` — passe `siteId[]=<siteId>` aux fetch `/clients` et `/suppliers` quand `siteId` est fourni ; sinon comportement actuel (liste complète).
|
||||||
|
|
||||||
|
- [ ] **Step 1 : test qui échoue**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
const getMock = vi.fn()
|
||||||
|
vi.stubGlobal('useApi', () => ({ get: getMock }))
|
||||||
|
|
||||||
|
import { useWeighingTicketReferentials } from '~/modules/logistique/composables/useWeighingTicketReferentials'
|
||||||
|
|
||||||
|
describe('useWeighingTicketReferentials', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
getMock.mockReset()
|
||||||
|
getMock.mockResolvedValue({ member: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passe siteId[] aux deux endpoints quand un site est fourni', async () => {
|
||||||
|
const { load } = useWeighingTicketReferentials()
|
||||||
|
await load(7)
|
||||||
|
|
||||||
|
const clientsCall = getMock.mock.calls.find(c => c[0] === '/clients')
|
||||||
|
const suppliersCall = getMock.mock.calls.find(c => c[0] === '/suppliers')
|
||||||
|
expect(clientsCall?.[1]).toMatchObject({ pagination: 'false', 'siteId[]': [7] })
|
||||||
|
expect(suppliersCall?.[1]).toMatchObject({ pagination: 'false', 'siteId[]': [7] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne passe pas siteId[] quand aucun site (liste complète)', async () => {
|
||||||
|
const { load } = useWeighingTicketReferentials()
|
||||||
|
await load(null)
|
||||||
|
|
||||||
|
const clientsCall = getMock.mock.calls.find(c => c[0] === '/clients')
|
||||||
|
expect(clientsCall?.[1]).not.toHaveProperty('siteId[]')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : lancer → échec**
|
||||||
|
|
||||||
|
`make nuxt-test` (ou ciblé) → FAIL (`load` n'accepte pas d'argument / `siteId[]` absent).
|
||||||
|
|
||||||
|
- [ ] **Step 3 : implémentation**
|
||||||
|
|
||||||
|
Modifier `fetchAll` et `load` :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Récupère une collection complète (pagination désactivée) en Hydra, filtrée site si fourni. */
|
||||||
|
async function fetchAll(url: string, siteId?: number | null): Promise<PartyMember[]> {
|
||||||
|
const query: Record<string, unknown> = { pagination: 'false' }
|
||||||
|
// Filtre par site courant (ERP-208) : un tiers est rattaché à un site via
|
||||||
|
// les sites de ses adresses. Param `siteId[]` déjà géré par les providers M1/M2.
|
||||||
|
if (siteId !== null && siteId !== undefined) {
|
||||||
|
query['siteId[]'] = [siteId]
|
||||||
|
}
|
||||||
|
const res = await api.get<{ member?: PartyMember[] }>(
|
||||||
|
url,
|
||||||
|
query,
|
||||||
|
{ headers: LD_JSON_HEADERS, toast: false },
|
||||||
|
)
|
||||||
|
return res.member ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(siteId?: number | null): Promise<void> {
|
||||||
|
await Promise.allSettled([
|
||||||
|
fetchAll('/clients', siteId).then((list) => {
|
||||||
|
clients.value = list.map(c => ({ value: c['@id'], label: c.companyName }))
|
||||||
|
}),
|
||||||
|
fetchAll('/suppliers', siteId).then((list) => {
|
||||||
|
suppliers.value = list.map(s => ({ value: s['@id'], label: s.companyName }))
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : lancer → succès**
|
||||||
|
|
||||||
|
`make nuxt-test` ciblé sur le spec → PASS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4 : Brancher site courant + recharge dans new.vue et edit.vue (front)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/modules/logistique/pages/weighing-tickets/new.vue`
|
||||||
|
- Modify: `frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue`
|
||||||
|
- Test: `frontend/modules/logistique/pages/__tests__/weighingTicketNew.spec.ts` (étendre)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `useCurrentSite().currentSite` (ref `Site | null`), `useWeighingTicketReferentials().load(siteId?)`, `form.clientIri` / `form.supplierIri` / `referentials.clients` / `referentials.suppliers`.
|
||||||
|
|
||||||
|
- [ ] **Step 1 : helper de reset partagé**
|
||||||
|
|
||||||
|
Logique commune aux deux pages : après recharge, vider le tiers sélectionné s'il n'est plus dans les options. Implémenté inline dans chaque page (2 lignes) — pas de nouveau composable pour si peu.
|
||||||
|
|
||||||
|
- [ ] **Step 2 : new.vue — brancher currentSite + watch**
|
||||||
|
|
||||||
|
Remplacer le bloc `onMounted` final :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { currentSite } = useCurrentSite()
|
||||||
|
|
||||||
|
/** Recharge les référentiels pour le site donné puis purge le tiers devenu hors-site (ERP-208). */
|
||||||
|
async function reloadReferentials(siteId: number | null): Promise<void> {
|
||||||
|
await referentials.load(siteId)
|
||||||
|
if (form.clientIri.value && !referentials.clients.value.some(o => o.value === form.clientIri.value)) {
|
||||||
|
form.clientIri.value = null
|
||||||
|
}
|
||||||
|
if (form.supplierIri.value && !referentials.suppliers.value.some(o => o.value === form.supplierIri.value)) {
|
||||||
|
form.supplierIri.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
reloadReferentials(currentSite.value?.id ?? null).catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Changement de site pendant la saisie → recharge les listes du nouveau site (ERP-208).
|
||||||
|
watch(() => currentSite.value?.id, (siteId) => {
|
||||||
|
reloadReferentials(siteId ?? null).catch(() => {})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Ajouter `watch` à l'import `vue` et `useCurrentSite` (auto-importé Nuxt — sinon import explicite `~/modules/sites/composables/useCurrentSite`).
|
||||||
|
|
||||||
|
- [ ] **Step 3 : edit.vue — même branchement**
|
||||||
|
|
||||||
|
Adapter le `onMounted` async existant (qui fait aussi `fetchTicket`/`hydrate`) :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { currentSite } = useCurrentSite()
|
||||||
|
|
||||||
|
async function reloadReferentials(siteId: number | null): Promise<void> {
|
||||||
|
await referentials.load(siteId)
|
||||||
|
if (form.clientIri.value && !referentials.clients.value.some(o => o.value === form.clientIri.value)) {
|
||||||
|
form.clientIri.value = null
|
||||||
|
}
|
||||||
|
if (form.supplierIri.value && !referentials.suppliers.value.some(o => o.value === form.supplierIri.value)) {
|
||||||
|
form.supplierIri.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
reloadReferentials(currentSite.value?.id ?? null).catch(() => {})
|
||||||
|
try {
|
||||||
|
const detail = await fetchTicket(ticketId)
|
||||||
|
ticketNumber.value = detail.number ?? ''
|
||||||
|
form.hydrate(detail)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
error.value = true
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => currentSite.value?.id, (siteId) => {
|
||||||
|
reloadReferentials(siteId ?? null).catch(() => {})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : étendre le spec front**
|
||||||
|
|
||||||
|
Dans `weighingTicketNew.spec.ts`, ajouter un cas vérifiant que `load` est appelé avec l'id du site courant au montage (mock `useCurrentSite` retournant un `currentSite` avec `id`). Adapter au style de mock déjà en place dans le fichier.
|
||||||
|
|
||||||
|
- [ ] **Step 5 : lancer les tests front**
|
||||||
|
|
||||||
|
`make nuxt-test` → PASS (specs new/edit + referentials).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vérification finale
|
||||||
|
|
||||||
|
- [ ] `make test` (back) — vert.
|
||||||
|
- [ ] `make nuxt-test` (front) — vert.
|
||||||
|
- [ ] `make php-cs-fixer-allow-risky` — pas de diff non voulu.
|
||||||
|
- [ ] **STOP** : remettre la main à Tristan pour les tests manuels (impression PDF + switch de site). Commits différés jusqu'à sa demande.
|
||||||
|
|
||||||
|
## Self-review (couverture spec)
|
||||||
|
|
||||||
|
- Point 1 (cartouche PDF nom seul) → Task 1 + Task 2. ✓
|
||||||
|
- Point 2 (filtre site + recharge au switch + reset-si-absent) → Task 3 + Task 4. ✓
|
||||||
|
- Définition « lié au site » via adresses → param `siteId[]` (back déjà OK). ✓
|
||||||
|
- Portée ticket-seulement (pas de modif répertoires) → on n'édite que le composable du ticket + ses pages. ✓
|
||||||
|
- Pas de migration / RBAC / E2E. ✓
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# ERP-208 — Fix ticket de pesée
|
||||||
|
|
||||||
|
> Module : **Logistique (M5)** — écrans « Ajouter / Modifier un ticket de pesée » + bon de pesée imprimé (PDF).
|
||||||
|
> Branche : `fix/erp-208-ticket-pesee`.
|
||||||
|
> Date : 2026-06-25.
|
||||||
|
|
||||||
|
## 1. Contexte
|
||||||
|
|
||||||
|
Le ticket de pesée (M5) est implémenté (ERP-181 → ERP-193). Deux retours client sont
|
||||||
|
regroupés dans ce fix :
|
||||||
|
|
||||||
|
1. **Bon de pesée PDF** : il manque un **cartouche bordé en haut à droite** de la
|
||||||
|
page contenant le **nom du tiers** (client / fournisseur / champ « autre »). Le
|
||||||
|
PDF actuel n'affiche que l'identité société (SA LIOT) en haut à gauche.
|
||||||
|
2. **Écran de saisie** : quand l'utilisateur a **plusieurs sites autorisés**, les
|
||||||
|
listes déroulantes Client / Fournisseur doivent être **filtrées sur le site
|
||||||
|
courant** (le tiers est rattaché à un site via les sites de ses adresses), et
|
||||||
|
**rechargées si l'utilisateur change de site** en restant sur la page.
|
||||||
|
|
||||||
|
## 2. État du code existant (constats de cadrage)
|
||||||
|
|
||||||
|
- **Le tiers n'a pas de site en propre.** Client (M1) et Supplier (M2) sont
|
||||||
|
rattachés à un site **via les sites de leurs adresses** (`getSites()` agrège ;
|
||||||
|
RG-2.06). « Lié au site » = a au moins une adresse rattachée à ce site.
|
||||||
|
- **Le filtre back existe déjà.** `ClientProvider` / `SupplierProvider` lisent un
|
||||||
|
filtre répétable `?siteId[]=<id>` (drawers des répertoires M1/M2) et le délèguent
|
||||||
|
à `createListQueryBuilder(..., array $siteIds, ...)` → `applySiteIds()` qui joint
|
||||||
|
`addresses → sites` (`site3.id IN (:siteIds)` / `site4.id IN (:siteIds)`).
|
||||||
|
**Aucun travail back n'est nécessaire pour le filtre.**
|
||||||
|
- **La donnée du PDF est déjà chargée.** `DoctrineWeighingTicketRepository::findById()`
|
||||||
|
fetch-joine `client` et `supplier` ; le `WeighingTicketPrintProvider` charge le
|
||||||
|
ticket par cette méthode. Le template a donc accès au nom du tiers.
|
||||||
|
- **Le changement de site est global** (`SiteSelector` header → `useCurrentSite.switchSite`
|
||||||
|
→ `PATCH /me/current-site` + `loadSidebar()` + `refreshNuxtData()`). `currentSite`
|
||||||
|
est un ref singleton de module. Les référentiels du ticket sont chargés en
|
||||||
|
`onMounted` **uniquement** (pas via `useAsyncData`) → ils ne se rechargent pas au
|
||||||
|
switch : **c'est le bug du point 2.**
|
||||||
|
- Le template PDF (`templates/logistique/weighing_ticket_print.html.twig`) est rendu
|
||||||
|
par Dompdf → **CSS 2.1 uniquement (pas de flexbox/grid)**, mise en page par tableaux.
|
||||||
|
|
||||||
|
## 3. Décisions (validées avec Tristan)
|
||||||
|
|
||||||
|
| Sujet | Décision |
|
||||||
|
|---|---|
|
||||||
|
| Définition « lié au site » | Tiers ayant ≥ 1 adresse rattachée au site sélectionné (via les adresses). |
|
||||||
|
| Portée du filtre | **Ticket de pesée seulement.** On ne modifie PAS le comportement des répertoires M1/M2 (déjà validés). On réutilise le param `?siteId[]=` existant côté front. |
|
||||||
|
| Switch de site avec tiers sélectionné | **Reset si absent** : après rechargement, si le tiers sélectionné n'est plus dans la liste du nouveau site, on vide sa valeur (le type de contrepartie reste). S'il y est encore, on le garde. |
|
||||||
|
| Contenu du cartouche PDF | **Nom seul** (pas de libellé « Client » / « Fournisseur » au-dessus). |
|
||||||
|
|
||||||
|
## 4. Conception
|
||||||
|
|
||||||
|
### 4.1 Point 1 — Cartouche tiers sur le bon de pesée (back + template)
|
||||||
|
|
||||||
|
**a. Résolution du nom — `WeighingTicket::getCounterpartyName(): ?string`**
|
||||||
|
|
||||||
|
Nouvelle méthode sur l'entité qui retourne, selon `counterpartyType` :
|
||||||
|
- `CLIENT` → `client?->getCompanyName()`
|
||||||
|
- `FOURNISSEUR` → `supplier?->getCompanyName()`
|
||||||
|
- `AUTRE` → `otherLabel`
|
||||||
|
- défaut → `null`
|
||||||
|
|
||||||
|
Rationale : garde le Twig « bête » (un seul `{{ ticket.counterpartyName }}`) et rend
|
||||||
|
la logique testable unitairement, sans toucher le provider ni le renderer.
|
||||||
|
|
||||||
|
**b. Template `weighing_ticket_print.html.twig`**
|
||||||
|
|
||||||
|
Passer le bloc d'en-tête en **table 2 colonnes** (contrainte Dompdf CSS 2.1) :
|
||||||
|
- colonne gauche (`width:auto`, `vertical-align:top`) : logo + identité société
|
||||||
|
(contenu **inchangé**) ;
|
||||||
|
- colonne droite (`text-align:right`, `vertical-align:top`) : un cartouche
|
||||||
|
`border:1px solid #000; padding:8px;` (largeur fixe, ~200px) contenant
|
||||||
|
`{{ ticket.counterpartyName }}` (nom seul, en gras).
|
||||||
|
|
||||||
|
Le reste du template (titre, table des pesées, poids net) est inchangé.
|
||||||
|
|
||||||
|
Cas `counterpartyName` null : en pratique l'impression a lieu après validation, où la
|
||||||
|
contrepartie est requise (groupe `finalize`). Par robustesse, si null → ne pas rendre
|
||||||
|
le cartouche (pas de cadre vide).
|
||||||
|
|
||||||
|
**c. Provider / renderer** : aucun changement (relations déjà fetch-jointes).
|
||||||
|
|
||||||
|
### 4.2 Point 2 — Listes filtrées par site + recharge au switch (front uniquement)
|
||||||
|
|
||||||
|
**a. `useWeighingTicketReferentials.ts`**
|
||||||
|
|
||||||
|
`load()` accepte un identifiant de site optionnel et l'injecte comme `siteId[]` dans
|
||||||
|
les requêtes `/clients` et `/suppliers` (en plus de `pagination=false`) :
|
||||||
|
- site fourni → `{ pagination: 'false', 'siteId[]': [siteId] }` ;
|
||||||
|
- site absent (`null`) → comportement actuel (liste complète, dégradé gracieux).
|
||||||
|
|
||||||
|
**b. Pages `weighing-tickets/new.vue` et `weighing-tickets/[id]/edit.vue`**
|
||||||
|
|
||||||
|
- récupèrent `currentSite` via `useCurrentSite()` ;
|
||||||
|
- `onMounted` → `referentials.load(currentSite.value?.id ?? null)` ;
|
||||||
|
- `watch(currentSite)` (sur l'id) → `referentials.load(newId)` puis **reset-si-absent** :
|
||||||
|
- si `form.clientIri` est défini et absent de `referentials.clients` → `form.clientIri = null` ;
|
||||||
|
- si `form.supplierIri` est défini et absent de `referentials.suppliers` → `form.supplierIri = null` ;
|
||||||
|
- `counterpartyType` et `otherLabel` ne sont pas touchés.
|
||||||
|
|
||||||
|
Note : le reset s'appuie sur les options (IRI `@id`) renvoyées par le référentiel ;
|
||||||
|
la comparaison se fait sur `value` (l'IRI Hydra).
|
||||||
|
|
||||||
|
**c. Cohérence avec la liste des tickets** : la liste `/weighing_tickets` est déjà
|
||||||
|
cloisonnée par site (provider M5). Filtrer les selects sur le site courant aligne la
|
||||||
|
saisie sur la liste.
|
||||||
|
|
||||||
|
## 5. Tests & vérification
|
||||||
|
|
||||||
|
| Niveau | Test | Contenu |
|
||||||
|
|---|---|---|
|
||||||
|
| Back (PHPUnit) | unitaire `WeighingTicket::getCounterpartyName()` | 3 cas : CLIENT → companyName, FOURNISSEUR → companyName, AUTRE → otherLabel ; + null si type absent. |
|
||||||
|
| Back | `WeighingTicketPrintApiTest` (existant) | reste vert (`%PDF`, content-type, disposition). |
|
||||||
|
| Front (Vitest) | `weighingTicketNew.spec.ts` / `weighingTicketEdit.spec.ts` | `load` passe `siteId[]` quand un site courant existe ; au changement de `currentSite` → rechargement + reset-si-absent du tiers sélectionné. |
|
||||||
|
|
||||||
|
Commandes : `make test` + `make nuxt-test` + `make php-cs-fixer-allow-risky`.
|
||||||
|
Pas de test E2E (règle d'or : Vitest privilégié).
|
||||||
|
|
||||||
|
## 6. Hors périmètre / non-objectifs
|
||||||
|
|
||||||
|
- Pas de modification du comportement des répertoires Clients / Fournisseurs (M1/M2).
|
||||||
|
- Pas de nouvelle permission RBAC, pas de migration, pas de changement de schéma.
|
||||||
|
- Pas de cloisonnement par site « global » sur `/clients` et `/suppliers` (rejeté :
|
||||||
|
on garde le filtre opt-in via `?siteId[]`).
|
||||||
|
- L'identité société du PDF reste fixe (décision ERP-192, ne change pas selon le site).
|
||||||
@@ -52,7 +52,8 @@
|
|||||||
"admin": "Sites"
|
"admin": "Sites"
|
||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"categories": "Gestion des catégories"
|
"categories": "Gestion des catégories",
|
||||||
|
"products": "Produits"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
@@ -183,6 +184,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",
|
||||||
@@ -190,6 +192,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",
|
||||||
@@ -350,6 +353,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",
|
||||||
@@ -441,6 +445,7 @@
|
|||||||
"categoryRequired": "Sélectionnez au moins une catégorie."
|
"categoryRequired": "Sélectionnez au moins une catégorie."
|
||||||
},
|
},
|
||||||
"contact": {
|
"contact": {
|
||||||
|
"title": "Contact {n}",
|
||||||
"lastName": "Nom",
|
"lastName": "Nom",
|
||||||
"firstName": "Prénom",
|
"firstName": "Prénom",
|
||||||
"jobTitle": "Fonction",
|
"jobTitle": "Fonction",
|
||||||
@@ -452,6 +457,7 @@
|
|||||||
"add": "Nouveau contact"
|
"add": "Nouveau contact"
|
||||||
},
|
},
|
||||||
"address": {
|
"address": {
|
||||||
|
"title": "Adresse {n}",
|
||||||
"sites": "Sites",
|
"sites": "Sites",
|
||||||
"contacts": "Contact(s) rattaché(s)",
|
"contacts": "Contact(s) rattaché(s)",
|
||||||
"country": "Pays",
|
"country": "Pays",
|
||||||
@@ -465,6 +471,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",
|
||||||
@@ -472,6 +479,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",
|
||||||
@@ -628,6 +636,7 @@
|
|||||||
"uploadFailed": "Le téléversement de la décharge a échoué."
|
"uploadFailed": "Le téléversement de la décharge a échoué."
|
||||||
},
|
},
|
||||||
"address": {
|
"address": {
|
||||||
|
"title": "Adresse",
|
||||||
"country": "Pays",
|
"country": "Pays",
|
||||||
"postalCode": "Code postal",
|
"postalCode": "Code postal",
|
||||||
"city": "Ville",
|
"city": "Ville",
|
||||||
@@ -637,6 +646,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."
|
||||||
},
|
},
|
||||||
"contact": {
|
"contact": {
|
||||||
|
"title": "Contact {n}",
|
||||||
"lastName": "Nom",
|
"lastName": "Nom",
|
||||||
"firstName": "Prénom",
|
"firstName": "Prénom",
|
||||||
"jobTitle": "Fonction",
|
"jobTitle": "Fonction",
|
||||||
@@ -654,6 +664,7 @@
|
|||||||
"confirm": "Supprimer"
|
"confirm": "Supprimer"
|
||||||
},
|
},
|
||||||
"price": {
|
"price": {
|
||||||
|
"title": "Prix {n}",
|
||||||
"direction": "Sens",
|
"direction": "Sens",
|
||||||
"directionClient": "Client",
|
"directionClient": "Client",
|
||||||
"directionSupplier": "Fournisseur",
|
"directionSupplier": "Fournisseur",
|
||||||
@@ -691,6 +702,74 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Connexion",
|
"login": "Connexion",
|
||||||
"logout": "Deconnexion",
|
"logout": "Deconnexion",
|
||||||
@@ -738,6 +817,7 @@
|
|||||||
"core_permission": "Permission",
|
"core_permission": "Permission",
|
||||||
"sites_site": "Site",
|
"sites_site": "Site",
|
||||||
"catalog_category": "Catégorie",
|
"catalog_category": "Catégorie",
|
||||||
|
"catalog_product": "Produit",
|
||||||
"commercial_client": "Client",
|
"commercial_client": "Client",
|
||||||
"commercial_clientaddress": "Adresse client",
|
"commercial_clientaddress": "Adresse client",
|
||||||
"commercial_clientcontact": "Contact client",
|
"commercial_clientcontact": "Contact client",
|
||||||
@@ -940,6 +1020,74 @@
|
|||||||
"duplicate": "Une catégorie nommée « {name} » existe déjà.",
|
"duplicate": "Une catégorie nommée « {name} » existe déjà.",
|
||||||
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
|
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"products": {
|
||||||
|
"title": "Catalogue produit",
|
||||||
|
"add": "Ajouter",
|
||||||
|
"export": "Exporter",
|
||||||
|
"empty": "Aucun produit pour l'instant.",
|
||||||
|
"column": {
|
||||||
|
"name": "Nom",
|
||||||
|
"code": "Numéro",
|
||||||
|
"category": "Catégorie"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"PURCHASE": "Achat",
|
||||||
|
"SALE": "Vendu",
|
||||||
|
"OTHER": "Autre"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"title": "Filtres",
|
||||||
|
"search": "Recherche",
|
||||||
|
"category": "Catégorie",
|
||||||
|
"categoryAll": "Toutes les catégories",
|
||||||
|
"state": "État",
|
||||||
|
"stateAll": "Tous les états",
|
||||||
|
"site": "Sites",
|
||||||
|
"apply": "Voir les résultats",
|
||||||
|
"reset": "Réinitialiser"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"title": "Ajouter un produit",
|
||||||
|
"back": "Retour au catalogue",
|
||||||
|
"submit": "Valider",
|
||||||
|
"states": "État du produit",
|
||||||
|
"sites": "Site",
|
||||||
|
"name": "Nom du produit",
|
||||||
|
"code": "Code produit",
|
||||||
|
"category": "Catégorie produit",
|
||||||
|
"storageTypes": "Type de stockage",
|
||||||
|
"manufactured": "Fabriqué",
|
||||||
|
"containsMolasses": "Contient de la mélasse",
|
||||||
|
"duplicateCode": "Un produit portant ce code existe déjà."
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Modifier le produit",
|
||||||
|
"back": "Retour",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"loading": "Chargement du produit…",
|
||||||
|
"notFound": "Produit introuvable."
|
||||||
|
},
|
||||||
|
"consultation": {
|
||||||
|
"title": "Fiche produit",
|
||||||
|
"back": "Retour au catalogue",
|
||||||
|
"loading": "Chargement du produit…",
|
||||||
|
"notFound": "Produit introuvable."
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"edit": "Modifier"
|
||||||
|
},
|
||||||
|
"tab": {
|
||||||
|
"suppliers": "Fournisseurs",
|
||||||
|
"clients": "Clients",
|
||||||
|
"placeholder": "Cet onglet est en cours de développement"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"error": "Une erreur est survenue. Réessayez.",
|
||||||
|
"exportError": "L'export du catalogue produit a échoué. Réessayez.",
|
||||||
|
"createSuccess": "Produit créé avec succès",
|
||||||
|
"updateSuccess": "Produit mis à jour avec succès"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
Onglets « Fournisseurs » / « Clients » de la fiche produit — HORS PERIMETRE
|
||||||
|
V0 (HP-M6-01, RG-6.10) : ils dependent d'un module Contrat inexistant.
|
||||||
|
Rendu en placeholder « en cours de développement » (meme composant que les
|
||||||
|
onglets non-dev des fiches M1→M4). AUCUN appel API, AUCUN champ saisissable.
|
||||||
|
|
||||||
|
Visibilite conditionnee par l'etat du produit (cf. spec C3, « Aucun » = OTHER) :
|
||||||
|
- « Fournisseurs » : visible si l'etat contient Achat (PURCHASE) ou Aucun (OTHER) ;
|
||||||
|
- « Clients » : visible si l'etat contient Vendu (SALE) ou Aucun (OTHER).
|
||||||
|
Si aucun onglet n'est applicable (etat vide), rien n'est rendu.
|
||||||
|
-->
|
||||||
|
<MalioTabList v-if="tabs.length" v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||||
|
<template #suppliers><ComingSoonPlaceholder :title="t('admin.products.tab.placeholder')" /></template>
|
||||||
|
<template #clients><ComingSoonPlaceholder :title="t('admin.products.tab.placeholder')" /></template>
|
||||||
|
</MalioTabList>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Etats du produit (codes enum PURCHASE / SALE / OTHER) pilotant la visibilite. */
|
||||||
|
states: string[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// RG (spec C3) : « Fournisseurs » si Achat ou Aucun ; « Clients » si Vendu ou Aucun.
|
||||||
|
const showSuppliers = computed(() => props.states.includes('PURCHASE') || props.states.includes('OTHER'))
|
||||||
|
const showClients = computed(() => props.states.includes('SALE') || props.states.includes('OTHER'))
|
||||||
|
|
||||||
|
// Icone (Iconify) par onglet, alignee sur la convention des fiches existantes.
|
||||||
|
const tabs = computed(() => {
|
||||||
|
const list: { key: string, label: string, icon: string }[] = []
|
||||||
|
if (showSuppliers.value) {
|
||||||
|
list.push({ key: 'suppliers', label: t('admin.products.tab.suppliers'), icon: 'mdi:truck-outline' })
|
||||||
|
}
|
||||||
|
if (showClients.value) {
|
||||||
|
list.push({ key: 'clients', label: t('admin.products.tab.clients'), icon: 'mdi:account-group-outline' })
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeTab = ref('suppliers')
|
||||||
|
|
||||||
|
// Si l'onglet actif disparait suite a un changement d'etat, retombe sur le premier
|
||||||
|
// onglet encore disponible (evite un onglet actif fantome).
|
||||||
|
watch(tabs, (list) => {
|
||||||
|
if (list.length && !list.some(tab => tab.key === activeTab.value)) {
|
||||||
|
activeTab.value = list[0].key
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, nextTick } from 'vue'
|
||||||
|
import ProductPlaceholderTabs from '../ProductPlaceholderTabs.vue'
|
||||||
|
|
||||||
|
// i18n auto-import : retourne la cle telle quelle.
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
|
||||||
|
// Stub de MalioTabList : expose les `key` des onglets recus (data-tab) pour
|
||||||
|
// verifier la visibilite conditionnee par l'etat, sans dependre de la lib UI.
|
||||||
|
const TabListStub = defineComponent({
|
||||||
|
props: { tabs: { type: Array, default: () => [] }, modelValue: { type: String, default: '' } },
|
||||||
|
setup(props) {
|
||||||
|
return () => h(
|
||||||
|
'div',
|
||||||
|
{ 'data-testid': 'tablist' },
|
||||||
|
(props.tabs as { key: string }[]).map(tab => h('span', { 'data-tab': tab.key })),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const PlaceholderStub = defineComponent({ setup() { return () => h('div') } })
|
||||||
|
|
||||||
|
function mountTabs(states: string[]) {
|
||||||
|
return mount(ProductPlaceholderTabs, {
|
||||||
|
props: { states },
|
||||||
|
global: { stubs: { MalioTabList: TabListStub, ComingSoonPlaceholder: PlaceholderStub } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabKeys = (wrapper: ReturnType<typeof mountTabs>): string[] =>
|
||||||
|
wrapper.findAll('[data-tab]').map(node => node.attributes('data-tab') ?? '')
|
||||||
|
|
||||||
|
describe('ProductPlaceholderTabs — visibilite conditionnee par l\'etat', () => {
|
||||||
|
it('Achat (PURCHASE) : affiche uniquement « Fournisseurs »', () => {
|
||||||
|
expect(tabKeys(mountTabs(['PURCHASE']))).toEqual(['suppliers'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Vendu (SALE) : affiche uniquement « Clients »', () => {
|
||||||
|
expect(tabKeys(mountTabs(['SALE']))).toEqual(['clients'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Aucun (OTHER) : affiche les deux onglets', () => {
|
||||||
|
expect(tabKeys(mountTabs(['OTHER']))).toEqual(['suppliers', 'clients'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Achat + Vendu : affiche les deux onglets', () => {
|
||||||
|
expect(tabKeys(mountTabs(['PURCHASE', 'SALE']))).toEqual(['suppliers', 'clients'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('etat vide : ne rend aucun onglet (MalioTabList absent)', () => {
|
||||||
|
const wrapper = mountTabs([])
|
||||||
|
expect(wrapper.find('[data-testid="tablist"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retombe sur le premier onglet visible si l\'actif disparait', async () => {
|
||||||
|
// OTHER -> suppliers actif par defaut ; passage a SALE retire « Fournisseurs ».
|
||||||
|
const wrapper = mountTabs(['OTHER'])
|
||||||
|
await wrapper.setProps({ states: ['SALE'] })
|
||||||
|
await nextTick()
|
||||||
|
// Seul « Clients » subsiste : pas d'onglet actif fantome (verifie via le modelValue).
|
||||||
|
const tablist = wrapper.findComponent(TabListStub)
|
||||||
|
expect(tablist.props('modelValue')).toBe('clients')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||||
|
import { useProductForm } from '../useProductForm'
|
||||||
|
|
||||||
|
// Stubs des auto-imports Nuxt consommes par le composable + ses dependances.
|
||||||
|
const mockGet = vi.hoisted(() => vi.fn())
|
||||||
|
const mockPost = vi.hoisted(() => vi.fn())
|
||||||
|
const mockPatch = vi.hoisted(() => vi.fn())
|
||||||
|
const mockToastSuccess = vi.hoisted(() => vi.fn())
|
||||||
|
const mockToastError = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.stubGlobal('useApi', () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: mockPost,
|
||||||
|
put: vi.fn(),
|
||||||
|
patch: mockPatch,
|
||||||
|
delete: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.stubGlobal('useToast', () => ({
|
||||||
|
success: mockToastSuccess,
|
||||||
|
error: mockToastError,
|
||||||
|
}))
|
||||||
|
// useFormErrors est un auto-import Nuxt : on expose l'implementation reelle
|
||||||
|
// (elle consomme useToast/useI18n deja stubbes) pour tester l'integration 422/409.
|
||||||
|
vi.stubGlobal('useFormErrors', useFormErrors)
|
||||||
|
vi.stubGlobal('useI18n', () => ({
|
||||||
|
t: (key: string, params?: Record<string, unknown>) =>
|
||||||
|
params ? `${key}::${JSON.stringify(params)}` : key,
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** Referentiel PLAT des types de stockage (renvoye tel quel, sans filtre site). */
|
||||||
|
const STORAGE_TYPES = {
|
||||||
|
member: [
|
||||||
|
{ '@id': '/api/storage_types/9', label: 'Tas' },
|
||||||
|
{ '@id': '/api/storage_types/5', label: 'Cellule' },
|
||||||
|
{ '@id': '/api/storage_types/7', label: 'Cuve mélasse' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useProductForm', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGet.mockReset()
|
||||||
|
mockPost.mockReset()
|
||||||
|
mockPatch.mockReset()
|
||||||
|
mockToastSuccess.mockReset()
|
||||||
|
mockToastError.mockReset()
|
||||||
|
|
||||||
|
// Routage des GET par url (referentiels). Le stockage est un referentiel
|
||||||
|
// plat : meme reponse quelle que soit la requete.
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url === '/sites') {
|
||||||
|
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] })
|
||||||
|
}
|
||||||
|
if (url === '/categories') {
|
||||||
|
return Promise.resolve({ member: [{ '@id': '/api/categories/12', name: 'Céréales' }] })
|
||||||
|
}
|
||||||
|
if (url === '/storage_types') {
|
||||||
|
return Promise.resolve(STORAGE_TYPES)
|
||||||
|
}
|
||||||
|
return Promise.resolve({ member: [] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RG-6.03 — champs conditionnels « Vendu »', () => {
|
||||||
|
it('isSale est vrai uniquement si states contient SALE', () => {
|
||||||
|
const { form, isSale } = useProductForm()
|
||||||
|
expect(isSale.value).toBe(false)
|
||||||
|
form.states = ['PURCHASE']
|
||||||
|
expect(isSale.value).toBe(false)
|
||||||
|
form.states = ['PURCHASE', 'SALE']
|
||||||
|
expect(isSale.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remet manufactured / containsMolasses a false quand SALE est retire', async () => {
|
||||||
|
const { form, isSale } = useProductForm()
|
||||||
|
form.states = ['SALE']
|
||||||
|
form.manufactured = true
|
||||||
|
form.containsMolasses = true
|
||||||
|
await nextTick()
|
||||||
|
expect(isSale.value).toBe(true)
|
||||||
|
|
||||||
|
form.states = ['PURCHASE']
|
||||||
|
await nextTick()
|
||||||
|
expect(form.manufactured).toBe(false)
|
||||||
|
expect(form.containsMolasses).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RG-6.06 — types de stockage (referentiel plat)', () => {
|
||||||
|
it('loadReferentials charge TOUS les types de stockage, sans filtre site', async () => {
|
||||||
|
const { storageTypeOptions, loadReferentials } = useProductForm()
|
||||||
|
await loadReferentials()
|
||||||
|
|
||||||
|
const storageCall = mockGet.mock.calls.find(c => c[0] === '/storage_types')
|
||||||
|
expect(storageCall).toBeDefined()
|
||||||
|
// Aucun filtre siteId envoye (referentiel plat).
|
||||||
|
expect(storageCall?.[1]).not.toHaveProperty('siteId[]')
|
||||||
|
expect(storageTypeOptions.value.map(o => o.value)).toEqual([
|
||||||
|
'/api/storage_types/9',
|
||||||
|
'/api/storage_types/5',
|
||||||
|
'/api/storage_types/7',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setSites met a jour les sites sans recharger le stockage ni purger la selection', async () => {
|
||||||
|
const { form, setSites, setStorageTypes, loadReferentials } = useProductForm()
|
||||||
|
await loadReferentials()
|
||||||
|
const storageCallsBefore = mockGet.mock.calls.filter(c => c[0] === '/storage_types').length
|
||||||
|
|
||||||
|
setStorageTypes(['/api/storage_types/9'])
|
||||||
|
setSites(['/api/sites/1'])
|
||||||
|
|
||||||
|
expect(form.siteIris).toEqual(['/api/sites/1'])
|
||||||
|
// Selection conservee : plus de cascade ni de purge par site.
|
||||||
|
expect(form.storageTypeIris).toEqual(['/api/storage_types/9'])
|
||||||
|
// setSites ne declenche aucun nouvel appel /storage_types.
|
||||||
|
const storageCallsAfter = mockGet.mock.calls.filter(c => c[0] === '/storage_types').length
|
||||||
|
expect(storageCallsAfter).toBe(storageCallsBefore)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('submit — POST /products', () => {
|
||||||
|
function fillValidForm(form: ReturnType<typeof useProductForm>['form']): void {
|
||||||
|
form.code = 'ble-01'
|
||||||
|
form.name = 'Blé tendre'
|
||||||
|
form.states = ['PURCHASE', 'SALE']
|
||||||
|
form.siteIris = ['/api/sites/1']
|
||||||
|
form.categoryIri = '/api/categories/12'
|
||||||
|
form.storageTypeIris = ['/api/storage_types/9']
|
||||||
|
form.manufactured = true
|
||||||
|
form.containsMolasses = false
|
||||||
|
}
|
||||||
|
|
||||||
|
it('poste le payload (relations en IRI) et retourne true au succes', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce({ id: 34 })
|
||||||
|
const { form, submit } = useProductForm()
|
||||||
|
fillValidForm(form)
|
||||||
|
|
||||||
|
const ok = await submit()
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/products',
|
||||||
|
{
|
||||||
|
code: 'ble-01',
|
||||||
|
name: 'Blé tendre',
|
||||||
|
states: ['PURCHASE', 'SALE'],
|
||||||
|
manufactured: true,
|
||||||
|
containsMolasses: false,
|
||||||
|
category: '/api/categories/12',
|
||||||
|
sites: ['/api/sites/1'],
|
||||||
|
storageTypes: ['/api/storage_types/9'],
|
||||||
|
},
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
expect(mockToastSuccess).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('force manufactured / containsMolasses a false hors « Vendu » (RG-6.03)', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce({ id: 35 })
|
||||||
|
const { form, submit } = useProductForm()
|
||||||
|
fillValidForm(form)
|
||||||
|
// L'utilisateur retire « Vendu » apres avoir coche les booleens.
|
||||||
|
form.states = ['PURCHASE']
|
||||||
|
|
||||||
|
await submit()
|
||||||
|
|
||||||
|
const payload = mockPost.mock.calls[0][1]
|
||||||
|
expect(payload.manufactured).toBe(false)
|
||||||
|
expect(payload.containsMolasses).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omet `category` du payload quand aucune categorie n\'est choisie', async () => {
|
||||||
|
// Envoyer category:null casserait la denormalisation back (type IRI
|
||||||
|
// attendu) et court-circuiterait les autres violations -> on l'omet.
|
||||||
|
mockPost.mockResolvedValueOnce({ id: 40 })
|
||||||
|
const { form, submit } = useProductForm()
|
||||||
|
fillValidForm(form)
|
||||||
|
form.categoryIri = null
|
||||||
|
|
||||||
|
await submit()
|
||||||
|
|
||||||
|
const payload = mockPost.mock.calls[0][1]
|
||||||
|
expect(payload).not.toHaveProperty('category')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe un 409 doublon de code sur errors.code + toast explicite', async () => {
|
||||||
|
mockPost.mockRejectedValueOnce({ response: { status: 409, _data: {} } })
|
||||||
|
const { form, errors, submit } = useProductForm()
|
||||||
|
fillValidForm(form)
|
||||||
|
|
||||||
|
const ok = await submit()
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(errors.code).toBe('admin.products.form.duplicateCode')
|
||||||
|
expect(mockToastError).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe une 422 inline par champ (errors.code) sans toast', async () => {
|
||||||
|
mockPost.mockRejectedValueOnce({
|
||||||
|
response: {
|
||||||
|
status: 422,
|
||||||
|
_data: { violations: [{ propertyPath: 'code', message: 'Le code produit est obligatoire.' }] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const { form, errors, submit } = useProductForm()
|
||||||
|
fillValidForm(form)
|
||||||
|
form.code = null
|
||||||
|
|
||||||
|
const ok = await submit()
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(errors.code).toBe('Le code produit est obligatoire.')
|
||||||
|
expect(mockToastError).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RG-6.08 — mode edition (prefill + PATCH)', () => {
|
||||||
|
// Produit charge (memes cles que la reponse reelle § 4.0.bis : @id sur les relations).
|
||||||
|
const PRODUCT = {
|
||||||
|
id: 34,
|
||||||
|
code: 'BLE-01',
|
||||||
|
name: 'Blé tendre',
|
||||||
|
states: ['PURCHASE', 'SALE'],
|
||||||
|
manufactured: true,
|
||||||
|
containsMolasses: false,
|
||||||
|
category: { '@id': '/api/categories/12', id: 12, name: 'Céréales', code: 'CEREALES' },
|
||||||
|
sites: [{ '@id': '/api/sites/1', id: 1, name: 'Chatellerault', code: '86', postalCode: '86100', city: 'C', color: '#000', fullAddress: 'x' }],
|
||||||
|
storageTypes: [{ '@id': '/api/storage_types/9', id: 9, code: 'TAS', label: 'Tas' }],
|
||||||
|
createdAt: '', updatedAt: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('pre-remplit le formulaire depuis le produit (relations en IRI)', async () => {
|
||||||
|
const { form, prefill } = useProductForm()
|
||||||
|
await prefill(PRODUCT)
|
||||||
|
|
||||||
|
expect(form.code).toBe('BLE-01')
|
||||||
|
expect(form.name).toBe('Blé tendre')
|
||||||
|
expect(form.states).toEqual(['PURCHASE', 'SALE'])
|
||||||
|
expect(form.categoryIri).toBe('/api/categories/12')
|
||||||
|
expect(form.siteIris).toEqual(['/api/sites/1'])
|
||||||
|
expect(form.storageTypeIris).toEqual(['/api/storage_types/9'])
|
||||||
|
expect(form.manufactured).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('soumet un PATCH /products/{id} apres prefill (RG-6.08)', async () => {
|
||||||
|
// Le PATCH renvoie le produit normalise : submit re-prefill le form a partir
|
||||||
|
// de la reponse (l'utilisateur reste sur l'ecran, pas de redirection).
|
||||||
|
mockPatch.mockResolvedValueOnce({ ...PRODUCT, code: 'BLE-01', name: 'Blé tendre' })
|
||||||
|
const { prefill, submit } = useProductForm()
|
||||||
|
await prefill(PRODUCT)
|
||||||
|
|
||||||
|
const ok = await submit()
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/products/34',
|
||||||
|
expect.objectContaining({ code: 'BLE-01', name: 'Blé tendre' }),
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
expect(mockToastSuccess).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-affiche les valeurs normalisees du serveur apres un PATCH (RG-6.07, pas de redirection)', async () => {
|
||||||
|
// Le back normalise code (trim+UPPER) et name (trim) : le form doit refleter
|
||||||
|
// la reponse serveur, pas la saisie locale.
|
||||||
|
mockPatch.mockResolvedValueOnce({ ...PRODUCT, code: 'BLE-01', name: 'Blé tendre' })
|
||||||
|
const { form, prefill, submit } = useProductForm()
|
||||||
|
await prefill(PRODUCT)
|
||||||
|
form.code = 'ble-01 '
|
||||||
|
form.name = ' Blé tendre '
|
||||||
|
|
||||||
|
await submit()
|
||||||
|
|
||||||
|
expect(form.code).toBe('BLE-01')
|
||||||
|
expect(form.name).toBe('Blé tendre')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe un 409 doublon de code aussi en edition', async () => {
|
||||||
|
mockPatch.mockRejectedValueOnce({ response: { status: 409, _data: {} } })
|
||||||
|
const { errors, prefill, submit } = useProductForm()
|
||||||
|
await prefill(PRODUCT)
|
||||||
|
|
||||||
|
const ok = await submit()
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(errors.code).toBe('admin.products.form.duplicateCode')
|
||||||
|
expect(mockToastError).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Product } from '~/modules/catalog/types/product'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chargement d'un produit unique (ecran « Modification produit », M6 — ERP-206).
|
||||||
|
* Lit le detail via `GET /api/products/{id}` — meme structure que la ligne de
|
||||||
|
* liste (category / sites / storageTypes embarques, § 4.0.bis).
|
||||||
|
*
|
||||||
|
* L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload
|
||||||
|
* Hydra complet (IRI `@id` des relations, necessaires au pre-remplissage des
|
||||||
|
* selects). Etat 100 % local a l'instance (refs) — aucune persistance URL.
|
||||||
|
*/
|
||||||
|
export function useProduct(id: number | string) {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const product = ref<Product | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(false)
|
||||||
|
|
||||||
|
/** Charge le detail du produit. En cas d'echec : `error = true`, `product = null`. */
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = false
|
||||||
|
try {
|
||||||
|
product.value = await api.get<Product>(
|
||||||
|
`/products/${id}`,
|
||||||
|
{},
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
error.value = true
|
||||||
|
product.value = null
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { product, loading, error, load }
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* Composable du formulaire de creation produit (M6 — ERP-205).
|
||||||
|
*
|
||||||
|
* Porte l'etat du formulaire principal, les referentiels des selects, les regles
|
||||||
|
* de gestion front (champs conditionnels RG-6.03) et la soumission
|
||||||
|
* `POST /api/products` avec mapping des erreurs 422/409 inline
|
||||||
|
* (useFormErrors). Reference : ecran « Ajouter un client » / « Ajouter un
|
||||||
|
* prestataire » (formulaire principal).
|
||||||
|
*
|
||||||
|
* Etat 100 % local a l'instance.
|
||||||
|
*/
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
useSiteOptions,
|
||||||
|
useCategoryOptions,
|
||||||
|
useStorageTypeOptions,
|
||||||
|
} from '~/modules/catalog/composables/useProductOptions'
|
||||||
|
import type { Product } from '~/modules/catalog/types/product'
|
||||||
|
|
||||||
|
/** Etats produit (miroir de l'enum back Product::STATE_*). */
|
||||||
|
export const PRODUCT_STATES = ['PURCHASE', 'SALE', 'OTHER'] as const
|
||||||
|
|
||||||
|
export function useProductForm() {
|
||||||
|
const api = useApi()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const toast = useToast()
|
||||||
|
const formErrors = useFormErrors()
|
||||||
|
|
||||||
|
const sites = useSiteOptions()
|
||||||
|
const categories = useCategoryOptions({ typeCode: 'PRODUIT' })
|
||||||
|
const storage = useStorageTypeOptions()
|
||||||
|
|
||||||
|
// ── Etat du formulaire ───────────────────────────────────────────────────
|
||||||
|
// Les relations sont stockees en IRI (envoyees telles quelles au POST) ;
|
||||||
|
// `states` porte les codes enum ; les booleens conditionnels RG-6.03 a part.
|
||||||
|
const form = reactive({
|
||||||
|
code: null as string | null,
|
||||||
|
name: null as string | null,
|
||||||
|
states: [] as string[],
|
||||||
|
siteIris: [] as string[],
|
||||||
|
categoryIri: null as string | null,
|
||||||
|
storageTypeIris: [] as string[],
|
||||||
|
manufactured: false,
|
||||||
|
containsMolasses: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
// Id du produit edite (null = creation). Pilote l'URL/methode du submit (RG-6.08 :
|
||||||
|
// « Modification » = meme formulaire/regles que « Ajouter », bouton « Enregistrer »).
|
||||||
|
const productId = ref<number | null>(null)
|
||||||
|
|
||||||
|
// RG-6.03 : « Fabriqué » / « Contient de la mélasse » saisissables uniquement
|
||||||
|
// si l'etat contient « Vendu » (SALE).
|
||||||
|
const isSale = computed(() => form.states.includes('SALE'))
|
||||||
|
|
||||||
|
// Quand l'etat ne contient plus SALE, on remet les booleens a false : le back
|
||||||
|
// les forcerait de toute facon (RG-6.03), on evite de soumettre une valeur
|
||||||
|
// fantome saisie avant de retirer « Vendu ».
|
||||||
|
watch(isSale, (sale) => {
|
||||||
|
if (!sale) {
|
||||||
|
form.manufactured = false
|
||||||
|
form.containsMolasses = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Met a jour les etats (multi-select). */
|
||||||
|
function setStates(states: string[]): void {
|
||||||
|
form.states = states
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Met a jour la categorie (select simple). */
|
||||||
|
function setCategory(iri: string | null): void {
|
||||||
|
form.categoryIri = iri
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Met a jour les types de stockage (multi-select). */
|
||||||
|
function setStorageTypes(iris: string[]): void {
|
||||||
|
form.storageTypeIris = iris
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Met a jour les sites de disponibilite (multi-select, RG-6.04). */
|
||||||
|
function setSites(iris: string[]): void {
|
||||||
|
form.siteIris = iris
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge les referentiels initiaux (sites + categories + types de stockage).
|
||||||
|
* Resilient. Les types de stockage forment un referentiel plat : on les charge
|
||||||
|
* tous d'emblee (plus de cascade par site, RG-6.06 revue).
|
||||||
|
*/
|
||||||
|
async function loadReferentials(): Promise<void> {
|
||||||
|
await Promise.allSettled([sites.load(), categories.load(), storage.load()])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-remplit le formulaire depuis un produit charge (mode edition, RG-6.08).
|
||||||
|
* Les relations sont reprises via leur IRI `@id` (= valeur d'option des selects).
|
||||||
|
* Les options de Type de stockage sont chargees par loadReferentials (referentiel
|
||||||
|
* plat) : prefill se contente de mapper la selection.
|
||||||
|
*/
|
||||||
|
async function prefill(product: Product): Promise<void> {
|
||||||
|
productId.value = product.id
|
||||||
|
form.code = product.code
|
||||||
|
form.name = product.name
|
||||||
|
form.states = [...product.states]
|
||||||
|
form.categoryIri = product.category?.['@id'] ?? null
|
||||||
|
form.siteIris = product.sites.map(s => s['@id'])
|
||||||
|
form.manufactured = product.manufactured
|
||||||
|
form.containsMolasses = product.containsMolasses
|
||||||
|
form.storageTypeIris = product.storageTypes.map(st => st['@id'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soumet le formulaire. Retourne true au succes (la page redirige), false sinon.
|
||||||
|
* Creation → `POST /products` ; edition (productId non nul, RG-6.08) →
|
||||||
|
* `PATCH /products/{id}` (mode merge-patch gere par useApi). 422 → mapping
|
||||||
|
* inline par champ (useFormErrors) ; 409 doublon de code → erreur inline sur
|
||||||
|
* `code` + toast explicite (RG-6.01, unicite re-validee aussi en edition).
|
||||||
|
*/
|
||||||
|
async function submit(): Promise<boolean> {
|
||||||
|
if (submitting.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
formErrors.clearErrors()
|
||||||
|
const editing = productId.value !== null
|
||||||
|
try {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
// Chaine vide (jamais null) : les setters back setCode/setName attendent
|
||||||
|
// un `string` non-nullable -> envoyer null leverait une erreur de type
|
||||||
|
// (denormalisation) qui court-circuiterait toutes les autres violations.
|
||||||
|
// Avec '', la contrainte NotBlank renvoie un message propre par champ.
|
||||||
|
code: form.code ?? '',
|
||||||
|
name: form.name ?? '',
|
||||||
|
states: form.states,
|
||||||
|
// RG-6.03 : booleens forces a false hors « Vendu » (le back les
|
||||||
|
// re-force, on garde le payload coherent).
|
||||||
|
manufactured: isSale.value ? form.manufactured : false,
|
||||||
|
containsMolasses: isSale.value ? form.containsMolasses : false,
|
||||||
|
sites: form.siteIris,
|
||||||
|
storageTypes: form.storageTypeIris,
|
||||||
|
}
|
||||||
|
// `category` attend un IRI (string) : envoyer null declencherait une
|
||||||
|
// erreur de denormalisation API Platform qui court-circuiterait TOUTES
|
||||||
|
// les autres violations. On omet la cle quand aucune categorie n'est
|
||||||
|
// choisie -> la contrainte NotNull renvoie un message propre, et les
|
||||||
|
// autres champs sont valides dans la meme 422 (mapping inline ERP-101).
|
||||||
|
if (form.categoryIri) {
|
||||||
|
payload.category = form.categoryIri
|
||||||
|
}
|
||||||
|
const options = { headers: { Accept: 'application/ld+json' }, toast: false }
|
||||||
|
if (editing) {
|
||||||
|
const updated = await api.patch<Product>(`/products/${productId.value}`, payload, options)
|
||||||
|
toast.success({ title: t('admin.products.toast.updateSuccess') })
|
||||||
|
// L'utilisateur garde la main (pas de redirection, calque client/
|
||||||
|
// fournisseur) : on reaffiche les valeurs normalisees renvoyees par le
|
||||||
|
// serveur (code trim+UPPER, name trim — RG-6.07) directement dans le form.
|
||||||
|
await prefill(updated)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await api.post('/products', payload, options)
|
||||||
|
toast.success({ title: t('admin.products.toast.createSuccess') })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
const status = (error as { response?: { status?: number } })?.response?.status
|
||||||
|
if (status === 409) {
|
||||||
|
// Doublon de code (RG-6.01) : inline sur le champ + toast explicite.
|
||||||
|
const message = t('admin.products.form.duplicateCode')
|
||||||
|
formErrors.setError('code', message)
|
||||||
|
toast.error({ title: t('admin.products.toast.error'), message })
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
formErrors.handleApiError(error, { fallbackMessage: t('admin.products.toast.error') })
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
productId,
|
||||||
|
errors: formErrors.errors,
|
||||||
|
submitting,
|
||||||
|
isSale,
|
||||||
|
siteOptions: sites.options,
|
||||||
|
categoryOptions: categories.options,
|
||||||
|
storageTypeOptions: storage.options,
|
||||||
|
setStates,
|
||||||
|
setCategory,
|
||||||
|
setStorageTypes,
|
||||||
|
setSites,
|
||||||
|
loadReferentials,
|
||||||
|
prefill,
|
||||||
|
submit,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Composables d'options des selects du formulaire produit (M6 — ERP-205).
|
||||||
|
*
|
||||||
|
* Chaque referentiel est borne (quelques dizaines d'entrees) : on le recupere en
|
||||||
|
* entier via l'echappatoire `?pagination=false`, avec l'en-tete
|
||||||
|
* `Accept: application/ld+json` impose par API Platform 4 pour obtenir l'enveloppe
|
||||||
|
* Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`), renvoyee telle
|
||||||
|
* quelle dans le payload POST (relations ManyToOne / ManyToMany).
|
||||||
|
*
|
||||||
|
* Etat 100 % local a l'instance (refs) — aucune persistance URL. Chaque appel cree
|
||||||
|
* sa propre instance ; le formulaire en consomme une via `useProductForm`.
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
|
||||||
|
export interface RefOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Membre Hydra minimal commun aux referentiels consommes ici. */
|
||||||
|
interface HydraMember {
|
||||||
|
'@id': string
|
||||||
|
name?: string
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recupere une collection complete (pagination desactivee) et la projette en
|
||||||
|
* options `{ value: IRI, label }`. Resilient : l'appelant gere l'echec (liste vide).
|
||||||
|
*/
|
||||||
|
async function fetchOptions(
|
||||||
|
url: string,
|
||||||
|
query: Record<string, string | string[]>,
|
||||||
|
toLabel: (member: HydraMember) => string,
|
||||||
|
): Promise<RefOption[]> {
|
||||||
|
const res = await useApi().get<{ member?: HydraMember[] }>(
|
||||||
|
url,
|
||||||
|
{ pagination: 'false', ...query },
|
||||||
|
{ headers: LD_JSON_HEADERS, toast: false },
|
||||||
|
)
|
||||||
|
return (res.member ?? []).map(m => ({ value: m['@id'], label: toLabel(m) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sites de disponibilite (libelle = nom du site). */
|
||||||
|
export function useSiteOptions() {
|
||||||
|
const options = ref<RefOption[]>([])
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
options.value = await fetchOptions('/sites', {}, s => s.name ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { options, load }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categories produit. Filtrees au type voulu (`?typeCode=PRODUIT` pour le produit,
|
||||||
|
* RG-6.05) cote serveur — le provider Category supporte deja `typeCode`.
|
||||||
|
*/
|
||||||
|
export function useCategoryOptions(params: { typeCode: string }) {
|
||||||
|
const options = ref<RefOption[]>([])
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
options.value = await fetchOptions('/categories', { typeCode: params.typeCode }, c => c.name ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { options, load }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types de stockage (libelle = `label`). Referentiel PLAT : on charge TOUS les
|
||||||
|
* types, sans filtrage par site (RG-6.06 revue — la dispo par site releve du futur
|
||||||
|
* module Stockage).
|
||||||
|
*/
|
||||||
|
export function useStorageTypeOptions() {
|
||||||
|
const options = ref<RefOption[]>([])
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
options.value = await fetchOptions('/storage_types', {}, s => s.label ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { options, load }
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, Suspense } from 'vue'
|
||||||
|
|
||||||
|
// Produit charge simule (cles de la reponse reelle § 4.0.bis).
|
||||||
|
const PRODUCT = {
|
||||||
|
id: 34,
|
||||||
|
code: 'BLE-01',
|
||||||
|
name: 'Blé tendre',
|
||||||
|
states: ['PURCHASE', 'SALE'],
|
||||||
|
manufactured: true,
|
||||||
|
containsMolasses: false,
|
||||||
|
category: { '@id': '/api/categories/12', id: 12, name: 'Céréales', code: 'CEREALES' },
|
||||||
|
sites: [{ '@id': '/api/sites/1', id: 1, name: 'Chatellerault' }],
|
||||||
|
storageTypes: [{ '@id': '/api/storage_types/9', id: 9, code: 'TAS', label: 'Tas' }],
|
||||||
|
createdAt: '', updatedAt: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const fx = vi.hoisted(() => ({ load: vi.fn() }))
|
||||||
|
|
||||||
|
vi.mock('~/modules/catalog/composables/useProduct', async () => {
|
||||||
|
const { ref } = await import('vue')
|
||||||
|
return {
|
||||||
|
useProduct: () => ({
|
||||||
|
product: ref(PRODUCT),
|
||||||
|
loading: ref(false),
|
||||||
|
error: ref(false),
|
||||||
|
load: fx.load,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||||
|
const mockPush = vi.hoisted(() => vi.fn())
|
||||||
|
const mockNavigateTo = vi.hoisted(() => vi.fn())
|
||||||
|
const mockCan = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
vi.stubGlobal('useHead', () => undefined)
|
||||||
|
vi.stubGlobal('useRoute', () => ({ params: { id: '34' } }))
|
||||||
|
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||||
|
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
||||||
|
vi.stubGlobal('navigateTo', mockNavigateTo)
|
||||||
|
|
||||||
|
const ViewPage = (await import('../admin/products/[id]/index.vue')).default
|
||||||
|
|
||||||
|
const ButtonStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' } },
|
||||||
|
emits: ['click'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Input lecture seule : expose le label + la valeur affichee (model-value).
|
||||||
|
const InputStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, modelValue: { default: null } },
|
||||||
|
setup(props) { return () => h('input', { 'data-label': props.label, 'data-value': String(props.modelValue ?? '') }) },
|
||||||
|
})
|
||||||
|
const CheckboxStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
|
||||||
|
setup(props) { return () => h('input', { 'type': 'checkbox', 'data-label': props.label }) },
|
||||||
|
})
|
||||||
|
const TabsStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'placeholder-tabs' }) } })
|
||||||
|
|
||||||
|
const stubs = {
|
||||||
|
MalioButtonIcon: ButtonStub,
|
||||||
|
MalioButton: ButtonStub,
|
||||||
|
MalioInputText: InputStub,
|
||||||
|
MalioCheckbox: CheckboxStub,
|
||||||
|
ProductPlaceholderTabs: TabsStub,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountPage() {
|
||||||
|
const wrapper = mount(defineComponent({
|
||||||
|
components: { ViewPage },
|
||||||
|
setup: () => () => h(Suspense, null, { default: () => h(ViewPage) }),
|
||||||
|
}), { global: { stubs } })
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Écran Consultation produit (page /admin/products/{id})', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fx.load.mockReset().mockResolvedValue(undefined)
|
||||||
|
mockPush.mockReset()
|
||||||
|
mockNavigateTo.mockReset()
|
||||||
|
mockCan.mockReset().mockReturnValue(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charge le produit au montage', async () => {
|
||||||
|
await mountPage()
|
||||||
|
expect(fx.load).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirige vers la liste sans la permission view', async () => {
|
||||||
|
mockCan.mockReturnValue(false)
|
||||||
|
await mountPage()
|
||||||
|
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche les champs en lecture seule (libelles mappes)', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
const valueOf = (label: string) =>
|
||||||
|
wrapper.find(`[data-label="${label}"]`).attributes('data-value')
|
||||||
|
expect(valueOf('admin.products.form.name')).toBe('Blé tendre')
|
||||||
|
expect(valueOf('admin.products.form.code')).toBe('BLE-01')
|
||||||
|
expect(valueOf('admin.products.form.category')).toBe('Céréales')
|
||||||
|
expect(valueOf('admin.products.form.sites')).toBe('Chatellerault')
|
||||||
|
expect(valueOf('admin.products.form.storageTypes')).toBe('Tas')
|
||||||
|
// Etats : libelles i18n joints.
|
||||||
|
expect(valueOf('admin.products.form.states')).toBe('admin.products.state.PURCHASE, admin.products.state.SALE')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bouton « Modifier » (manage) → ecran d\'edition', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
await wrapper.find('[data-label="admin.products.action.edit"]').trigger('click')
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/admin/products/34/edit')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masque « Modifier » sans la permission manage', async () => {
|
||||||
|
// view OK mais manage refuse.
|
||||||
|
mockCan.mockImplementation((perm: string) => perm === 'catalog.products.view')
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
expect(wrapper.find('[data-label="admin.products.action.edit"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche AUCUN onglet en consultation (coquilles vides masquees, ERP-193)', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
expect(wrapper.find('[data-testid="placeholder-tabs"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masque un champ vide / une checkbox non cochee (ERP-193, isFilled)', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
// containsMolasses = false dans le fixture => case masquee.
|
||||||
|
expect(wrapper.find('[data-label="admin.products.form.containsMolasses"]').exists()).toBe(false)
|
||||||
|
// manufactured = true => case affichee.
|
||||||
|
expect(wrapper.find('[data-label="admin.products.form.manufactured"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, Suspense } from 'vue'
|
||||||
|
|
||||||
|
// Produit charge simule (cles de la reponse reelle § 4.0.bis).
|
||||||
|
const PRODUCT = {
|
||||||
|
id: 34,
|
||||||
|
code: 'BLE-01',
|
||||||
|
name: 'Blé tendre',
|
||||||
|
states: ['PURCHASE'],
|
||||||
|
manufactured: false,
|
||||||
|
containsMolasses: false,
|
||||||
|
category: { '@id': '/api/categories/12', id: 12, name: 'Céréales', code: 'CEREALES' },
|
||||||
|
sites: [{ '@id': '/api/sites/1', id: 1, name: 'Chatellerault' }],
|
||||||
|
storageTypes: [{ '@id': '/api/storage_types/9', id: 9, code: 'TAS', label: 'Tas' }],
|
||||||
|
createdAt: '', updatedAt: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holders crees dans les factories (vue initialise au moment de l'import page).
|
||||||
|
const fx = vi.hoisted(() => ({
|
||||||
|
isSale: null as unknown as { value: boolean },
|
||||||
|
submit: vi.fn(),
|
||||||
|
prefill: vi.fn(),
|
||||||
|
loadReferentials: vi.fn(),
|
||||||
|
load: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/modules/catalog/composables/useProductForm', async () => {
|
||||||
|
const { ref, reactive } = await import('vue')
|
||||||
|
fx.isSale = ref(false)
|
||||||
|
return {
|
||||||
|
PRODUCT_STATES: ['PURCHASE', 'SALE', 'OTHER'],
|
||||||
|
useProductForm: () => ({
|
||||||
|
form: reactive({
|
||||||
|
code: null, name: null, states: [], siteIris: [],
|
||||||
|
categoryIri: null, storageTypeIris: [], manufactured: false, containsMolasses: false,
|
||||||
|
}),
|
||||||
|
errors: reactive({}),
|
||||||
|
submitting: ref(false),
|
||||||
|
isSale: fx.isSale,
|
||||||
|
siteOptions: ref([]),
|
||||||
|
categoryOptions: ref([]),
|
||||||
|
storageTypeOptions: ref([]),
|
||||||
|
setStates: vi.fn(),
|
||||||
|
setCategory: vi.fn(),
|
||||||
|
setStorageTypes: vi.fn(),
|
||||||
|
setSites: vi.fn(),
|
||||||
|
loadReferentials: fx.loadReferentials,
|
||||||
|
prefill: fx.prefill,
|
||||||
|
submit: fx.submit,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('~/modules/catalog/composables/useProduct', async () => {
|
||||||
|
const { ref } = await import('vue')
|
||||||
|
return {
|
||||||
|
useProduct: () => ({
|
||||||
|
product: ref(PRODUCT),
|
||||||
|
loading: ref(false),
|
||||||
|
error: ref(false),
|
||||||
|
load: fx.load,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||||
|
const mockPush = vi.hoisted(() => vi.fn())
|
||||||
|
const mockNavigateTo = vi.hoisted(() => vi.fn())
|
||||||
|
const mockCan = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
vi.stubGlobal('useHead', () => undefined)
|
||||||
|
vi.stubGlobal('useRoute', () => ({ params: { id: '34' } }))
|
||||||
|
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||||
|
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
||||||
|
vi.stubGlobal('navigateTo', mockNavigateTo)
|
||||||
|
|
||||||
|
const EditPage = (await import('../admin/products/[id]/edit.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 }) },
|
||||||
|
})
|
||||||
|
const CheckboxStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' } },
|
||||||
|
setup(props) { return () => h('input', { 'type': 'checkbox', 'data-label': props.label }) },
|
||||||
|
})
|
||||||
|
// Placeholder : rendu sans aucun appel API (juste un marqueur).
|
||||||
|
const TabsStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'placeholder-tabs' }) } })
|
||||||
|
|
||||||
|
const stubs = {
|
||||||
|
MalioButtonIcon: ButtonStub,
|
||||||
|
MalioButton: ButtonStub,
|
||||||
|
MalioInputText: InputStub,
|
||||||
|
MalioSelect: InputStub,
|
||||||
|
MalioSelectCheckbox: InputStub,
|
||||||
|
MalioCheckbox: CheckboxStub,
|
||||||
|
ProductPlaceholderTabs: TabsStub,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountPage() {
|
||||||
|
const wrapper = mount(defineComponent({
|
||||||
|
components: { EditPage },
|
||||||
|
setup: () => () => h(Suspense, null, { default: () => h(EditPage) }),
|
||||||
|
}), { global: { stubs } })
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Écran Modifier un produit (page /admin/products/{id}/edit)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fx.submit.mockReset().mockResolvedValue(true)
|
||||||
|
fx.prefill.mockReset().mockResolvedValue(undefined)
|
||||||
|
fx.loadReferentials.mockReset().mockResolvedValue(undefined)
|
||||||
|
fx.load.mockReset().mockResolvedValue(undefined)
|
||||||
|
mockPush.mockReset()
|
||||||
|
mockNavigateTo.mockReset()
|
||||||
|
mockCan.mockReset().mockReturnValue(true)
|
||||||
|
fx.isSale.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charge le produit et pre-remplit le formulaire au montage', async () => {
|
||||||
|
await mountPage()
|
||||||
|
expect(fx.load).toHaveBeenCalled()
|
||||||
|
expect(fx.prefill).toHaveBeenCalledWith(PRODUCT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirige vers la consultation sans la permission manage', async () => {
|
||||||
|
mockCan.mockReturnValue(false)
|
||||||
|
await mountPage()
|
||||||
|
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products/34')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bouton « Enregistrer » : submit (PATCH) SANS redirection (l\'utilisateur garde la main)', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
await wrapper.find('[data-label="admin.products.edit.save"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(fx.submit).toHaveBeenCalled()
|
||||||
|
// On reste sur l'ecran d'edition : aucune navigation au succes (calque client/fournisseur).
|
||||||
|
expect(mockPush).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche les onglets placeholder (rendu sans appel API)', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
expect(wrapper.find('[data-testid="placeholder-tabs"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, Suspense } from 'vue'
|
||||||
|
|
||||||
|
// ── Mock du composable form (sa logique est testee a part : useProductForm.spec).
|
||||||
|
// Ici on teste le WIRING de la page : rendu conditionnel RG-6.03 + submit→redirect.
|
||||||
|
// Les refs partagees sont creees DANS la factory (vue est initialise au moment ou
|
||||||
|
// la page est importee) et exposees via un holder hoiste pour pilotage par test.
|
||||||
|
const fx = vi.hoisted(() => ({
|
||||||
|
isSale: null as unknown as { value: boolean },
|
||||||
|
submit: vi.fn(),
|
||||||
|
loadReferentials: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/modules/catalog/composables/useProductForm', async () => {
|
||||||
|
const { ref, reactive } = await import('vue')
|
||||||
|
fx.isSale = ref(false)
|
||||||
|
return {
|
||||||
|
PRODUCT_STATES: ['PURCHASE', 'SALE', 'OTHER'],
|
||||||
|
useProductForm: () => ({
|
||||||
|
form: reactive({
|
||||||
|
code: null, name: null, states: [], siteIris: [],
|
||||||
|
categoryIri: null, storageTypeIris: [], manufactured: false, containsMolasses: false,
|
||||||
|
}),
|
||||||
|
errors: reactive({}),
|
||||||
|
submitting: ref(false),
|
||||||
|
isSale: fx.isSale,
|
||||||
|
siteOptions: ref([]),
|
||||||
|
categoryOptions: ref([]),
|
||||||
|
storageTypeOptions: ref([]),
|
||||||
|
setStates: vi.fn(),
|
||||||
|
setCategory: vi.fn(),
|
||||||
|
setStorageTypes: vi.fn(),
|
||||||
|
setSites: vi.fn(),
|
||||||
|
loadReferentials: fx.loadReferentials,
|
||||||
|
submit: fx.submit,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||||
|
const mockPush = vi.hoisted(() => vi.fn())
|
||||||
|
const mockNavigateTo = vi.hoisted(() => vi.fn())
|
||||||
|
const mockCan = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
vi.stubGlobal('useHead', () => undefined)
|
||||||
|
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||||
|
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
||||||
|
vi.stubGlobal('navigateTo', mockNavigateTo)
|
||||||
|
|
||||||
|
const NewPage = (await import('../admin/products/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 }) },
|
||||||
|
})
|
||||||
|
const CheckboxStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
|
||||||
|
setup(props) { return () => h('input', { 'type': 'checkbox', 'data-label': props.label }) },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Placeholder (onglets Fournisseurs/Clients) : marqueur sans appel API.
|
||||||
|
const TabsStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'placeholder-tabs' }) } })
|
||||||
|
|
||||||
|
const stubs = {
|
||||||
|
MalioButtonIcon: ButtonStub,
|
||||||
|
MalioButton: ButtonStub,
|
||||||
|
MalioInputText: InputStub,
|
||||||
|
MalioSelect: InputStub,
|
||||||
|
MalioSelectCheckbox: InputStub,
|
||||||
|
MalioCheckbox: CheckboxStub,
|
||||||
|
ProductPlaceholderTabs: TabsStub,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 un produit (page /admin/products/new)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fx.submit.mockReset().mockResolvedValue(true)
|
||||||
|
fx.loadReferentials.mockReset().mockResolvedValue(undefined)
|
||||||
|
mockPush.mockReset()
|
||||||
|
mockNavigateTo.mockReset()
|
||||||
|
mockCan.mockReset().mockReturnValue(true)
|
||||||
|
fx.isSale.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirige vers la liste sans la permission manage', async () => {
|
||||||
|
mockCan.mockReturnValue(false)
|
||||||
|
await mountPage()
|
||||||
|
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charge les referentiels au montage', async () => {
|
||||||
|
await mountPage()
|
||||||
|
expect(fx.loadReferentials).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('RG-6.03 : masque Fabriqué / mélasse tant que l\'etat ne contient pas « Vendu »', async () => {
|
||||||
|
fx.isSale.value = false
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
expect(wrapper.find('[data-label="admin.products.form.manufactured"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-label="admin.products.form.containsMolasses"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('RG-6.03 : affiche Fabriqué / mélasse quand l\'etat contient « Vendu »', async () => {
|
||||||
|
fx.isSale.value = true
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
expect(wrapper.find('[data-label="admin.products.form.manufactured"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-label="admin.products.form.containsMolasses"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('« Valider » : submit puis retour a la liste au succes', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
await wrapper.find('[data-label="admin.products.form.submit"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(fx.submit).toHaveBeenCalled()
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/admin/products')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne redirige pas si submit echoue (erreurs inline)', async () => {
|
||||||
|
fx.submit.mockResolvedValueOnce(false)
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
await wrapper.find('[data-label="admin.products.form.submit"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockPush).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche PAS les onglets Fournisseurs/Clients a l\'ajout (avant validation)', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
expect(wrapper.find('[data-testid="placeholder-tabs"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } 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→M5.
|
||||||
|
const mockPush = vi.hoisted(() => vi.fn())
|
||||||
|
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||||
|
const mockCan = vi.hoisted(() => vi.fn())
|
||||||
|
const mockSetFilters = vi.hoisted(() => vi.fn())
|
||||||
|
const mockFetch = 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 }))
|
||||||
|
// usePaginatedList est l'auto-import pilotant la liste : on controle items +
|
||||||
|
// setFilters + fetch. La ligne reproduit le contrat JSON reel (§ 4.0.bis).
|
||||||
|
vi.stubGlobal('usePaginatedList', () => ({
|
||||||
|
items: ref<Array<Record<string, unknown>>>([
|
||||||
|
{
|
||||||
|
id: 34,
|
||||||
|
code: 'BLE-TENDRE-01',
|
||||||
|
name: 'Blé tendre',
|
||||||
|
states: ['PURCHASE', 'SALE'],
|
||||||
|
manufactured: true,
|
||||||
|
containsMolasses: true,
|
||||||
|
category: { id: 12, name: 'Céréales', code: 'CEREALES' },
|
||||||
|
sites: [{ id: 1, name: 'Chatellerault', code: '86' }],
|
||||||
|
storageTypes: [{ id: 9, code: 'TAS', label: 'Tas' }],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
totalItems: ref(1),
|
||||||
|
currentPage: ref(1),
|
||||||
|
itemsPerPage: ref(10),
|
||||||
|
itemsPerPageOptions: ref([10, 25, 50]),
|
||||||
|
fetch: mockFetch,
|
||||||
|
goToPage: vi.fn(),
|
||||||
|
setItemsPerPage: vi.fn(),
|
||||||
|
setFilters: mockSetFilters,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 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 ProductsIndex = (await import('../admin/products/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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const DataTableStub = defineComponent({
|
||||||
|
props: { items: { type: Array, default: () => [] } },
|
||||||
|
emits: ['row-click', 'update:page', 'update:per-page'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('div', { 'data-testid': 'datatable' },
|
||||||
|
(props.items as Array<Record<string, unknown>>).map(it =>
|
||||||
|
h('tr', {
|
||||||
|
'data-row-id': it.id,
|
||||||
|
'data-name': it.name,
|
||||||
|
'data-code': it.code,
|
||||||
|
'data-category': it.categoryName,
|
||||||
|
'onClick': () => emit('row-click', it),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const DrawerStub = defineComponent({
|
||||||
|
props: { modelValue: { type: Boolean, default: false } },
|
||||||
|
setup(_, { slots }) {
|
||||||
|
return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const SlotStub = defineComponent({ setup(_, { slots }) { return () => h('div', {}, slots.default?.()) } })
|
||||||
|
|
||||||
|
const PageHeaderStub = defineComponent({
|
||||||
|
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const CheckboxStub = defineComponent({
|
||||||
|
props: { id: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
|
||||||
|
emits: ['update:model-value'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('input', {
|
||||||
|
'type': 'checkbox',
|
||||||
|
'data-id': props.id,
|
||||||
|
'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLInputElement).checked),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const SelectStub = defineComponent({
|
||||||
|
props: {
|
||||||
|
modelValue: { type: [String, Number, null] as unknown as () => string | number | null, default: null },
|
||||||
|
options: { type: Array, default: () => [] },
|
||||||
|
emptyOptionLabel: { type: String, default: '' },
|
||||||
|
},
|
||||||
|
emits: ['update:model-value'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('select', {
|
||||||
|
'data-empty-label': props.emptyOptionLabel,
|
||||||
|
'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLSelectElement).value),
|
||||||
|
}, (props.options as Array<{ value: string | number, label: string }>).map(o =>
|
||||||
|
h('option', { value: o.value }, o.label),
|
||||||
|
))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const InputTextStub = defineComponent({ setup() { return () => h('input') } })
|
||||||
|
|
||||||
|
function mountPage() {
|
||||||
|
return mount(ProductsIndex, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
PageHeader: PageHeaderStub,
|
||||||
|
MalioButton: ButtonStub,
|
||||||
|
MalioDataTable: DataTableStub,
|
||||||
|
MalioDrawer: DrawerStub,
|
||||||
|
MalioAccordion: SlotStub,
|
||||||
|
MalioAccordionItem: SlotStub,
|
||||||
|
MalioInputText: InputTextStub,
|
||||||
|
MalioSelect: SelectStub,
|
||||||
|
MalioCheckbox: CheckboxStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Catalogue produit (page /admin/products)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPush.mockReset()
|
||||||
|
mockApiGet.mockReset().mockImplementation((url: string) => {
|
||||||
|
if (url === '/categories') {
|
||||||
|
return Promise.resolve({ member: [{ '@id': '/api/categories/12', id: 12, name: 'Céréales' }] })
|
||||||
|
}
|
||||||
|
if (url === '/sites') {
|
||||||
|
return Promise.resolve({ member: [{ id: 1, name: 'Chatellerault' }] })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ member: [] })
|
||||||
|
})
|
||||||
|
mockCan.mockReset().mockReturnValue(true)
|
||||||
|
mockSetFilters.mockReset()
|
||||||
|
mockFetch.mockReset()
|
||||||
|
mockToastError.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charge la liste au montage', async () => {
|
||||||
|
mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockFetch).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe les colonnes Nom / Numéro / Catégorie sur le JSON réel (§ 4.0.bis)', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
const row = wrapper.find('tr[data-row-id="34"]')
|
||||||
|
expect(row.attributes('data-name')).toBe('Blé tendre')
|
||||||
|
expect(row.attributes('data-code')).toBe('BLE-TENDRE-01')
|
||||||
|
expect(row.attributes('data-category')).toBe('Céréales')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche « + Ajouter » uniquement avec la permission manage', async () => {
|
||||||
|
mockCan.mockImplementation((perm: string) => perm === 'catalog.products.manage')
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-label="admin.products.add"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
|
||||||
|
mockCan.mockImplementation((perm: string) => perm === 'catalog.products.view')
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-label="admin.products.add"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigue vers la consultation au clic sur une ligne', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('tr[data-row-id="34"]').trigger('click')
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/admin/products/34')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigue vers la création au clic sur « + Ajouter »', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('[data-label="admin.products.add"]').trigger('click')
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/admin/products/new')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appelle l\'export XLSX sur /products/export.xlsx en blob', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('[data-label="admin.products.export"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockApiGet).toHaveBeenCalledWith(
|
||||||
|
'/products/export.xlsx',
|
||||||
|
expect.any(Object),
|
||||||
|
expect.objectContaining({ responseType: 'blob', toast: false }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('répercute les sites cochés dans setFilters (filtre multi, clé siteId[])', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('input[data-id="filter-site-1"]').setValue(true)
|
||||||
|
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
|
||||||
|
|
||||||
|
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||||
|
{ 'siteId[]': ['1'] },
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
|
||||||
|
expect(mockPush).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('répercute l\'état sélectionné dans setFilters (param state)', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('select[data-empty-label="admin.products.filters.stateAll"]').setValue('SALE')
|
||||||
|
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
|
||||||
|
|
||||||
|
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||||
|
{ state: 'SALE' },
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('répercute la catégorie sélectionnée dans setFilters (param categoryId)', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('select[data-empty-label="admin.products.filters.categoryAll"]').setValue('12')
|
||||||
|
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
|
||||||
|
|
||||||
|
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||||
|
{ categoryId: '12' },
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('badge filtres actifs + Réinitialiser vide l\'état appliqué', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('input[data-id="filter-site-1"]').setValue(true)
|
||||||
|
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
|
||||||
|
|
||||||
|
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
|
||||||
|
expect(wrapper.find('[data-label="admin.products.filters.title (1)"]').exists()).toBe(true)
|
||||||
|
|
||||||
|
// Réinitialiser → query propre (setFilters avec objet vide).
|
||||||
|
await wrapper.find('[data-label="admin.products.filters.reset"]').trigger('click')
|
||||||
|
expect(mockSetFilters).toHaveBeenLastCalledWith({}, { replace: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- En-tete : retour vers le catalogue + nom du produit. -->
|
||||||
|
<div class="flex items-center gap-3 pt-11">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
:title="t('admin.products.edit.back')"
|
||||||
|
v-bind="{ ariaLabel: t('admin.products.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('admin.products.edit.loading') }}</p>
|
||||||
|
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('admin.products.edit.notFound') }}</p>
|
||||||
|
|
||||||
|
<template v-else-if="product">
|
||||||
|
<!-- ── Formulaire principal pre-rempli (mêmes champs/regles que l'ajout,
|
||||||
|
RG-6.01→6.07). Bouton « Enregistrer » → PATCH (RG-6.08). -->
|
||||||
|
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<!-- Etat du produit : multi-select obligatoire (>= 1, RG-6.02). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="form.states"
|
||||||
|
:options="stateOptions"
|
||||||
|
:label="t('admin.products.form.states')"
|
||||||
|
:display-tag="true"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.states"
|
||||||
|
@update:model-value="(v: (string | number)[]) => setStates(v.map(String))"
|
||||||
|
/>
|
||||||
|
<!-- Sites de disponibilite : multi-select obligatoire (>= 1, RG-6.04). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="form.siteIris"
|
||||||
|
:options="siteOptions"
|
||||||
|
:label="t('admin.products.form.sites')"
|
||||||
|
:display-tag="true"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.sites"
|
||||||
|
@update:model-value="(v: (string | number)[]) => setSites(v.map(String))"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.name"
|
||||||
|
:mask="FREE_TEXT_MASK"
|
||||||
|
:label="t('admin.products.form.name')"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.name"
|
||||||
|
/>
|
||||||
|
<!-- Code modifiable techniquement ; l'unicite reste re-validee serveur (RG-6.01). -->
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.code"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
|
:label="t('admin.products.form.code')"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.code"
|
||||||
|
/>
|
||||||
|
<!-- Categorie produit : select simple obligatoire, filtre type PRODUIT (RG-6.05). -->
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="form.categoryIri"
|
||||||
|
:options="categoryOptions"
|
||||||
|
:label="t('admin.products.form.category')"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:error="errors.category"
|
||||||
|
@update:model-value="(v: string | number | null) => setCategory(v === null || v === '' ? null : String(v))"
|
||||||
|
/>
|
||||||
|
<!-- Type de stockage : multi-select obligatoire (>= 1). Referentiel plat :
|
||||||
|
tous les types (plus de filtrage par site, RG-6.06). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="form.storageTypeIris"
|
||||||
|
:options="storageTypeOptions"
|
||||||
|
:label="t('admin.products.form.storageTypes')"
|
||||||
|
:display-tag="true"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.storageTypes"
|
||||||
|
@update:model-value="(v: (string | number)[]) => setStorageTypes(v.map(String))"
|
||||||
|
/>
|
||||||
|
<!-- RG-6.03 : « Fabriqué » + « Contient de la mélasse » visibles
|
||||||
|
uniquement si l'Etat contient « Vendu ». -->
|
||||||
|
<MalioCheckbox
|
||||||
|
v-if="isSale"
|
||||||
|
v-model="form.manufactured"
|
||||||
|
:label="t('admin.products.form.manufactured')"
|
||||||
|
group-class="self-center"
|
||||||
|
/>
|
||||||
|
<MalioCheckbox
|
||||||
|
v-if="isSale"
|
||||||
|
v-model="form.containsMolasses"
|
||||||
|
:label="t('admin.products.form.containsMolasses')"
|
||||||
|
group-class="self-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-12 flex justify-center">
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('admin.products.edit.save')"
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="onSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Onglets Fournisseurs / Clients en placeholder (HP-M6-01). Visibilite
|
||||||
|
conditionnee par l'etat : Fournisseurs si Achat/Aucun, Clients si
|
||||||
|
Vendu/Aucun (cf. ProductPlaceholderTabs). -->
|
||||||
|
<ProductPlaceholderTabs :states="form.states" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useProductForm, PRODUCT_STATES } from '~/modules/catalog/composables/useProductForm'
|
||||||
|
import { useProduct } from '~/modules/catalog/composables/useProduct'
|
||||||
|
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
const productId = route.params.id as string
|
||||||
|
|
||||||
|
// Gating de la route : la modification est reservee a `manage` ; sinon retour
|
||||||
|
// consultation (la lecture seule reste accessible avec `view`).
|
||||||
|
if (!can('catalog.products.manage')) {
|
||||||
|
await navigateTo(`/admin/products/${productId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { product, loading, error, load } = useProduct(productId)
|
||||||
|
|
||||||
|
const {
|
||||||
|
form,
|
||||||
|
errors,
|
||||||
|
submitting,
|
||||||
|
isSale,
|
||||||
|
siteOptions,
|
||||||
|
categoryOptions,
|
||||||
|
storageTypeOptions,
|
||||||
|
setStates,
|
||||||
|
setCategory,
|
||||||
|
setStorageTypes,
|
||||||
|
setSites,
|
||||||
|
loadReferentials,
|
||||||
|
prefill,
|
||||||
|
submit,
|
||||||
|
} = useProductForm()
|
||||||
|
|
||||||
|
const headerTitle = computed(() => product.value?.name ?? t('admin.products.edit.title'))
|
||||||
|
|
||||||
|
useHead({ title: headerTitle })
|
||||||
|
|
||||||
|
// Options de l'etat : libelles i18n (la valeur d'option = code enum).
|
||||||
|
const stateOptions = computed(() =>
|
||||||
|
PRODUCT_STATES.map(code => ({ value: code, label: t(`admin.products.state.${code}`) })),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Retour vers la consultation du produit (fleche d'en-tete). */
|
||||||
|
function goBack(): void {
|
||||||
|
router.push(`/admin/products/${productId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soumet la modification (PATCH). Au succes : on RESTE sur l'ecran d'edition
|
||||||
|
* (l'utilisateur garde la main, calque client/fournisseur) — le toast de succes et
|
||||||
|
* la reaffichage des valeurs normalisees sont geres par `submit()`. La navigation
|
||||||
|
* reste manuelle (fleche retour -> consultation).
|
||||||
|
*/
|
||||||
|
async function onSubmit(): Promise<void> {
|
||||||
|
await submit()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Referentiels (selects) + detail du produit charges en parallele.
|
||||||
|
await Promise.all([
|
||||||
|
loadReferentials().catch(() => {}),
|
||||||
|
load(),
|
||||||
|
])
|
||||||
|
// Pre-remplissage une fois le produit charge (echec de chargement => message).
|
||||||
|
if (product.value) {
|
||||||
|
await prefill(product.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- En-tete : retour catalogue + nom du produit + action « Modifier ». -->
|
||||||
|
<div class="flex items-center gap-3 pt-11">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
:title="t('admin.products.consultation.back')"
|
||||||
|
v-bind="{ ariaLabel: t('admin.products.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="canManage"
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:pencil-outline"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('admin.products.action.edit')"
|
||||||
|
@click="goEdit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Etats de chargement / introuvable. -->
|
||||||
|
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('admin.products.consultation.loading') }}</p>
|
||||||
|
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('admin.products.consultation.notFound') }}</p>
|
||||||
|
|
||||||
|
<template v-else-if="product">
|
||||||
|
<!-- ── Bloc principal (lecture seule) — meme disposition que l'ajout/edition.
|
||||||
|
Champs non remplis masques (ERP-193, isFilled). -->
|
||||||
|
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText
|
||||||
|
v-if="isFilled(statesLabel)"
|
||||||
|
:model-value="statesLabel"
|
||||||
|
:label="t('admin.products.form.states')"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-if="isFilled(sitesLabel)"
|
||||||
|
:model-value="sitesLabel"
|
||||||
|
:label="t('admin.products.form.sites')"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-if="isFilled(product.name)"
|
||||||
|
:model-value="product.name"
|
||||||
|
:label="t('admin.products.form.name')"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-if="isFilled(product.code)"
|
||||||
|
:model-value="product.code"
|
||||||
|
:label="t('admin.products.form.code')"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-if="isFilled(categoryLabel)"
|
||||||
|
:model-value="categoryLabel"
|
||||||
|
:label="t('admin.products.form.category')"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-if="isFilled(storageTypesLabel)"
|
||||||
|
:model-value="storageTypesLabel"
|
||||||
|
:label="t('admin.products.form.storageTypes')"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<!-- RG-6.03 : « Fabriqué » / « Contient de la mélasse » affiches
|
||||||
|
uniquement si l'etat contient « Vendu » ET la case est cochee. -->
|
||||||
|
<div v-if="isSale && isFilled(product.manufactured)" class="flex h-12 items-center">
|
||||||
|
<MalioCheckbox
|
||||||
|
id="product-view-manufactured"
|
||||||
|
:label="t('admin.products.form.manufactured')"
|
||||||
|
:model-value="product.manufactured"
|
||||||
|
disabled
|
||||||
|
:reserve-message-space="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="isSale && isFilled(product.containsMolasses)" class="flex h-12 items-center">
|
||||||
|
<MalioCheckbox
|
||||||
|
id="product-view-molasses"
|
||||||
|
:label="t('admin.products.form.containsMolasses')"
|
||||||
|
:model-value="product.containsMolasses"
|
||||||
|
disabled
|
||||||
|
:reserve-message-space="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pas d'onglet en consultation (ERP-193) : on masque les onglets vides.
|
||||||
|
Les onglets Fournisseurs / Clients sont des coquilles non implementees
|
||||||
|
(placeholder, module Contrat inexistant, HP-M6-01) => aucune donnee a
|
||||||
|
afficher, donc rien n'est rendu ici. Ils restent visibles a l'edition
|
||||||
|
(preview + regle d'etat). Quand le module Contrat existera, ce bloc
|
||||||
|
affichera les onglets effectivement remplis (calque client/fournisseur). -->
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useProduct } from '~/modules/catalog/composables/useProduct'
|
||||||
|
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
// Gating de la route : la consultation est reservee a `view` (catalogue admin-only).
|
||||||
|
if (!can('catalog.products.view')) {
|
||||||
|
await navigateTo('/admin/products')
|
||||||
|
}
|
||||||
|
|
||||||
|
const productId = route.params.id as string
|
||||||
|
|
||||||
|
const { product, loading, error, load } = useProduct(productId)
|
||||||
|
|
||||||
|
// L'edition est reservee a `manage` ; le bouton « Modifier » suit cette permission.
|
||||||
|
const canManage = computed(() => can('catalog.products.manage'))
|
||||||
|
|
||||||
|
const headerTitle = computed(() => product.value?.name ?? t('admin.products.consultation.title'))
|
||||||
|
|
||||||
|
useHead({ title: t('admin.products.consultation.title') })
|
||||||
|
|
||||||
|
// RG-6.03 : « Vendu » conditionne l'affichage des booleens fabriqué / mélasse.
|
||||||
|
const isSale = computed(() => product.value?.states.includes('SALE') ?? false)
|
||||||
|
|
||||||
|
// ── Libelles lecture seule (relations embarquees mappees en texte) ───────────
|
||||||
|
const statesLabel = computed(() =>
|
||||||
|
(product.value?.states ?? []).map(code => t(`admin.products.state.${code}`)).join(', '),
|
||||||
|
)
|
||||||
|
const sitesLabel = computed(() =>
|
||||||
|
(product.value?.sites ?? []).map(site => site.name).join(', '),
|
||||||
|
)
|
||||||
|
const categoryLabel = computed(() => product.value?.category?.name ?? '')
|
||||||
|
const storageTypesLabel = computed(() =>
|
||||||
|
(product.value?.storageTypes ?? []).map(type => type.label).join(', '),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Retour vers le catalogue produit (fleche d'en-tete). */
|
||||||
|
function goBack(): void {
|
||||||
|
router.push('/admin/products')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bascule vers l'ecran de modification. */
|
||||||
|
function goEdit(): void {
|
||||||
|
router.push(`/admin/products/${productId}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PageHeader>
|
||||||
|
{{ t('admin.products.title') }}
|
||||||
|
<template #actions>
|
||||||
|
<!-- gap-8 = 32px d'espacement entre Filtrer et Ajouter (meme
|
||||||
|
design que le Repertoire transporteurs / la Gestion des categories). -->
|
||||||
|
<div class="flex items-center gap-8">
|
||||||
|
<!-- Bouton Filtrer a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
|
||||||
|
<MalioButton
|
||||||
|
v-if="canView"
|
||||||
|
variant="tertiary"
|
||||||
|
:label="filterButtonLabel"
|
||||||
|
icon-name="mdi:tune"
|
||||||
|
icon-position="left"
|
||||||
|
icon-size="24"
|
||||||
|
@click="openFilters"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
v-if="canManage"
|
||||||
|
variant="secondary"
|
||||||
|
:label="t('admin.products.add')"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
@click="goToCreate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<!-- Datatable branchee sur usePaginatedList : pagination serveur, tri
|
||||||
|
name ASC par defaut (cote back, § 4.1). Colonnes Nom / Numero /
|
||||||
|
Categorie (docx p.3). -->
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="rows"
|
||||||
|
:total-items="totalItems"
|
||||||
|
:page="currentPage"
|
||||||
|
:per-page="itemsPerPage"
|
||||||
|
:per-page-options="itemsPerPageOptions"
|
||||||
|
row-clickable
|
||||||
|
:empty-message="t('admin.products.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('admin.products.export')"
|
||||||
|
:disabled="exporting"
|
||||||
|
@click="exportXlsx"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
|
||||||
|
« Voir les résultats ». Meme pattern que les repertoires M1→M5.
|
||||||
|
Etat 100 % local, jamais dans l'URL (regle ABSOLUE n°6). -->
|
||||||
|
<MalioDrawer
|
||||||
|
v-model="filterDrawerOpen"
|
||||||
|
drawer-class="max-w-[450px]"
|
||||||
|
body-class="p-0"
|
||||||
|
footer-class="justify-between border-t border-black p-6"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold uppercase">{{ t('admin.products.filters.title') }}</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<MalioAccordion>
|
||||||
|
<!-- Recherche : code + nom (param `search`, partiel insensible a la casse). -->
|
||||||
|
<MalioAccordionItem :title="t('admin.products.filters.search')" value="search">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="draftSearch"
|
||||||
|
icon-name="mdi:magnify"
|
||||||
|
/>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<!-- Categorie : select simple (param `categoryId`). Referentiel borne
|
||||||
|
aux categories de type PRODUIT (RG-6.05). -->
|
||||||
|
<MalioAccordionItem :title="t('admin.products.filters.category')" value="category">
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="draftCategoryId"
|
||||||
|
:options="categoryOptions"
|
||||||
|
:empty-option-label="t('admin.products.filters.categoryAll')"
|
||||||
|
@update:model-value="(v: string | number | null) => draftCategoryId = v === null || v === '' ? null : Number(v)"
|
||||||
|
/>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<!-- Etat : select simple (param `state`, enum PURCHASE / SALE / OTHER). -->
|
||||||
|
<MalioAccordionItem :title="t('admin.products.filters.state')" value="state">
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="draftState"
|
||||||
|
:options="stateOptions"
|
||||||
|
:empty-option-label="t('admin.products.filters.stateAll')"
|
||||||
|
@update:model-value="(v: string | number | null) => draftState = v === null || v === '' ? null : String(v)"
|
||||||
|
/>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<!-- Site(s) : cases a cocher (multi, param `siteId[]`). Un produit
|
||||||
|
remonte s'il est disponible sur AU MOINS UN des sites coches. -->
|
||||||
|
<MalioAccordionItem :title="t('admin.products.filters.site')" value="site">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<MalioCheckbox
|
||||||
|
v-for="opt in siteOptions"
|
||||||
|
:id="`filter-site-${opt.value}`"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:model-value="draftSiteIds.includes(opt.value)"
|
||||||
|
@update:model-value="(val: boolean) => toggleSite(opt.value, val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
</MalioAccordion>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
:label="t('admin.products.filters.reset')"
|
||||||
|
button-class="w-m-btn-action"
|
||||||
|
@click="resetFilters"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('admin.products.filters.apply')"
|
||||||
|
button-class="w-[170px]"
|
||||||
|
@click="applyFilters"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioDrawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import type { Product } from '~/modules/catalog/types/product'
|
||||||
|
|
||||||
|
interface FilterOption {
|
||||||
|
value: number
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const api = useApi()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
useHead({ title: t('admin.products.title') })
|
||||||
|
|
||||||
|
// Catalogue produit admin-only (docx p.3) : « + Ajouter » reserve a `manage`.
|
||||||
|
// « Filtrer » / « Exporter » suivent `view` (gate page-level). L'item sidebar
|
||||||
|
// est deja masque cote back pour les roles sans `view` (RBAC § 5.2).
|
||||||
|
const canManage = computed(() => can('catalog.products.manage'))
|
||||||
|
const canView = computed(() => can('catalog.products.view'))
|
||||||
|
|
||||||
|
// Pagination serveur via le composable partage. Le ProductProvider applique
|
||||||
|
// deja name ASC (§ 4.1) — pas de defaultSort cote front tant qu'aucun
|
||||||
|
// OrderFilter n'est expose.
|
||||||
|
const {
|
||||||
|
items: products,
|
||||||
|
totalItems,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
fetch: loadProducts,
|
||||||
|
goToPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
setFilters,
|
||||||
|
} = usePaginatedList<Product>({ url: '/products' })
|
||||||
|
|
||||||
|
// Mappe les produits en objets « plats » pour MalioDataTable (items typees
|
||||||
|
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
|
||||||
|
// implicite, contrairement a l'interface Product. Meme pattern que M1→M5.
|
||||||
|
const rows = computed(() => products.value.map(product => ({
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
code: product.code,
|
||||||
|
categoryName: product.category?.name ?? '',
|
||||||
|
})))
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'name', label: t('admin.products.column.name') },
|
||||||
|
{ key: 'code', label: t('admin.products.column.code') },
|
||||||
|
{ key: 'categoryName', label: t('admin.products.column.category') },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Clic sur une ligne → ecran de consultation (lecture seule) /admin/products/{id}. */
|
||||||
|
function onRowClick(item: Record<string, unknown>): void {
|
||||||
|
router.push(`/admin/products/${item.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToCreate(): void {
|
||||||
|
router.push('/admin/products/new')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Referentiels des filtres ─────────────────────────────────────────────────
|
||||||
|
// Charges une fois (pagination desactivee, referentiels bornes). Categories
|
||||||
|
// filtrees au type PRODUIT (RG-6.05) ; sites = tous les sites actifs.
|
||||||
|
const categoryOptions = ref<FilterOption[]>([])
|
||||||
|
const siteOptions = ref<FilterOption[]>([])
|
||||||
|
|
||||||
|
// Etats produit (miroir de l'enum back Product::STATE_*). Le libelle est resolu
|
||||||
|
// par i18n. Select simple cote filtre (`?state=` n'accepte qu'une valeur).
|
||||||
|
const PRODUCT_STATES = ['PURCHASE', 'SALE', 'OTHER'] as const
|
||||||
|
|
||||||
|
const stateOptions = computed(() =>
|
||||||
|
PRODUCT_STATES.map(code => ({ value: code, label: t(`admin.products.state.${code}`) })),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface HydraMember { '@id': string, id: number, name?: string, postalCode?: string }
|
||||||
|
|
||||||
|
/** Recupere une collection complete (pagination desactivee) en Hydra. */
|
||||||
|
async function fetchAll<T extends HydraMember>(
|
||||||
|
url: string,
|
||||||
|
query: Record<string, string> = {},
|
||||||
|
): Promise<T[]> {
|
||||||
|
const res = await api.get<{ member?: T[] }>(
|
||||||
|
url,
|
||||||
|
{ pagination: 'false', ...query },
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
return res.member ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge les referentiels des filtres en parallele et de maniere resiliente :
|
||||||
|
* un referentiel en echec (403/500) reste vide sans casser l'autre.
|
||||||
|
*/
|
||||||
|
async function loadFilterReferentials(): Promise<void> {
|
||||||
|
await Promise.allSettled([
|
||||||
|
fetchAll('/categories', { typeCode: 'PRODUIT' })
|
||||||
|
.then((cats) => { categoryOptions.value = cats.map(c => ({ value: c.id, label: c.name ?? '' })) }),
|
||||||
|
fetchAll('/sites')
|
||||||
|
.then((sitesList) => { siteOptions.value = sitesList.map(s => ({ value: s.id, label: s.name ?? '' })) }),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filtres (drawer) ─────────────────────────────────────────────────────────
|
||||||
|
// Deux niveaux d'etat (pattern repertoires M1→M5) :
|
||||||
|
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
|
||||||
|
// uniquement au clic « Voir les résultats » / « Réinitialiser ».
|
||||||
|
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
|
||||||
|
const filterDrawerOpen = ref(false)
|
||||||
|
|
||||||
|
const draftSearch = ref('')
|
||||||
|
const draftCategoryId = ref<number | null>(null)
|
||||||
|
const draftState = ref<string | null>(null)
|
||||||
|
const draftSiteIds = ref<number[]>([])
|
||||||
|
|
||||||
|
const appliedSearch = ref('')
|
||||||
|
const appliedCategoryId = ref<number | null>(null)
|
||||||
|
const appliedState = ref<string | null>(null)
|
||||||
|
const appliedSiteIds = ref<number[]>([])
|
||||||
|
|
||||||
|
const activeFilterCount = computed(() => {
|
||||||
|
let count = 0
|
||||||
|
if (appliedSearch.value.trim() !== '') count++
|
||||||
|
if (appliedCategoryId.value !== null) count++
|
||||||
|
if (appliedState.value !== null) count++
|
||||||
|
if (appliedSiteIds.value.length > 0) count++
|
||||||
|
return count
|
||||||
|
})
|
||||||
|
|
||||||
|
const filterButtonLabel = computed(() => {
|
||||||
|
const base = t('admin.products.filters.title')
|
||||||
|
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
|
||||||
|
})
|
||||||
|
|
||||||
|
// Recopie l'etat applique vers le brouillon puis ouvre le drawer : la reouverture
|
||||||
|
// reflete les filtres actifs.
|
||||||
|
function openFilters(): void {
|
||||||
|
draftSearch.value = appliedSearch.value
|
||||||
|
draftCategoryId.value = appliedCategoryId.value
|
||||||
|
draftState.value = appliedState.value
|
||||||
|
draftSiteIds.value = [...appliedSiteIds.value]
|
||||||
|
filterDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coche / decoche un site dans le brouillon (filtre multi). */
|
||||||
|
function toggleSite(id: number, selected: boolean): void {
|
||||||
|
draftSiteIds.value = selected
|
||||||
|
? [...draftSiteIds.value, id]
|
||||||
|
: draftSiteIds.value.filter(s => s !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le payload de filtres serveur a partir de l'etat applique. Cle
|
||||||
|
* `siteId[]` pour que PHP la parse en tableau (OR cote back). Les filtres vides
|
||||||
|
* sont omis pour une query propre.
|
||||||
|
*/
|
||||||
|
function buildFilterPayload(): Record<string, string | string[]> {
|
||||||
|
const payload: Record<string, string | string[]> = {}
|
||||||
|
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
||||||
|
if (appliedCategoryId.value !== null) payload.categoryId = String(appliedCategoryId.value)
|
||||||
|
if (appliedState.value !== null) payload.state = appliedState.value
|
||||||
|
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = appliedSiteIds.value.map(String)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// « Voir les résultats » : recopie brouillon → applied, pousse les filtres
|
||||||
|
// (retombe en page 1 via usePaginatedList) et ferme le drawer.
|
||||||
|
function applyFilters(): void {
|
||||||
|
appliedSearch.value = draftSearch.value.trim()
|
||||||
|
appliedCategoryId.value = draftCategoryId.value
|
||||||
|
appliedState.value = draftState.value
|
||||||
|
appliedSiteIds.value = [...draftSiteIds.value]
|
||||||
|
|
||||||
|
setFilters(buildFilterPayload(), { replace: true })
|
||||||
|
filterDrawerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
|
||||||
|
// Le drawer reste ouvert pour montrer le formulaire vide.
|
||||||
|
function resetFilters(): void {
|
||||||
|
draftSearch.value = ''
|
||||||
|
draftCategoryId.value = null
|
||||||
|
draftState.value = null
|
||||||
|
draftSiteIds.value = []
|
||||||
|
|
||||||
|
appliedSearch.value = ''
|
||||||
|
appliedCategoryId.value = null
|
||||||
|
appliedState.value = null
|
||||||
|
appliedSiteIds.value = []
|
||||||
|
|
||||||
|
setFilters({}, { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export XLSX ──────────────────────────────────────────────────────────────
|
||||||
|
// Memes filtres que la vue : l'export reflete exactement ce que l'utilisateur voit.
|
||||||
|
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→M5).
|
||||||
|
const blob = await api.get<Blob>('/products/export.xlsx', buildFilterPayload(), {
|
||||||
|
responseType: 'blob',
|
||||||
|
toast: false,
|
||||||
|
} as unknown as Parameters<typeof api.get>[2])
|
||||||
|
|
||||||
|
triggerDownload(blob, 'catalogue-produits.xlsx')
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
toast.error({
|
||||||
|
title: t('admin.products.toast.error'),
|
||||||
|
message: t('admin.products.toast.exportError'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Declenche le telechargement d'un blob via un lien temporaire. */
|
||||||
|
function triggerDownload(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadProducts()
|
||||||
|
loadFilterReferentials()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- En-tete : retour vers le catalogue + titre. -->
|
||||||
|
<div class="flex items-center gap-3 pt-11">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
:title="t('admin.products.form.back')"
|
||||||
|
v-bind="{ ariaLabel: t('admin.products.form.back') }"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
|
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('admin.products.form.title') }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Formulaire principal de creation ───────────────────────────────
|
||||||
|
Bouton « Valider » TOUJOURS actif (ERP-101) : la validation
|
||||||
|
autoritaire est serveur, les erreurs 422 reviennent inline. -->
|
||||||
|
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<!-- Etat du produit : multi-select obligatoire (>= 1, RG-6.02). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="form.states"
|
||||||
|
:options="stateOptions"
|
||||||
|
:label="t('admin.products.form.states')"
|
||||||
|
:display-tag="true"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.states"
|
||||||
|
@update:model-value="(v: (string | number)[]) => setStates(v.map(String))"
|
||||||
|
/>
|
||||||
|
<!-- Sites de disponibilite : multi-select obligatoire (>= 1, RG-6.04). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="form.siteIris"
|
||||||
|
:options="siteOptions"
|
||||||
|
:label="t('admin.products.form.sites')"
|
||||||
|
:display-tag="true"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.sites"
|
||||||
|
@update:model-value="(v: (string | number)[]) => setSites(v.map(String))"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.name"
|
||||||
|
:mask="FREE_TEXT_MASK"
|
||||||
|
:label="t('admin.products.form.name')"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.name"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.code"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
|
:label="t('admin.products.form.code')"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.code"
|
||||||
|
/>
|
||||||
|
<!-- Categorie produit : select simple obligatoire, filtre type PRODUIT (RG-6.05). -->
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="form.categoryIri"
|
||||||
|
:options="categoryOptions"
|
||||||
|
:label="t('admin.products.form.category')"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:error="errors.category"
|
||||||
|
@update:model-value="(v: string | number | null) => setCategory(v === null || v === '' ? null : String(v))"
|
||||||
|
/>
|
||||||
|
<!-- Type de stockage : multi-select obligatoire (>= 1). Referentiel plat :
|
||||||
|
tous les types (plus de filtrage par site, RG-6.06). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="form.storageTypeIris"
|
||||||
|
:options="storageTypeOptions"
|
||||||
|
:label="t('admin.products.form.storageTypes')"
|
||||||
|
:display-tag="true"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.storageTypes"
|
||||||
|
@update:model-value="(v: (string | number)[]) => setStorageTypes(v.map(String))"
|
||||||
|
/>
|
||||||
|
<!-- RG-6.03 : « Fabriqué » + « Contient de la mélasse » visibles
|
||||||
|
uniquement si l'Etat contient « Vendu ». -->
|
||||||
|
<MalioCheckbox
|
||||||
|
v-if="isSale"
|
||||||
|
v-model="form.manufactured"
|
||||||
|
:label="t('admin.products.form.manufactured')"
|
||||||
|
group-class="self-center"
|
||||||
|
/>
|
||||||
|
<MalioCheckbox
|
||||||
|
v-if="isSale"
|
||||||
|
v-model="form.containsMolasses"
|
||||||
|
:label="t('admin.products.form.containsMolasses')"
|
||||||
|
group-class="self-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-12 flex justify-center">
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('admin.products.form.submit')"
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="onSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Onglets Fournisseurs / Clients (placeholder, HP-M6-01) : NON affiches a
|
||||||
|
l'ajout. Ils n'apparaissent qu'apres validation du formulaire principal
|
||||||
|
(ecran de modification), une fois le produit cree. -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useProductForm, PRODUCT_STATES } from '~/modules/catalog/composables/useProductForm'
|
||||||
|
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
useHead({ title: t('admin.products.form.title') })
|
||||||
|
|
||||||
|
// Gating de la route : la creation est reservee a `manage` (catalogue admin-only).
|
||||||
|
if (!can('catalog.products.manage')) {
|
||||||
|
await navigateTo('/admin/products')
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
form,
|
||||||
|
errors,
|
||||||
|
submitting,
|
||||||
|
isSale,
|
||||||
|
siteOptions,
|
||||||
|
categoryOptions,
|
||||||
|
storageTypeOptions,
|
||||||
|
setStates,
|
||||||
|
setCategory,
|
||||||
|
setStorageTypes,
|
||||||
|
setSites,
|
||||||
|
loadReferentials,
|
||||||
|
submit,
|
||||||
|
} = useProductForm()
|
||||||
|
|
||||||
|
// Options de l'etat : libelles i18n (la valeur d'option = code enum).
|
||||||
|
const stateOptions = computed(() =>
|
||||||
|
PRODUCT_STATES.map(code => ({ value: code, label: t(`admin.products.state.${code}`) })),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Retour vers le catalogue produit (fleche d'en-tete). */
|
||||||
|
function goBack(): void {
|
||||||
|
router.push('/admin/products')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soumet la creation ; au succes, retour a la liste. */
|
||||||
|
async function onSubmit(): Promise<void> {
|
||||||
|
const ok = await submit()
|
||||||
|
if (ok) {
|
||||||
|
router.push('/admin/products')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Echec du chargement des referentiels non bloquant : les selects restent vides.
|
||||||
|
loadReferentials().catch(() => {})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Types front du module Catalog (M6 — Catalogue produit).
|
||||||
|
*
|
||||||
|
* Contrats API consommes :
|
||||||
|
* - GET /api/products → HydraCollection<Product>
|
||||||
|
* - GET /api/products/{id} → Product
|
||||||
|
* - GET /api/products/export.xlsx → binaire XLSX (export complet, filtres actifs)
|
||||||
|
*
|
||||||
|
* Notes (cf. spec-back § 4.0.bis, contrat JSON capture en ERP-203) :
|
||||||
|
* - `category` est embarque (objet, pas IRI) ; idem `sites` / `storageTypes`
|
||||||
|
* (tableaux d'objets bornes). On n'a besoin que de `category.name` en liste.
|
||||||
|
* - `states` est un tableau de chaines (PURCHASE / SALE / OTHER).
|
||||||
|
* - `skip_null_values` actif cote back : ne pas presumer la presence des nulls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Type de categorie embarque dans `category.categoryTypes` (RG-6.05). */
|
||||||
|
export interface ProductCategoryType {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Categorie embarquee dans un produit (lecture seule, sous-ensemble utile au front). */
|
||||||
|
export interface ProductCategory {
|
||||||
|
/** IRI Hydra, ex. `/api/categories/12` — utilise pour pre-selectionner le select en edition. */
|
||||||
|
'@id': string
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
categoryTypes?: ProductCategoryType[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Site de disponibilite embarque dans un produit (groupe `site:read`). */
|
||||||
|
export interface ProductSite {
|
||||||
|
/** IRI Hydra, ex. `/api/sites/1` — utilise pour pre-selectionner le multi-select en edition. */
|
||||||
|
'@id': string
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
postalCode: string
|
||||||
|
city: string
|
||||||
|
color: string
|
||||||
|
fullAddress: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type de stockage embarque dans un produit (referentiel borne, § 2.4). */
|
||||||
|
export interface ProductStorageType {
|
||||||
|
/** IRI Hydra, ex. `/api/storage_types/9` — utilise pour pre-selectionner le multi-select en edition. */
|
||||||
|
'@id': string
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produit metier — tel qu'il est lu depuis l'API. L'entite porte le pattern
|
||||||
|
* Timestampable+Blamable (cf. spec-back § 2.8).
|
||||||
|
*/
|
||||||
|
export interface Product {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
/** Etats : sous-ensemble de PURCHASE / SALE / OTHER (RG-6.02). */
|
||||||
|
states: string[]
|
||||||
|
manufactured: boolean
|
||||||
|
containsMolasses: boolean
|
||||||
|
category: ProductCategory | null
|
||||||
|
sites: ProductSite[]
|
||||||
|
storageTypes: ProductStorageType[]
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
@@ -1,203 +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 && !disabled"
|
<!-- 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"
|
||||||
:disabled="disabled"
|
:label="t('commercial.clients.form.address.addressType')"
|
||||||
:required="!readonly && !disabled"
|
|
||||||
: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"
|
|
||||||
: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
|
|
||||||
v-else
|
|
||||||
:model-value="model.city"
|
|
||||||
:label="t('commercial.clients.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 + 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 && !disabled"
|
|
||||||
:model-value="model.street"
|
|
||||||
:options="addressOptions"
|
|
||||||
:loading="addressLoading"
|
|
||||||
:min-search-length="3"
|
|
||||||
:label="t('commercial.clients.form.address.street')"
|
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:required="!readonly && !disabled"
|
:required="!readonly && !disabled"
|
||||||
:error="errors?.street"
|
:error="errors?.isProspect"
|
||||||
:allow-create="true"
|
@update:model-value="onAddressTypeChange"
|
||||||
: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"
|
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-1.10). -->
|
||||||
@select="onAddressSelect"
|
<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')"
|
||||||
: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"
|
:mask="ADDRESS_MASK"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:error="errors?.streetComplement"
|
:required="!readonly && !disabled"
|
||||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
:error="errors?.city"
|
||||||
|
@update:model-value="(v: string) => update('city', v)"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- 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 && !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>
|
||||||
|
|
||||||
@@ -230,6 +238,8 @@ 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). */
|
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
|||||||
@@ -1,84 +1,93 @@
|
|||||||
<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 && !disabled"
|
<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"
|
||||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
v-bind="{ ariaLabel: t('commercial.clients.form.contact.remove') }"
|
||||||
:model-value="model.lastName"
|
@click="$emit('remove')"
|
||||||
:label="t('commercial.clients.form.contact.lastName')"
|
/>
|
||||||
:mask="PERSON_NAME_MASK"
|
</div>
|
||||||
:readonly="readonly"
|
|
||||||
:disabled="disabled"
|
<!-- Grille 4 colonnes des champs du contact. -->
|
||||||
:error="errors?.lastName"
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
@update:model-value="(v: string) => update('lastName', v)"
|
<MalioInputText
|
||||||
/>
|
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||||
<MalioInputText
|
:model-value="model.lastName"
|
||||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
:label="t('commercial.clients.form.contact.lastName')"
|
||||||
:model-value="model.firstName"
|
:mask="PERSON_NAME_MASK"
|
||||||
:label="t('commercial.clients.form.contact.firstName')"
|
:readonly="readonly"
|
||||||
:mask="PERSON_NAME_MASK"
|
:disabled="disabled"
|
||||||
:readonly="readonly"
|
:error="errors?.lastName"
|
||||||
:disabled="disabled"
|
@update:model-value="(v: string) => update('lastName', v)"
|
||||||
:error="errors?.firstName"
|
/>
|
||||||
@update:model-value="(v: string) => update('firstName', v)"
|
<MalioInputText
|
||||||
/>
|
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
:model-value="model.firstName"
|
||||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
:label="t('commercial.clients.form.contact.firstName')"
|
||||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
:mask="PERSON_NAME_MASK"
|
||||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
:readonly="readonly"
|
||||||
<MalioInputText
|
:disabled="disabled"
|
||||||
:model-value="model.jobTitle"
|
:error="errors?.firstName"
|
||||||
:label="t('commercial.clients.form.contact.jobTitle')"
|
@update:model-value="(v: string) => update('firstName', v)"
|
||||||
:mask="FREE_TEXT_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?.jobTitle"
|
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
<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
|
|
||||||
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>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -98,6 +107,8 @@ 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). */
|
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||||
|
|||||||
@@ -1,189 +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 && !disabled"
|
<!-- 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"
|
||||||
:disabled="disabled"
|
:label="t('commercial.suppliers.form.address.addressType')"
|
||||||
empty-option-label=""
|
:readonly="readonly"
|
||||||
:required="!readonly && !disabled"
|
:disabled="disabled"
|
||||||
:error="errors?.addressType"
|
empty-option-label=""
|
||||||
@update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))"
|
:required="!readonly && !disabled"
|
||||||
/>
|
: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). -->
|
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-2.06). -->
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox
|
||||||
:model-value="model.siteIris"
|
:model-value="model.siteIris"
|
||||||
:options="siteOptions"
|
:options="siteOptions"
|
||||||
:label="t('commercial.suppliers.form.address.sites')"
|
:label="t('commercial.suppliers.form.address.sites')"
|
||||||
:display-tag="true"
|
: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
|
|
||||||
v-else
|
|
||||||
:model-value="model.city"
|
|
||||||
:label="t('commercial.suppliers.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 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"
|
:readonly="readonly"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:required="!readonly && !disabled"
|
:required="!readonly && !disabled"
|
||||||
:error="errors?.street"
|
:error="errors?.sites"
|
||||||
:allow-create="true"
|
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||||
: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"
|
<!-- Contacts rattaches (M2M, facultatif). -->
|
||||||
@select="onAddressSelect"
|
<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"
|
:mask="ADDRESS_MASK"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:required="!readonly && !disabled"
|
:required="!readonly && !disabled"
|
||||||
:error="errors?.street"
|
:error="errors?.city"
|
||||||
@update:model-value="(v: string) => update('street', v)"
|
@update:model-value="(v: string) => update('city', v)"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
|
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
|
||||||
<MalioInputText
|
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
||||||
:model-value="model.streetComplement"
|
<div class="col-span-2">
|
||||||
:label="t('commercial.suppliers.form.address.streetComplement')"
|
<MalioInputAutocomplete
|
||||||
:mask="ADDRESS_MASK"
|
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"
|
:readonly="readonly"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:error="errors?.streetComplement"
|
:error="errors?.bennes"
|
||||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
@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>
|
||||||
|
|
||||||
<!-- 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>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -210,6 +219,8 @@ 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). */
|
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
|||||||
@@ -1,83 +1,92 @@
|
|||||||
<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 && !disabled"
|
<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"
|
||||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
v-bind="{ ariaLabel: t('commercial.suppliers.form.contact.remove') }"
|
||||||
:model-value="model.lastName"
|
@click="$emit('remove')"
|
||||||
:label="t('commercial.suppliers.form.contact.lastName')"
|
/>
|
||||||
:mask="PERSON_NAME_MASK"
|
</div>
|
||||||
:readonly="readonly"
|
|
||||||
:disabled="disabled"
|
<!-- Grille 4 colonnes des champs du contact. -->
|
||||||
:error="errors?.lastName"
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
@update:model-value="(v: string) => update('lastName', v)"
|
<MalioInputText
|
||||||
/>
|
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||||
<MalioInputText
|
:model-value="model.lastName"
|
||||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
:label="t('commercial.suppliers.form.contact.lastName')"
|
||||||
:model-value="model.firstName"
|
:mask="PERSON_NAME_MASK"
|
||||||
:label="t('commercial.suppliers.form.contact.firstName')"
|
:readonly="readonly"
|
||||||
:mask="PERSON_NAME_MASK"
|
:disabled="disabled"
|
||||||
:readonly="readonly"
|
:error="errors?.lastName"
|
||||||
:disabled="disabled"
|
@update:model-value="(v: string) => update('lastName', v)"
|
||||||
:error="errors?.firstName"
|
/>
|
||||||
@update:model-value="(v: string) => update('firstName', v)"
|
<MalioInputText
|
||||||
/>
|
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
:model-value="model.firstName"
|
||||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
:label="t('commercial.suppliers.form.contact.firstName')"
|
||||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
:mask="PERSON_NAME_MASK"
|
||||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
:readonly="readonly"
|
||||||
<MalioInputText
|
:disabled="disabled"
|
||||||
:model-value="model.jobTitle"
|
:error="errors?.firstName"
|
||||||
:label="t('commercial.suppliers.form.contact.jobTitle')"
|
@update:model-value="(v: string) => update('firstName', v)"
|
||||||
:mask="FREE_TEXT_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?.jobTitle"
|
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
<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
|
|
||||||
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>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -96,6 +105,8 @@ 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). */
|
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||||
|
|||||||
@@ -77,4 +77,23 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
|
|||||||
// Le libelle d'un site est son numero de departement (2 premiers chiffres du code postal).
|
// Le libelle d'un site est son numero de departement (2 premiers chiffres du code postal).
|
||||||
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
|
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('separe les categories CLIENT (formulaire) des categories ADRESSE (blocs adresse)', async () => {
|
||||||
|
// Le mock distingue les deux appels /categories par leur filtre typeCode.
|
||||||
|
mockGet.mockImplementation((url: string, query?: Record<string, unknown>) => {
|
||||||
|
if (url === '/categories' && query?.typeCode === 'CLIENT') {
|
||||||
|
return Promise.resolve({ member: [{ '@id': '/api/categories/1', code: 'SECTEUR', name: 'Secteur' }] })
|
||||||
|
}
|
||||||
|
if (url === '/categories' && query?.typeCode === 'ADRESSE') {
|
||||||
|
return Promise.resolve({ member: [{ '@id': '/api/categories/9', code: 'SIEGE', name: 'Siège' }] })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ member: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
const refs = useClientReferentials()
|
||||||
|
await refs.loadCommon()
|
||||||
|
|
||||||
|
expect(refs.categories.value).toEqual([{ value: '/api/categories/1', label: 'Secteur', code: 'SECTEUR' }])
|
||||||
|
expect(refs.addressCategories.value).toEqual([{ value: '/api/categories/9', label: 'Siège', code: 'SIEGE' }])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ describe('useSupplierReferentials', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('charge les categories d\'adresse filtrees sur le type ADRESSE', async () => {
|
||||||
|
await useSupplierReferentials().loadCommon()
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith(
|
||||||
|
'/categories',
|
||||||
|
expect.objectContaining({ pagination: 'false', typeCode: 'ADRESSE' }),
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('mappe les categories en options { value: IRI, label: name, code }', async () => {
|
it('mappe les categories en options { value: IRI, label: name, code }', async () => {
|
||||||
mockGet.mockImplementation((url: string) => {
|
mockGet.mockImplementation((url: string) => {
|
||||||
if (url === '/categories') {
|
if (url === '/categories') {
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ export function useClientReferentials() {
|
|||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
const categories = ref<CategoryOption[]>([])
|
const categories = ref<CategoryOption[]>([])
|
||||||
|
// Taxonomie dediee aux blocs adresse (type ADRESSE), distincte des categories
|
||||||
|
// CLIENT du formulaire principal.
|
||||||
|
const addressCategories = ref<CategoryOption[]>([])
|
||||||
const sites = ref<RefOption[]>([])
|
const sites = ref<RefOption[]>([])
|
||||||
const tvaModes = ref<RefOption[]>([])
|
const tvaModes = ref<RefOption[]>([])
|
||||||
const paymentDelays = ref<RefOption[]>([])
|
const paymentDelays = ref<RefOption[]>([])
|
||||||
@@ -109,6 +112,9 @@ export function useClientReferentials() {
|
|||||||
// de type CLIENT (pas FOURNISSEUR) -> on filtre la collection cote API.
|
// de type CLIENT (pas FOURNISSEUR) -> on filtre la collection cote API.
|
||||||
fetchAll<CategoryMember>('/categories', { typeCode: 'CLIENT' })
|
fetchAll<CategoryMember>('/categories', { typeCode: 'CLIENT' })
|
||||||
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
|
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
|
||||||
|
// Categories des blocs adresse : taxonomie dediee type ADRESSE.
|
||||||
|
fetchAll<CategoryMember>('/categories', { typeCode: 'ADRESSE' })
|
||||||
|
.then((cats) => { addressCategories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
|
||||||
fetchAll<SiteMember>('/sites')
|
fetchAll<SiteMember>('/sites')
|
||||||
// Libelle = numero de departement (2 premiers chiffres du code
|
// Libelle = numero de departement (2 premiers chiffres du code
|
||||||
// postal du site), ex: 86100 -> « 86 ». Le code postal est deja
|
// postal du site), ex: 86100 -> « 86 ». Le code postal est deja
|
||||||
@@ -151,6 +157,7 @@ export function useClientReferentials() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
categories,
|
categories,
|
||||||
|
addressCategories,
|
||||||
sites,
|
sites,
|
||||||
tvaModes,
|
tvaModes,
|
||||||
paymentDelays,
|
paymentDelays,
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export function useSupplierReferentials() {
|
|||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
const categories = ref<CategoryOption[]>([])
|
const categories = ref<CategoryOption[]>([])
|
||||||
|
// Taxonomie dediee aux blocs adresse (type ADRESSE), distincte des categories
|
||||||
|
// FOURNISSEUR du formulaire principal.
|
||||||
|
const addressCategories = ref<CategoryOption[]>([])
|
||||||
const sites = ref<RefOption[]>([])
|
const sites = ref<RefOption[]>([])
|
||||||
const tvaModes = ref<RefOption[]>([])
|
const tvaModes = ref<RefOption[]>([])
|
||||||
const paymentDelays = ref<RefOption[]>([])
|
const paymentDelays = ref<RefOption[]>([])
|
||||||
@@ -97,6 +100,9 @@ export function useSupplierReferentials() {
|
|||||||
// categories de type FOURNISSEUR (RG-2.10) -> on filtre cote API.
|
// categories de type FOURNISSEUR (RG-2.10) -> on filtre cote API.
|
||||||
fetchAll<CategoryMember>('/categories', { typeCode: 'FOURNISSEUR' })
|
fetchAll<CategoryMember>('/categories', { typeCode: 'FOURNISSEUR' })
|
||||||
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
|
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
|
||||||
|
// Categories des blocs adresse : taxonomie dediee type ADRESSE.
|
||||||
|
fetchAll<CategoryMember>('/categories', { typeCode: 'ADRESSE' })
|
||||||
|
.then((cats) => { addressCategories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
|
||||||
fetchAll<SiteMember>('/sites')
|
fetchAll<SiteMember>('/sites')
|
||||||
// Libelle = numero de departement (2 premiers chiffres du code
|
// Libelle = numero de departement (2 premiers chiffres du code
|
||||||
// postal du site), ex: 86100 -> « 86 ».
|
// postal du site), ex: 86100 -> « 86 ».
|
||||||
@@ -121,6 +127,7 @@ export function useSupplierReferentials() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
categories,
|
categories,
|
||||||
|
addressCategories,
|
||||||
sites,
|
sites,
|
||||||
tvaModes,
|
tvaModes,
|
||||||
paymentDelays,
|
paymentDelays,
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
<MalioTabList 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">
|
||||||
<!-- 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 -> ~4px de
|
sur les inputs (champ 40px centre dans un h-12 -> ~4px de
|
||||||
coussin de chaque cote). -->
|
coussin de chaque cote). -->
|
||||||
@@ -178,6 +178,7 @@
|
|||||||
: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="isRowRemovable(contacts, index)"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
|
:last="index === contacts.length - 1"
|
||||||
:disabled="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -210,6 +211,7 @@
|
|||||||
: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"
|
||||||
@@ -244,8 +246,10 @@
|
|||||||
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')"
|
||||||
@@ -314,21 +318,27 @@
|
|||||||
</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 && isRowRemovable(visibleRibs, index)"
|
<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')"
|
||||||
@@ -469,9 +479,6 @@ import { readHistoryTab } from '~/shared/utils/historyTab'
|
|||||||
const SIREN_MASK = '#########'
|
const SIREN_MASK = '#########'
|
||||||
const EMPLOYEES_MASK = '#######'
|
const EMPLOYEES_MASK = '#######'
|
||||||
|
|
||||||
// Codes de categorie interdits sur une adresse (RG-1.29, ERP-78).
|
|
||||||
const FORBIDDEN_ADDRESS_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -563,15 +570,17 @@ function mergeOptions<T extends { value: string }>(primary: T[], extra: T[]): T[
|
|||||||
return [...primary, ...extra.filter(o => !seen.has(o.value))]
|
return [...primary, ...extra.filter(o => !seen.has(o.value))]
|
||||||
}
|
}
|
||||||
|
|
||||||
const embedCategoryOptions = computed<CategoryOption[]>(() => {
|
// Categories du formulaire principal (type CLIENT) : referentiel UNION categories
|
||||||
const fromClient = categoryOptionsOf(client.value?.categories)
|
// embarquees du client (fallback si le referentiel n'est pas chargeable).
|
||||||
const fromAddresses = (client.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))
|
const embedClientCategoryOptions = computed<CategoryOption[]>(() => categoryOptionsOf(client.value?.categories))
|
||||||
return mergeOptions(fromClient, fromAddresses)
|
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedClientCategoryOptions.value))
|
||||||
})
|
// Categories des blocs adresse (type ADRESSE) : referentiel dedie UNION categories
|
||||||
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedCategoryOptions.value))
|
// embarquees des adresses (fallback meme fonction qu'au-dessus).
|
||||||
// Categories autorisees sur une adresse : toutes SAUF DISTRIBUTEUR/COURTIER (RG-1.29).
|
const embedAddressCategoryOptions = computed<CategoryOption[]>(() =>
|
||||||
|
mergeOptions([], (client.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))),
|
||||||
|
)
|
||||||
const addressCategoryOptions = computed(() =>
|
const addressCategoryOptions = computed(() =>
|
||||||
mainCategoryOptions.value.filter(c => !FORBIDDEN_ADDRESS_CATEGORY_CODES.includes(c.code)),
|
mergeOptions(referentials.addressCategories.value, embedAddressCategoryOptions.value),
|
||||||
)
|
)
|
||||||
|
|
||||||
const embedSiteOptions = computed<RefOption[]>(() =>
|
const embedSiteOptions = computed<RefOption[]>(() =>
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
<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">
|
||||||
<!-- 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 -> ~4px de
|
sur les inputs (champ 40px centre dans un h-12 -> ~4px de
|
||||||
coussin de chaque cote). -->
|
coussin de chaque cote). -->
|
||||||
@@ -156,6 +156,7 @@
|
|||||||
: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 })"
|
||||||
|
:last="index === contacts.length - 1"
|
||||||
disabled
|
disabled
|
||||||
hide-empty
|
hide-empty
|
||||||
/>
|
/>
|
||||||
@@ -170,6 +171,7 @@
|
|||||||
: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"
|
||||||
@@ -183,8 +185,10 @@
|
|||||||
<!-- 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)"
|
v-if="isFilled(accounting.siren)"
|
||||||
:model-value="accounting.siren"
|
:model-value="accounting.siren"
|
||||||
@@ -239,13 +243,16 @@
|
|||||||
</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)"
|
v-if="isFilled(rib.label)"
|
||||||
:model-value="rib.label"
|
:model-value="rib.label"
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
<MalioTabList v-model="activeTab" :tabs="tabs" 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">
|
||||||
<!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs, dont
|
<!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs, dont
|
||||||
le champ de 40px est centre dans un conteneur h-12 (~4px de
|
le champ de 40px est centre dans un conteneur h-12 (~4px de
|
||||||
coussin en HAUT et en BAS). Sans pb-1, le textarea descend ~4px
|
coussin en HAUT et en BAS). Sans pb-1, le textarea descend ~4px
|
||||||
@@ -177,6 +177,7 @@
|
|||||||
: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="isRowRemovable(contacts, index)"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
|
:last="index === contacts.length - 1"
|
||||||
:disabled="isValidated('contact')"
|
:disabled="isValidated('contact')"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -209,6 +210,7 @@
|
|||||||
: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"
|
||||||
@@ -242,8 +244,10 @@
|
|||||||
<!-- 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')"
|
||||||
@@ -312,22 +316,28 @@
|
|||||||
</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 && isRowRemovable(visibleRibs, index)"
|
<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')"
|
||||||
@@ -446,9 +456,6 @@ const SIREN_MASK = '#########'
|
|||||||
// Masque « nombre » du champ Nombre de salaries : chiffres uniquement (max 7).
|
// Masque « nombre » du champ Nombre de salaries : chiffres uniquement (max 7).
|
||||||
const EMPLOYEES_MASK = '#######'
|
const EMPLOYEES_MASK = '#######'
|
||||||
|
|
||||||
// Codes de categorie interdits sur une adresse (RG-1.29, ERP-78).
|
|
||||||
const FORBIDDEN_ADDRESS_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -806,10 +813,8 @@ async function submitContacts(): Promise<void> {
|
|||||||
const addresses = ref<AddressFormDraft[]>([emptyAddress()])
|
const addresses = ref<AddressFormDraft[]>([emptyAddress()])
|
||||||
const addressDegradedNotified = ref(false)
|
const addressDegradedNotified = ref(false)
|
||||||
|
|
||||||
// Categories autorisees sur une adresse : toutes SAUF DISTRIBUTEUR/COURTIER (RG-1.29).
|
// Categories autorisees sur une adresse : taxonomie dediee type ADRESSE.
|
||||||
const addressCategoryOptions = computed(() =>
|
const addressCategoryOptions = computed(() => referentials.addressCategories.value)
|
||||||
referentials.categories.value.filter(c => !FORBIDDEN_ADDRESS_CATEGORY_CODES.includes(c.code)),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Contacts deja crees, rattachables a une adresse (M2M, via leur IRI).
|
// Contacts deja crees, rattachables a une adresse (M2M, via leur IRI).
|
||||||
const contactOptions = computed<RefOption[]>(() =>
|
const contactOptions = computed<RefOption[]>(() =>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
<MalioTabList 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">
|
||||||
<!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs. -->
|
<!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs. -->
|
||||||
<MalioInputTextArea
|
<MalioInputTextArea
|
||||||
v-model="information.description"
|
v-model="information.description"
|
||||||
@@ -147,6 +147,7 @@
|
|||||||
: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="isRowRemovable(contacts, index)"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
|
:last="index === contacts.length - 1"
|
||||||
:disabled="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -179,7 +180,8 @@
|
|||||||
: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 })"
|
||||||
:category-options="mainCategoryOptions"
|
:last="index === addresses.length - 1"
|
||||||
|
:category-options="addressCategoryOptions"
|
||||||
:site-options="siteOptions"
|
:site-options="siteOptions"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
@@ -213,8 +215,10 @@
|
|||||||
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')"
|
||||||
@@ -283,21 +287,27 @@
|
|||||||
</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 && isRowRemovable(visibleRibs, index)"
|
<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')"
|
||||||
@@ -526,15 +536,18 @@ function mergeOptions<T extends { value: string }>(primary: T[], extra: T[]): T[
|
|||||||
return [...primary, ...extra.filter(o => !seen.has(o.value))]
|
return [...primary, ...extra.filter(o => !seen.has(o.value))]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Categories issues de l'embed (fournisseur + adresses), role-independantes.
|
// Categories du formulaire principal (type FOURNISSEUR) : referentiel UNION
|
||||||
const embedCategoryOptions = computed<CategoryOption[]>(() => {
|
// categories embarquees du fournisseur (fallback si referentiel non chargeable).
|
||||||
const fromSupplier = categoryOptionsOf(supplier.value?.categories)
|
const embedSupplierCategoryOptions = computed<CategoryOption[]>(() => categoryOptionsOf(supplier.value?.categories))
|
||||||
const fromAddresses = (supplier.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))
|
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedSupplierCategoryOptions.value))
|
||||||
return mergeOptions(fromSupplier, fromAddresses)
|
// Categories des blocs adresse (type ADRESSE) : referentiel dedie UNION categories
|
||||||
})
|
// embarquees des adresses (meme logique de fallback).
|
||||||
// Toutes les categories de type FOURNISSEUR sont autorisees, sur le bloc principal
|
const embedAddressCategoryOptions = computed<CategoryOption[]>(() =>
|
||||||
// comme sur une adresse (pas de restriction Distributeur/Courtier comme au M1 — RG-2.10).
|
mergeOptions([], (supplier.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))),
|
||||||
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedCategoryOptions.value))
|
)
|
||||||
|
const addressCategoryOptions = computed(() =>
|
||||||
|
mergeOptions(referentials.addressCategories.value, embedAddressCategoryOptions.value),
|
||||||
|
)
|
||||||
|
|
||||||
const embedSiteOptions = computed<RefOption[]>(() =>
|
const embedSiteOptions = computed<RefOption[]>(() =>
|
||||||
mergeOptions([], (supplier.value?.addresses ?? []).flatMap(a => siteOptionsOf(a.sites))),
|
mergeOptions([], (supplier.value?.addresses ?? []).flatMap(a => siteOptionsOf(a.sites))),
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
<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">
|
||||||
<!-- 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
|
||||||
@@ -137,6 +137,7 @@
|
|||||||
: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 })"
|
||||||
|
:last="index === contacts.length - 1"
|
||||||
disabled
|
disabled
|
||||||
hide-empty
|
hide-empty
|
||||||
/>
|
/>
|
||||||
@@ -151,6 +152,7 @@
|
|||||||
: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"
|
||||||
@@ -164,8 +166,10 @@
|
|||||||
<!-- 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)"
|
v-if="isFilled(accounting.siren)"
|
||||||
:model-value="accounting.siren"
|
:model-value="accounting.siren"
|
||||||
@@ -220,13 +224,16 @@
|
|||||||
</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)"
|
v-if="isFilled(rib.label)"
|
||||||
:model-value="rib.label"
|
:model-value="rib.label"
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
<MalioTabList v-model="activeTab" :tabs="tabs" 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">
|
||||||
<MalioInputTextArea
|
<MalioInputTextArea
|
||||||
v-model="information.description"
|
v-model="information.description"
|
||||||
:label="t('commercial.suppliers.form.information.description')"
|
:label="t('commercial.suppliers.form.information.description')"
|
||||||
@@ -145,6 +145,7 @@
|
|||||||
: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="isRowRemovable(contacts, index)"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
|
:last="index === contacts.length - 1"
|
||||||
:disabled="isValidated('contacts')"
|
:disabled="isValidated('contacts')"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -177,7 +178,8 @@
|
|||||||
: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 })"
|
||||||
:category-options="referentials.categories.value"
|
:last="index === addresses.length - 1"
|
||||||
|
:category-options="referentials.addressCategories.value"
|
||||||
:site-options="referentials.sites.value"
|
:site-options="referentials.sites.value"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
@@ -210,8 +212,10 @@
|
|||||||
<!-- 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')"
|
||||||
@@ -280,21 +284,27 @@
|
|||||||
</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 && isRowRemovable(visibleRibs, index)"
|
<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')"
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { useWeighingTicketReferentials } from '../useWeighingTicketReferentials'
|
||||||
|
|
||||||
|
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||||
|
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests des référentiels Client/Fournisseur de l'écran ticket de pesée (M5).
|
||||||
|
* Contrat couvert (ERP-208) : `load(siteId)` filtre les deux endpoints par site
|
||||||
|
* courant via `siteId[]` ; sans site → listes complètes (param absent).
|
||||||
|
*/
|
||||||
|
describe('useWeighingTicketReferentials', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockApiGet.mockReset()
|
||||||
|
mockApiGet.mockResolvedValue({ member: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passe siteId[] aux deux endpoints quand un site courant est fourni', async () => {
|
||||||
|
const { load } = useWeighingTicketReferentials()
|
||||||
|
await load(7)
|
||||||
|
|
||||||
|
const clientsCall = mockApiGet.mock.calls.find(c => c[0] === '/clients')
|
||||||
|
const suppliersCall = mockApiGet.mock.calls.find(c => c[0] === '/suppliers')
|
||||||
|
expect(clientsCall?.[1]).toMatchObject({ pagination: 'false', 'siteId[]': [7] })
|
||||||
|
expect(suppliersCall?.[1]).toMatchObject({ pagination: 'false', 'siteId[]': [7] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne passe pas siteId[] quand aucun site (liste complète)', async () => {
|
||||||
|
const { load } = useWeighingTicketReferentials()
|
||||||
|
await load(null)
|
||||||
|
|
||||||
|
const clientsCall = mockApiGet.mock.calls.find(c => c[0] === '/clients')
|
||||||
|
expect(clientsCall?.[1]).not.toHaveProperty('siteId[]')
|
||||||
|
expect(clientsCall?.[1]).toMatchObject({ pagination: 'false' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe les membres Hydra en options { value: @id, label: companyName }', async () => {
|
||||||
|
mockApiGet.mockResolvedValue({ member: [{ '@id': '/api/clients/3', companyName: 'ACME' }] })
|
||||||
|
const { load, clients } = useWeighingTicketReferentials()
|
||||||
|
await load(7)
|
||||||
|
|
||||||
|
expect(clients.value).toEqual([{ value: '/api/clients/3', label: 'ACME' }])
|
||||||
|
})
|
||||||
|
})
|
||||||
+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,71 @@
|
|||||||
|
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. Filtre par
|
||||||
|
* site courant si `siteId` est fourni (ERP-208) : un tiers est rattaché à un site
|
||||||
|
* via les sites de ses adresses — param `siteId[]` déjà géré par les providers M1/M2.
|
||||||
|
*/
|
||||||
|
async function fetchAll(url: string, siteId?: number | null): Promise<PartyMember[]> {
|
||||||
|
const query: Record<string, unknown> = { pagination: 'false' }
|
||||||
|
if (siteId !== null && siteId !== undefined) {
|
||||||
|
query['siteId[]'] = [siteId]
|
||||||
|
}
|
||||||
|
const res = await api.get<{ member?: PartyMember[] }>(
|
||||||
|
url,
|
||||||
|
query,
|
||||||
|
{ 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). `siteId` (site courant) filtre les listes par site
|
||||||
|
* (ERP-208) ; absent → listes complètes.
|
||||||
|
*/
|
||||||
|
async function load(siteId?: number | null): Promise<void> {
|
||||||
|
await Promise.allSettled([
|
||||||
|
fetchAll('/clients', siteId).then((list) => {
|
||||||
|
clients.value = list.map(c => ({ value: c['@id'], label: c.companyName }))
|
||||||
|
}),
|
||||||
|
fetchAll('/suppliers', siteId).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,152 @@
|
|||||||
|
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())
|
||||||
|
const mockRefLoad = 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: mockRefLoad }),
|
||||||
|
}))
|
||||||
|
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()
|
||||||
|
mockRefLoad.mockReset().mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charge le ticket au montage (pré-remplissage via hydrate)', async () => {
|
||||||
|
await mountPage()
|
||||||
|
expect(mockFetchTicket).toHaveBeenCalledWith('9')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filtre les référentiels sur le SITE DU TICKET, pas le site courant (ERP-208)', async () => {
|
||||||
|
await mountPage()
|
||||||
|
// DETAIL.site.id = 1 → les listes sont chargées pour le site du ticket (immuable).
|
||||||
|
expect(mockRefLoad).toHaveBeenCalledWith(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
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,111 @@
|
|||||||
|
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())
|
||||||
|
const mockRefLoad = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({
|
||||||
|
useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: mockRefLoad }),
|
||||||
|
}))
|
||||||
|
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() }))
|
||||||
|
// Site courant (ERP-208) : id 7 → les référentiels doivent être chargés filtrés sur ce site.
|
||||||
|
vi.stubGlobal('useCurrentSite', () => ({ currentSite: ref({ id: 7, name: 'Site 7', color: '#000000' }) }))
|
||||||
|
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()
|
||||||
|
mockRefLoad.mockReset().mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charge les référentiels filtrés sur le site courant au montage (ERP-208)', async () => {
|
||||||
|
await mountPage()
|
||||||
|
expect(mockRefLoad).toHaveBeenCalledWith(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
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,443 @@
|
|||||||
|
<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, type WeighingTicketDetail } 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')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garantit que la contrepartie DÉJÀ ENREGISTRÉE (hydratée depuis le ticket) reste
|
||||||
|
* affichée même si la liste filtrée par site ne la contient pas (ticket antérieur
|
||||||
|
* à ERP-208, droits restreints sur /clients, contrepartie hors site…) : on injecte
|
||||||
|
* son option plutôt que de la purger. Évite toute perte silencieuse de la
|
||||||
|
* contrepartie en édition (ERP-208, retour review).
|
||||||
|
*/
|
||||||
|
function ensureSelectedOptionPresent(detail: WeighingTicketDetail): void {
|
||||||
|
const client = detail.client
|
||||||
|
if (client && !referentials.clients.value.some(o => o.value === client['@id'])) {
|
||||||
|
referentials.clients.value.push({ value: client['@id'], label: client.companyName })
|
||||||
|
}
|
||||||
|
const supplier = detail.supplier
|
||||||
|
if (supplier && !referentials.suppliers.value.some(o => o.value === supplier['@id'])) {
|
||||||
|
referentials.suppliers.value.push({ value: supplier['@id'], label: supplier.companyName })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const detail = await fetchTicket(ticketId)
|
||||||
|
ticketNumber.value = detail.number ?? ''
|
||||||
|
form.hydrate(detail)
|
||||||
|
// Listes filtrées sur le SITE DU TICKET (immuable, RG-5.09) — pas le site
|
||||||
|
// courant — et chargées APRÈS hydrate pour ne jamais purger la sélection
|
||||||
|
// existante (pas de race load/hydrate, ERP-208).
|
||||||
|
await referentials.load(detail.site?.id ?? null)
|
||||||
|
ensureSelectedOptionPresent(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,402 @@
|
|||||||
|
<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, watch } 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentSite } = useCurrentSite()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recharge les référentiels Client/Fournisseur pour le site donné, puis purge le
|
||||||
|
* tiers sélectionné s'il n'appartient plus à la liste du nouveau site (ERP-208).
|
||||||
|
*/
|
||||||
|
async function reloadReferentials(siteId: number | null): Promise<void> {
|
||||||
|
await referentials.load(siteId)
|
||||||
|
if (form.clientIri.value && !referentials.clients.value.some(o => o.value === form.clientIri.value)) {
|
||||||
|
form.clientIri.value = null
|
||||||
|
}
|
||||||
|
if (form.supplierIri.value && !referentials.suppliers.value.some(o => o.value === form.supplierIri.value)) {
|
||||||
|
form.supplierIri.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
reloadReferentials(currentSite.value?.id ?? null).catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Changement de site pendant la saisie → recharge les listes du nouveau site (ERP-208).
|
||||||
|
watch(() => currentSite.value?.id, (siteId) => {
|
||||||
|
reloadReferentials(siteId ?? null).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() : ''
|
||||||
|
}
|
||||||
@@ -1,131 +1,140 @@
|
|||||||
<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 && !disabled"
|
<!-- 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('technique.providers.form.address.remove') }"
|
<MalioButtonIcon
|
||||||
@click="$emit('remove')"
|
v-if="removable && !readonly && !disabled"
|
||||||
/>
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). -->
|
button-class="p-0"
|
||||||
<MalioSelectCheckbox
|
v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }"
|
||||||
v-if="!hideEmpty || isFilled(model.siteIris)"
|
@click="$emit('remove')"
|
||||||
: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>
|
||||||
|
|
||||||
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
|
<!-- 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
|
<MalioInputText
|
||||||
:model-value="model.streetComplement"
|
v-if="!hideEmpty || isFilled(model.postalCode)"
|
||||||
:label="t('technique.providers.form.address.streetComplement')"
|
: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"
|
:mask="ADDRESS_MASK"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:error="errors?.streetComplement"
|
:required="!readonly && !disabled"
|
||||||
@update:model-value="(v: string) => update('streetComplement', 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 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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -143,6 +152,8 @@ const POSTAL_CODE_MASK = '#####'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** Brouillon de l'adresse (v-model). */
|
/** Brouillon de l'adresse (v-model). */
|
||||||
modelValue: ProviderAddressFormDraft
|
modelValue: ProviderAddressFormDraft
|
||||||
|
/** Titre du bloc (ex: « Adresse 1 »). */
|
||||||
|
title: string
|
||||||
/** Sites Starseed disponibles. */
|
/** Sites Starseed disponibles. */
|
||||||
siteOptions: RefOption[]
|
siteOptions: RefOption[]
|
||||||
/** Contacts deja saisis, rattachables a l'adresse. */
|
/** Contacts deja saisis, rattachables a l'adresse. */
|
||||||
@@ -150,6 +161,8 @@ 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). */
|
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
|||||||
@@ -1,84 +1,93 @@
|
|||||||
<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) 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 && !disabled"
|
<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) ou en lecture seule. -->
|
||||||
v-bind="{ ariaLabel: t('technique.providers.form.contact.remove') }"
|
<MalioButtonIcon
|
||||||
@click="$emit('remove')"
|
v-if="removable && !readonly && !disabled"
|
||||||
/>
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
<MalioInputText
|
button-class="p-0"
|
||||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
v-bind="{ ariaLabel: t('technique.providers.form.contact.remove') }"
|
||||||
:model-value="model.lastName"
|
@click="$emit('remove')"
|
||||||
:label="t('technique.providers.form.contact.lastName')"
|
/>
|
||||||
:mask="PERSON_NAME_MASK"
|
</div>
|
||||||
:readonly="readonly"
|
|
||||||
:disabled="disabled"
|
<!-- Grille 4 colonnes des champs du contact. -->
|
||||||
:error="errors?.lastName"
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
@update:model-value="(v: string) => update('lastName', v)"
|
<MalioInputText
|
||||||
/>
|
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||||
<MalioInputText
|
:model-value="model.lastName"
|
||||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
:label="t('technique.providers.form.contact.lastName')"
|
||||||
:model-value="model.firstName"
|
:mask="PERSON_NAME_MASK"
|
||||||
:label="t('technique.providers.form.contact.firstName')"
|
:readonly="readonly"
|
||||||
:mask="PERSON_NAME_MASK"
|
:disabled="disabled"
|
||||||
:readonly="readonly"
|
:error="errors?.lastName"
|
||||||
:disabled="disabled"
|
@update:model-value="(v: string) => update('lastName', v)"
|
||||||
:error="errors?.firstName"
|
/>
|
||||||
@update:model-value="(v: string) => update('firstName', v)"
|
<MalioInputText
|
||||||
/>
|
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
:model-value="model.firstName"
|
||||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
:label="t('technique.providers.form.contact.firstName')"
|
||||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
:mask="PERSON_NAME_MASK"
|
||||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
:readonly="readonly"
|
||||||
<MalioInputText
|
:disabled="disabled"
|
||||||
:model-value="model.jobTitle"
|
:error="errors?.firstName"
|
||||||
:label="t('technique.providers.form.contact.jobTitle')"
|
@update:model-value="(v: string) => update('firstName', v)"
|
||||||
:mask="FREE_TEXT_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?.jobTitle"
|
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
<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>
|
||||||
<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>
|
</template>
|
||||||
|
|
||||||
@@ -93,8 +102,12 @@ const PHONE_MASK = '## ## ## ## ##'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** Brouillon du contact (v-model). */
|
/** Brouillon du contact (v-model). */
|
||||||
modelValue: ProviderContactFormDraft
|
modelValue: ProviderContactFormDraft
|
||||||
|
/** Titre du bloc (ex: « Contact 1 »). */
|
||||||
|
title: string
|
||||||
/** Affiche l'icone de suppression (1er bloc non supprimable). */
|
/** Affiche l'icone de suppression (1er bloc non supprimable). */
|
||||||
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). */
|
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<str
|
|||||||
return mount(ProviderAddressBlock, {
|
return mount(ProviderAddressBlock, {
|
||||||
props: {
|
props: {
|
||||||
modelValue: { ...emptyProviderAddress(), ...overrides },
|
modelValue: { ...emptyProviderAddress(), ...overrides },
|
||||||
|
title: 'Adresse 1',
|
||||||
siteOptions: [],
|
siteOptions: [],
|
||||||
contactOptions: [],
|
contactOptions: [],
|
||||||
countryOptions: [],
|
countryOptions: [],
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ function mountBlock(errors?: Record<string, string>) {
|
|||||||
return mount(ProviderContactBlock, {
|
return mount(ProviderContactBlock, {
|
||||||
props: {
|
props: {
|
||||||
modelValue: emptyProviderContact(),
|
modelValue: emptyProviderContact(),
|
||||||
|
title: 'Contact 1',
|
||||||
...(errors ? { errors } : {}),
|
...(errors ? { errors } : {}),
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
|
|||||||
@@ -72,7 +72,9 @@
|
|||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
|
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
|
||||||
:removable="isRowRemovable(contacts, index)"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
|
:last="index === contacts.length - 1"
|
||||||
:disabled="businessReadonly"
|
:disabled="businessReadonly"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -104,6 +106,8 @@
|
|||||||
v-for="(address, index) in addresses"
|
v-for="(address, index) in addresses"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="address"
|
:model-value="address"
|
||||||
|
:title="t('technique.providers.form.address.title', { n: index + 1 })"
|
||||||
|
:last="index === addresses.length - 1"
|
||||||
:site-options="referentials.sites.value"
|
:site-options="referentials.sites.value"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
@@ -136,8 +140,10 @@
|
|||||||
<!-- Onglet Comptabilite (present si accounting.view ; editable si manage). -->
|
<!-- Onglet Comptabilite (present si accounting.view ; editable si 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('technique.providers.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('technique.providers.form.accounting.siren')"
|
:label="t('technique.providers.form.accounting.siren')"
|
||||||
@@ -206,21 +212,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08). -->
|
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.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 && isRowRemovable(visibleRibs, index)"
|
<div class="flex items-center justify-between">
|
||||||
icon="mdi:delete-outline"
|
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.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('technique.providers.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('technique.providers.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('technique.providers.form.accounting.ribLabel')"
|
:label="t('technique.providers.form.accounting.ribLabel')"
|
||||||
|
|||||||
@@ -81,6 +81,8 @@
|
|||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
|
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
|
||||||
|
:last="index === contacts.length - 1"
|
||||||
disabled
|
disabled
|
||||||
hide-empty
|
hide-empty
|
||||||
/>
|
/>
|
||||||
@@ -94,6 +96,8 @@
|
|||||||
v-for="(view, index) in addressViews"
|
v-for="(view, index) in addressViews"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="view.draft"
|
:model-value="view.draft"
|
||||||
|
:title="t('technique.providers.form.address.title', { n: index + 1 })"
|
||||||
|
:last="index === addressViews.length - 1"
|
||||||
:site-options="view.siteOptions"
|
:site-options="view.siteOptions"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptionsFor(view.draft.country)"
|
:country-options="countryOptionsFor(view.draft.country)"
|
||||||
@@ -108,8 +112,10 @@
|
|||||||
<!-- 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('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.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 />
|
<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="" />
|
<MalioSelect v-if="isFilled(accounting.tvaModeIri)" :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" disabled empty-option-label="" />
|
||||||
@@ -120,13 +126,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Blocs RIB (uniquement si type de reglement = LCR). -->
|
<!-- Blocs RIB (uniquement si type de reglement = LCR).
|
||||||
|
Titre « RIB N », 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="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 }"
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
<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.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.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 />
|
<MalioInputText v-if="isFilled(rib.iban)" :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" disabled />
|
||||||
|
|||||||
@@ -73,7 +73,9 @@
|
|||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
|
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
|
||||||
:removable="isRowRemovable(contacts, index)"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
|
:last="index === contacts.length - 1"
|
||||||
:disabled="isValidated('contact')"
|
:disabled="isValidated('contact')"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -108,6 +110,8 @@
|
|||||||
v-for="(address, index) in addresses"
|
v-for="(address, index) in addresses"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="address"
|
:model-value="address"
|
||||||
|
:title="t('technique.providers.form.address.title', { n: index + 1 })"
|
||||||
|
:last="index === addresses.length - 1"
|
||||||
:site-options="referentials.sites.value"
|
:site-options="referentials.sites.value"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
@@ -139,8 +143,10 @@
|
|||||||
<!-- Onglet Comptabilite (present uniquement si accounting.view ; editable si manage). -->
|
<!-- Onglet Comptabilite (present uniquement si accounting.view ; editable si 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('technique.providers.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('technique.providers.form.accounting.siren')"
|
:label="t('technique.providers.form.accounting.siren')"
|
||||||
@@ -210,21 +216,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08). -->
|
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.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 && isRowRemovable(visibleRibs, index)"
|
<div class="flex items-center justify-between">
|
||||||
icon="mdi:delete-outline"
|
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.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('technique.providers.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('technique.providers.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('technique.providers.form.accounting.ribLabel')"
|
:label="t('technique.providers.form.accounting.ribLabel')"
|
||||||
|
|||||||
@@ -1,103 +1,113 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Adresse UNIQUE par transporteur (ERP-172) : un seul bloc, jamais supprimable. -->
|
<!-- Adresse UNIQUE par transporteur (ERP-172) : un seul bloc, jamais supprimable. -->
|
||||||
<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
|
||||||
<!-- Pays : prerempli « France » (RG-4.05). -->
|
(pas de bordure sous le dernier bloc). -->
|
||||||
<MalioSelect
|
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
|
||||||
v-if="!hideEmpty || isFilled(model.country)"
|
<!-- En-tete : titre du bloc, en noir (adresse unique, sans suppression). -->
|
||||||
:model-value="model.country"
|
<div class="flex items-center justify-between">
|
||||||
:options="countryOptions"
|
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
|
||||||
:label="t('transport.carriers.form.address.country')"
|
|
||||||
:readonly="readonly"
|
|
||||||
:disabled="disabled"
|
|
||||||
:required="!readonly && !disabled"
|
|
||||||
:error="errors?.country"
|
|
||||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Code postal (RG-4.06) : declenche l'autocomplete ville (BAN). -->
|
|
||||||
<MalioInputText
|
|
||||||
v-if="!hideEmpty || isFilled(model.postalCode)"
|
|
||||||
:model-value="model.postalCode"
|
|
||||||
:label="t('transport.carriers.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('transport.carriers.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('transport.carriers.form.address.city')"
|
|
||||||
:mask="ADDRESS_MASK"
|
|
||||||
:readonly="readonly"
|
|
||||||
:disabled="disabled"
|
|
||||||
:required="!readonly && !disabled"
|
|
||||||
:error="errors?.city"
|
|
||||||
@update:model-value="(v: string) => update('city', v)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Filler : aligne le debut de la ligne suivante sur la grille. Inutile en
|
|
||||||
consultation masquee (la grille se recompose sans les champs vides). -->
|
|
||||||
<div v-if="!hideEmpty" aria-hidden="true" />
|
|
||||||
|
|
||||||
<!-- 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('transport.carriers.form.address.street')"
|
|
||||||
:readonly="readonly"
|
|
||||||
:disabled="disabled"
|
|
||||||
:required="!readonly && !disabled"
|
|
||||||
:error="errors?.street"
|
|
||||||
:allow-create="true"
|
|
||||||
:no-results-text="t('transport.carriers.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('transport.carriers.form.address.street')"
|
|
||||||
:readonly="readonly"
|
|
||||||
:disabled="disabled"
|
|
||||||
:required="!readonly && !disabled"
|
|
||||||
:error="errors?.street"
|
|
||||||
@update:model-value="(v: string) => update('street', v)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MalioInputText
|
<!-- Grille 4 colonnes des champs de l'adresse. -->
|
||||||
v-if="!hideEmpty || isFilled(model.streetComplement)"
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
:model-value="model.streetComplement"
|
<!-- Pays : prerempli « France » (RG-4.05). -->
|
||||||
:label="t('transport.carriers.form.address.streetComplement')"
|
<MalioSelect
|
||||||
:mask="ADDRESS_MASK"
|
v-if="!hideEmpty || isFilled(model.country)"
|
||||||
:readonly="readonly"
|
:model-value="model.country"
|
||||||
:disabled="disabled"
|
:options="countryOptions"
|
||||||
:error="errors?.streetComplement"
|
:label="t('transport.carriers.form.address.country')"
|
||||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
:readonly="readonly"
|
||||||
/>
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
:error="errors?.country"
|
||||||
|
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Code postal (RG-4.06) : declenche l'autocomplete ville (BAN). -->
|
||||||
|
<MalioInputText
|
||||||
|
v-if="!hideEmpty || isFilled(model.postalCode)"
|
||||||
|
:model-value="model.postalCode"
|
||||||
|
:label="t('transport.carriers.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('transport.carriers.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('transport.carriers.form.address.city')"
|
||||||
|
:mask="ADDRESS_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
:error="errors?.city"
|
||||||
|
@update:model-value="(v: string) => update('city', v)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Filler : aligne le debut de la ligne suivante sur la grille. Inutile en
|
||||||
|
consultation masquee (la grille se recompose sans les champs vides). -->
|
||||||
|
<div v-if="!hideEmpty" aria-hidden="true" />
|
||||||
|
|
||||||
|
<!-- 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('transport.carriers.form.address.street')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
:error="errors?.street"
|
||||||
|
:allow-create="true"
|
||||||
|
:no-results-text="t('transport.carriers.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('transport.carriers.form.address.street')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="!readonly && !disabled"
|
||||||
|
:error="errors?.street"
|
||||||
|
@update:model-value="(v: string) => update('street', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MalioInputText
|
||||||
|
v-if="!hideEmpty || isFilled(model.streetComplement)"
|
||||||
|
:model-value="model.streetComplement"
|
||||||
|
:label="t('transport.carriers.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>
|
</template>
|
||||||
|
|
||||||
@@ -118,8 +128,12 @@ const POSTAL_CODE_MASK = '#####'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** Brouillon de l'adresse (v-model). */
|
/** Brouillon de l'adresse (v-model). */
|
||||||
modelValue: CarrierAddressFormDraft
|
modelValue: CarrierAddressFormDraft
|
||||||
|
/** Titre du bloc (ex: « Adresse 1 »). */
|
||||||
|
title: string
|
||||||
/** Pays disponibles (France par defaut). */
|
/** Pays disponibles (France par defaut). */
|
||||||
countryOptions: RefOption[]
|
countryOptions: RefOption[]
|
||||||
|
/** 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). */
|
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
|||||||
@@ -1,84 +1,93 @@
|
|||||||
<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 côté parent. Masquée si
|
(pas de bordure sous le dernier bloc). -->
|
||||||
non supprimable (1er bloc) 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 && !disabled"
|
<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 côté parent. Masquée si
|
||||||
button-class="absolute top-3 right-3"
|
non supprimable (1er bloc) ou en lecture seule. -->
|
||||||
v-bind="{ ariaLabel: t('transport.carriers.form.contact.remove') }"
|
<MalioButtonIcon
|
||||||
@click="$emit('remove')"
|
v-if="removable && !readonly && !disabled"
|
||||||
/>
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
<MalioInputText
|
button-class="p-0"
|
||||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
v-bind="{ ariaLabel: t('transport.carriers.form.contact.remove') }"
|
||||||
:model-value="model.lastName"
|
@click="$emit('remove')"
|
||||||
:label="t('transport.carriers.form.contact.lastName')"
|
/>
|
||||||
:mask="PERSON_NAME_MASK"
|
</div>
|
||||||
:readonly="readonly"
|
|
||||||
:disabled="disabled"
|
<!-- Grille 4 colonnes des champs du contact. -->
|
||||||
:error="errors?.lastName"
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
@update:model-value="(v: string) => update('lastName', v)"
|
<MalioInputText
|
||||||
/>
|
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||||
<MalioInputText
|
:model-value="model.lastName"
|
||||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
:label="t('transport.carriers.form.contact.lastName')"
|
||||||
:model-value="model.firstName"
|
:mask="PERSON_NAME_MASK"
|
||||||
:label="t('transport.carriers.form.contact.firstName')"
|
:readonly="readonly"
|
||||||
:mask="PERSON_NAME_MASK"
|
:disabled="disabled"
|
||||||
:readonly="readonly"
|
:error="errors?.lastName"
|
||||||
:disabled="disabled"
|
@update:model-value="(v: string) => update('lastName', v)"
|
||||||
:error="errors?.firstName"
|
/>
|
||||||
@update:model-value="(v: string) => update('firstName', v)"
|
<MalioInputText
|
||||||
/>
|
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText (inheritAttrs:false)
|
:model-value="model.firstName"
|
||||||
renvoie `class` sur l'input interne, pas sur la cellule de grille. -->
|
:label="t('transport.carriers.form.contact.firstName')"
|
||||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
:mask="PERSON_NAME_MASK"
|
||||||
<MalioInputText
|
:readonly="readonly"
|
||||||
:model-value="model.jobTitle"
|
:disabled="disabled"
|
||||||
:label="t('transport.carriers.form.contact.jobTitle')"
|
:error="errors?.firstName"
|
||||||
:mask="FREE_TEXT_MASK"
|
@update:model-value="(v: string) => update('firstName', v)"
|
||||||
:readonly="readonly"
|
/>
|
||||||
:disabled="disabled"
|
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText (inheritAttrs:false)
|
||||||
:error="errors?.jobTitle"
|
renvoie `class` sur l'input interne, pas sur la cellule de grille. -->
|
||||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.jobTitle"
|
||||||
|
:label="t('transport.carriers.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('transport.carriers.form.contact.email')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:lowercase="true"
|
||||||
|
:error="errors?.email"
|
||||||
|
@update:model-value="(v: string) => update('email', v)"
|
||||||
|
/>
|
||||||
|
<!-- Téléphone principal + bouton « + » révélant le 2e numéro (max 2). -->
|
||||||
|
<MalioInputPhone
|
||||||
|
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||||
|
:model-value="model.phonePrimary"
|
||||||
|
:label="t('transport.carriers.form.contact.phonePrimary')"
|
||||||
|
:mask="PHONE_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors?.phonePrimary"
|
||||||
|
:addable="!model.hasSecondaryPhone && !readonly"
|
||||||
|
:add-button-label="t('transport.carriers.form.contact.addPhone')"
|
||||||
|
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||||
|
@add="revealSecondaryPhone"
|
||||||
|
/>
|
||||||
|
<!-- 2e numéro : révélé à la demande (max 2 téléphones — RG-4.08). -->
|
||||||
|
<MalioInputPhone
|
||||||
|
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||||
|
:model-value="model.phoneSecondary"
|
||||||
|
:label="t('transport.carriers.form.contact.phoneSecondary')"
|
||||||
|
:mask="PHONE_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors?.phoneSecondary"
|
||||||
|
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<MalioInputEmail
|
|
||||||
v-if="!hideEmpty || isFilled(model.email)"
|
|
||||||
:model-value="model.email"
|
|
||||||
:label="t('transport.carriers.form.contact.email')"
|
|
||||||
:readonly="readonly"
|
|
||||||
:disabled="disabled"
|
|
||||||
:lowercase="true"
|
|
||||||
:error="errors?.email"
|
|
||||||
@update:model-value="(v: string) => update('email', v)"
|
|
||||||
/>
|
|
||||||
<!-- Téléphone principal + bouton « + » révélant le 2e numéro (max 2). -->
|
|
||||||
<MalioInputPhone
|
|
||||||
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
|
||||||
:model-value="model.phonePrimary"
|
|
||||||
:label="t('transport.carriers.form.contact.phonePrimary')"
|
|
||||||
:mask="PHONE_MASK"
|
|
||||||
:readonly="readonly"
|
|
||||||
:disabled="disabled"
|
|
||||||
:error="errors?.phonePrimary"
|
|
||||||
:addable="!model.hasSecondaryPhone && !readonly"
|
|
||||||
:add-button-label="t('transport.carriers.form.contact.addPhone')"
|
|
||||||
@update:model-value="(v: string) => update('phonePrimary', v)"
|
|
||||||
@add="revealSecondaryPhone"
|
|
||||||
/>
|
|
||||||
<!-- 2e numéro : révélé à la demande (max 2 téléphones — RG-4.08). -->
|
|
||||||
<MalioInputPhone
|
|
||||||
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
|
||||||
:model-value="model.phoneSecondary"
|
|
||||||
:label="t('transport.carriers.form.contact.phoneSecondary')"
|
|
||||||
:mask="PHONE_MASK"
|
|
||||||
:readonly="readonly"
|
|
||||||
:disabled="disabled"
|
|
||||||
:error="errors?.phoneSecondary"
|
|
||||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -93,8 +102,12 @@ const PHONE_MASK = '## ## ## ## ##'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** Brouillon du contact (v-model). */
|
/** Brouillon du contact (v-model). */
|
||||||
modelValue: CarrierContactFormDraft
|
modelValue: CarrierContactFormDraft
|
||||||
|
/** Titre du bloc (ex: « Contact 1 »). */
|
||||||
|
title: string
|
||||||
/** Affiche l'icône de suppression (1er bloc non supprimable). */
|
/** Affiche l'icône de suppression (1er bloc non supprimable). */
|
||||||
removable?: boolean
|
removable?: boolean
|
||||||
|
/** Dernier bloc de la liste : supprime le filet de separation bas. */
|
||||||
|
last?: boolean
|
||||||
/** Bloc en lecture seule (onglet validé). */
|
/** Bloc en lecture seule (onglet validé). */
|
||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||||
|
|||||||
@@ -1,190 +1,199 @@
|
|||||||
<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 côté parent. -->
|
(pas de bordure sous le dernier bloc), aligne sur les blocs contact / adresse. -->
|
||||||
<MalioButtonIcon
|
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
|
||||||
v-if="removable && !readonly && !disabled"
|
<!-- 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 côté parent. -->
|
||||||
v-bind="{ ariaLabel: t('transport.carriers.form.price.remove') }"
|
<MalioButtonIcon
|
||||||
@click="$emit('remove')"
|
v-if="removable && !readonly && !disabled"
|
||||||
/>
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
<!-- RG-4.09 : sens du prix (CLIENT / FOURNISSEUR) en colonne 1 / ligne 1, radios
|
button-class="p-0"
|
||||||
EN LIGNE (horizontaux), centrés sur la hauteur de champ (h-12) comme la
|
v-bind="{ ariaLabel: t('transport.carriers.form.price.remove') }"
|
||||||
case « Affréter ». Pas de label de groupe. -->
|
@click="$emit('remove')"
|
||||||
<div>
|
/>
|
||||||
<div class="flex h-12 items-center gap-6">
|
|
||||||
<MalioRadioButton
|
|
||||||
:model-value="model.direction"
|
|
||||||
:name="`price-direction-${uid}`"
|
|
||||||
value="CLIENT"
|
|
||||||
:label="t('transport.carriers.form.price.directionClient')"
|
|
||||||
:disabled="readonly || disabled"
|
|
||||||
group-class="mt-0"
|
|
||||||
@update:model-value="onDirectionChange"
|
|
||||||
/>
|
|
||||||
<MalioRadioButton
|
|
||||||
:model-value="model.direction"
|
|
||||||
:name="`price-direction-${uid}`"
|
|
||||||
value="FOURNISSEUR"
|
|
||||||
:label="t('transport.carriers.form.price.directionSupplier')"
|
|
||||||
:disabled="readonly || disabled"
|
|
||||||
group-class="mt-0"
|
|
||||||
@update:model-value="onDirectionChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p v-if="errors?.direction" class="ml-[2px] text-xs text-m-danger">{{ errors.direction }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Branche CLIENT (RG-4.10). -->
|
<!-- Grille 4 colonnes des champs du prix. -->
|
||||||
<template v-if="model.direction === 'CLIENT'">
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
<MalioSelect
|
<!-- RG-4.09 : sens du prix (CLIENT / FOURNISSEUR) en colonne 1 / ligne 1, radios
|
||||||
:model-value="model.clientIri"
|
EN LIGNE (horizontaux), centrés sur la hauteur de champ (h-12) comme la
|
||||||
:options="clientOptions"
|
case « Affréter ». Pas de label de groupe. -->
|
||||||
:label="t('transport.carriers.form.price.client')"
|
|
||||||
empty-option-label=""
|
|
||||||
:required="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
:disabled="disabled"
|
|
||||||
:error="errors?.client"
|
|
||||||
@update:model-value="onClientChange"
|
|
||||||
/>
|
|
||||||
<MalioSelect
|
|
||||||
:model-value="model.clientDeliveryAddressIri"
|
|
||||||
:options="clientAddressOptions"
|
|
||||||
:label="t('transport.carriers.form.price.clientDeliveryAddress')"
|
|
||||||
empty-option-label=""
|
|
||||||
:required="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
:disabled="disabled"
|
|
||||||
:error="errors?.clientDeliveryAddress"
|
|
||||||
@update:model-value="(v: string | number | null) => update('clientDeliveryAddressIri', v === null ? null : String(v))"
|
|
||||||
/>
|
|
||||||
<MalioSelect
|
|
||||||
:model-value="model.departureSiteIri"
|
|
||||||
:options="siteOptions"
|
|
||||||
:label="t('transport.carriers.form.price.departureSite')"
|
|
||||||
empty-option-label=""
|
|
||||||
:required="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
:disabled="disabled"
|
|
||||||
:error="errors?.departureSite"
|
|
||||||
@update:model-value="(v: string | number | null) => update('departureSiteIri', v === null ? null : String(v))"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Branche FOURNISSEUR (RG-4.11). -->
|
|
||||||
<template v-else-if="model.direction === 'FOURNISSEUR'">
|
|
||||||
<MalioSelect
|
|
||||||
:model-value="model.supplierIri"
|
|
||||||
:options="supplierOptions"
|
|
||||||
:label="t('transport.carriers.form.price.supplier')"
|
|
||||||
empty-option-label=""
|
|
||||||
:required="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
:disabled="disabled"
|
|
||||||
:error="errors?.supplier"
|
|
||||||
@update:model-value="onSupplierChange"
|
|
||||||
/>
|
|
||||||
<MalioSelect
|
|
||||||
:model-value="model.supplierSupplyAddressIri"
|
|
||||||
:options="supplierAddressOptions"
|
|
||||||
:label="t('transport.carriers.form.price.supplierSupplyAddress')"
|
|
||||||
empty-option-label=""
|
|
||||||
:required="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
:disabled="disabled"
|
|
||||||
:error="errors?.supplierSupplyAddress"
|
|
||||||
@update:model-value="(v: string | number | null) => update('supplierSupplyAddressIri', v === null ? null : String(v))"
|
|
||||||
/>
|
|
||||||
<MalioSelect
|
|
||||||
:model-value="model.deliverySiteIri"
|
|
||||||
:options="siteOptions"
|
|
||||||
:label="t('transport.carriers.form.price.deliverySite')"
|
|
||||||
empty-option-label=""
|
|
||||||
:required="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
:disabled="disabled"
|
|
||||||
:error="errors?.deliverySite"
|
|
||||||
@update:model-value="(v: string | number | null) => update('deliverySiteIri', v === null ? null : String(v))"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Communs (visibles dès qu'un sens est choisi). -->
|
|
||||||
<template v-if="model.direction !== null">
|
|
||||||
<!-- Contenant : Benne / Fond mouvant (radios centrés h-12, pas de label). -->
|
|
||||||
<div>
|
<div>
|
||||||
<div class="flex h-12 items-center gap-4">
|
<div class="flex h-12 items-center gap-6">
|
||||||
<MalioRadioButton
|
<MalioRadioButton
|
||||||
:model-value="model.containerType"
|
:model-value="model.direction"
|
||||||
:name="`price-container-${uid}`"
|
:name="`price-direction-${uid}`"
|
||||||
value="BENNE"
|
value="CLIENT"
|
||||||
:label="t('transport.carriers.containerType.BENNE')"
|
:label="t('transport.carriers.form.price.directionClient')"
|
||||||
:disabled="readonly || disabled"
|
:disabled="readonly || disabled"
|
||||||
group-class="mt-0"
|
group-class="mt-0"
|
||||||
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
|
@update:model-value="onDirectionChange"
|
||||||
/>
|
/>
|
||||||
<MalioRadioButton
|
<MalioRadioButton
|
||||||
:model-value="model.containerType"
|
:model-value="model.direction"
|
||||||
:name="`price-container-${uid}`"
|
:name="`price-direction-${uid}`"
|
||||||
value="FOND_MOUVANT"
|
value="FOURNISSEUR"
|
||||||
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
|
:label="t('transport.carriers.form.price.directionSupplier')"
|
||||||
:disabled="readonly || disabled"
|
:disabled="readonly || disabled"
|
||||||
group-class="mt-0"
|
group-class="mt-0"
|
||||||
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
|
@update:model-value="onDirectionChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="errors?.containerType" class="ml-[2px] text-xs text-m-danger">{{ errors.containerType }}</p>
|
<p v-if="errors?.direction" class="ml-[2px] text-xs text-m-danger">{{ errors.direction }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tarification : Forfait / Tonne (radios centrés h-12, pas de label). -->
|
<!-- Branche CLIENT (RG-4.10). -->
|
||||||
<div>
|
<template v-if="model.direction === 'CLIENT'">
|
||||||
<div class="flex h-12 items-center gap-4">
|
<MalioSelect
|
||||||
<MalioRadioButton
|
:model-value="model.clientIri"
|
||||||
:model-value="model.pricingUnit"
|
:options="clientOptions"
|
||||||
:name="`price-unit-${uid}`"
|
:label="t('transport.carriers.form.price.client')"
|
||||||
value="FORFAIT"
|
empty-option-label=""
|
||||||
:label="t('transport.carriers.form.price.pricingForfait')"
|
:required="true"
|
||||||
:disabled="readonly || disabled"
|
:readonly="readonly"
|
||||||
group-class="mt-0"
|
:disabled="disabled"
|
||||||
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
|
:error="errors?.client"
|
||||||
/>
|
@update:model-value="onClientChange"
|
||||||
<MalioRadioButton
|
/>
|
||||||
:model-value="model.pricingUnit"
|
<MalioSelect
|
||||||
:name="`price-unit-${uid}`"
|
:model-value="model.clientDeliveryAddressIri"
|
||||||
value="TONNE"
|
:options="clientAddressOptions"
|
||||||
:label="t('transport.carriers.form.price.pricingTonne')"
|
:label="t('transport.carriers.form.price.clientDeliveryAddress')"
|
||||||
:disabled="readonly || disabled"
|
empty-option-label=""
|
||||||
group-class="mt-0"
|
:required="true"
|
||||||
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
|
:readonly="readonly"
|
||||||
/>
|
:disabled="disabled"
|
||||||
|
:error="errors?.clientDeliveryAddress"
|
||||||
|
@update:model-value="(v: string | number | null) => update('clientDeliveryAddressIri', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="model.departureSiteIri"
|
||||||
|
:options="siteOptions"
|
||||||
|
:label="t('transport.carriers.form.price.departureSite')"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors?.departureSite"
|
||||||
|
@update:model-value="(v: string | number | null) => update('departureSiteIri', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Branche FOURNISSEUR (RG-4.11). -->
|
||||||
|
<template v-else-if="model.direction === 'FOURNISSEUR'">
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="model.supplierIri"
|
||||||
|
:options="supplierOptions"
|
||||||
|
:label="t('transport.carriers.form.price.supplier')"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors?.supplier"
|
||||||
|
@update:model-value="onSupplierChange"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="model.supplierSupplyAddressIri"
|
||||||
|
:options="supplierAddressOptions"
|
||||||
|
:label="t('transport.carriers.form.price.supplierSupplyAddress')"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors?.supplierSupplyAddress"
|
||||||
|
@update:model-value="(v: string | number | null) => update('supplierSupplyAddressIri', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="model.deliverySiteIri"
|
||||||
|
:options="siteOptions"
|
||||||
|
:label="t('transport.carriers.form.price.deliverySite')"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors?.deliverySite"
|
||||||
|
@update:model-value="(v: string | number | null) => update('deliverySiteIri', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Communs (visibles dès qu'un sens est choisi). -->
|
||||||
|
<template v-if="model.direction !== null">
|
||||||
|
<!-- Contenant : Benne / Fond mouvant (radios centrés h-12, pas de label). -->
|
||||||
|
<div>
|
||||||
|
<div class="flex h-12 items-center gap-4">
|
||||||
|
<MalioRadioButton
|
||||||
|
:model-value="model.containerType"
|
||||||
|
:name="`price-container-${uid}`"
|
||||||
|
value="BENNE"
|
||||||
|
:label="t('transport.carriers.containerType.BENNE')"
|
||||||
|
:disabled="readonly || disabled"
|
||||||
|
group-class="mt-0"
|
||||||
|
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
<MalioRadioButton
|
||||||
|
:model-value="model.containerType"
|
||||||
|
:name="`price-container-${uid}`"
|
||||||
|
value="FOND_MOUVANT"
|
||||||
|
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
|
||||||
|
:disabled="readonly || disabled"
|
||||||
|
group-class="mt-0"
|
||||||
|
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="errors?.containerType" class="ml-[2px] text-xs text-m-danger">{{ errors.containerType }}</p>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="errors?.pricingUnit" class="ml-[2px] text-xs text-m-danger">{{ errors.pricingUnit }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MalioInputAmount
|
<!-- Tarification : Forfait / Tonne (radios centrés h-12, pas de label). -->
|
||||||
:model-value="model.price"
|
<div>
|
||||||
:label="t('transport.carriers.form.price.price')"
|
<div class="flex h-12 items-center gap-4">
|
||||||
:required="true"
|
<MalioRadioButton
|
||||||
:readonly="readonly"
|
:model-value="model.pricingUnit"
|
||||||
:disabled="disabled"
|
:name="`price-unit-${uid}`"
|
||||||
:error="errors?.price"
|
value="FORFAIT"
|
||||||
@update:model-value="(v: string) => update('price', v)"
|
:label="t('transport.carriers.form.price.pricingForfait')"
|
||||||
/>
|
:disabled="readonly || disabled"
|
||||||
|
group-class="mt-0"
|
||||||
|
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
<MalioRadioButton
|
||||||
|
:model-value="model.pricingUnit"
|
||||||
|
:name="`price-unit-${uid}`"
|
||||||
|
value="TONNE"
|
||||||
|
:label="t('transport.carriers.form.price.pricingTonne')"
|
||||||
|
:disabled="readonly || disabled"
|
||||||
|
group-class="mt-0"
|
||||||
|
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="errors?.pricingUnit" class="ml-[2px] text-xs text-m-danger">{{ errors.pricingUnit }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<MalioSelect
|
<MalioInputAmount
|
||||||
:model-value="model.priceState"
|
:model-value="model.price"
|
||||||
:options="priceStateOptions"
|
:label="t('transport.carriers.form.price.price')"
|
||||||
:label="t('transport.carriers.form.price.priceState')"
|
:required="true"
|
||||||
empty-option-label=""
|
:readonly="readonly"
|
||||||
:required="true"
|
:disabled="disabled"
|
||||||
:readonly="readonly"
|
:error="errors?.price"
|
||||||
:disabled="disabled"
|
@update:model-value="(v: string) => update('price', v)"
|
||||||
:error="errors?.priceState"
|
/>
|
||||||
@update:model-value="(v: string | number | null) => update('priceState', v === null ? null : String(v))"
|
|
||||||
/>
|
<MalioSelect
|
||||||
</template>
|
:model-value="model.priceState"
|
||||||
|
:options="priceStateOptions"
|
||||||
|
:label="t('transport.carriers.form.price.priceState')"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors?.priceState"
|
||||||
|
@update:model-value="(v: string | number | null) => update('priceState', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -200,6 +209,10 @@ interface SelectOption {
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** Brouillon du prix (v-model). */
|
/** Brouillon du prix (v-model). */
|
||||||
modelValue: CarrierPriceFormDraft
|
modelValue: CarrierPriceFormDraft
|
||||||
|
/** Titre du bloc (ex: « Prix 1 »). */
|
||||||
|
title: string
|
||||||
|
/** Dernier bloc de la liste : supprime le filet de separation bas. */
|
||||||
|
last?: boolean
|
||||||
/** Clients disponibles (IRI en value). */
|
/** Clients disponibles (IRI en value). */
|
||||||
clientOptions: SelectOption[]
|
clientOptions: SelectOption[]
|
||||||
/** Fournisseurs disponibles (IRI en value). */
|
/** Fournisseurs disponibles (IRI en value). */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { debounce } from '~/shared/utils/debounce'
|
import { debounce } from '~/shared/utils/debounce'
|
||||||
|
import { formatDateFr } from '~/shared/utils/date'
|
||||||
import { useQualimatSearch, type QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
import { useQualimatSearch, type QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,19 +93,6 @@ function isExpired(value: string): boolean {
|
|||||||
return date.getTime() < today.getTime()
|
return date.getTime() < today.getTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format court français JJ-MM-AAAA (chaîne vide si date absente / invalide). */
|
|
||||||
function formatDateFr(value: string | null | undefined): string {
|
|
||||||
if (!value) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const day = String(date.getDate()).padStart(2, '0')
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
return `${day}-${month}-${date.getFullYear()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Confirmation d'intégration ───────────────────────────────────────────────
|
// ── Confirmation d'intégration ───────────────────────────────────────────────
|
||||||
const confirmOpen = ref(false)
|
const confirmOpen = ref(false)
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ function mountBlock(overrides: Record<string, unknown> = {}) {
|
|||||||
return mount(CarrierAddressBlock, {
|
return mount(CarrierAddressBlock, {
|
||||||
props: {
|
props: {
|
||||||
modelValue: { ...emptyCarrierAddress(), ...overrides },
|
modelValue: { ...emptyCarrierAddress(), ...overrides },
|
||||||
|
title: 'Adresse 1',
|
||||||
countryOptions: [{ value: 'France', label: 'France' }],
|
countryOptions: [{ value: 'France', label: 'France' }],
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
|
|||||||
@@ -143,6 +143,8 @@
|
|||||||
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
|
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
|
||||||
<CarrierAddressBlock
|
<CarrierAddressBlock
|
||||||
:model-value="address"
|
:model-value="address"
|
||||||
|
:title="t('transport.carriers.form.address.title')"
|
||||||
|
:last="true"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:errors="addressErrors"
|
:errors="addressErrors"
|
||||||
@update:model-value="(v) => address = v"
|
@update:model-value="(v) => address = v"
|
||||||
@@ -160,7 +162,9 @@
|
|||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
|
:title="t('transport.carriers.form.contact.title', { n: index + 1 })"
|
||||||
:removable="isRowRemovable(contacts, index)"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
|
:last="index === contacts.length - 1"
|
||||||
: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)"
|
||||||
@@ -178,10 +182,12 @@
|
|||||||
v-for="(price, index) in prices"
|
v-for="(price, index) in prices"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="price"
|
:model-value="price"
|
||||||
|
:title="t('transport.carriers.form.price.title', { n: index + 1 })"
|
||||||
:client-options="clientOptions"
|
:client-options="clientOptions"
|
||||||
:supplier-options="supplierOptions"
|
:supplier-options="supplierOptions"
|
||||||
:site-options="siteOptions"
|
:site-options="siteOptions"
|
||||||
removable
|
removable
|
||||||
|
:last="index === prices.length - 1"
|
||||||
:errors="priceErrors[index]"
|
:errors="priceErrors[index]"
|
||||||
@update:model-value="(v) => prices[index] = v"
|
@update:model-value="(v) => prices[index] = v"
|
||||||
@remove="askRemovePrice(index)"
|
@remove="askRemovePrice(index)"
|
||||||
|
|||||||
@@ -123,6 +123,8 @@
|
|||||||
<!-- Adresse UNIQUE (ERP-172). -->
|
<!-- Adresse UNIQUE (ERP-172). -->
|
||||||
<CarrierAddressBlock
|
<CarrierAddressBlock
|
||||||
:model-value="address"
|
:model-value="address"
|
||||||
|
:title="t('transport.carriers.form.address.title')"
|
||||||
|
:last="true"
|
||||||
:country-options="countryOptionsFor(address.country)"
|
:country-options="countryOptionsFor(address.country)"
|
||||||
disabled
|
disabled
|
||||||
hide-empty
|
hide-empty
|
||||||
@@ -136,6 +138,8 @@
|
|||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
|
:title="t('transport.carriers.form.contact.title', { n: index + 1 })"
|
||||||
|
:last="index === contacts.length - 1"
|
||||||
disabled
|
disabled
|
||||||
hide-empty
|
hide-empty
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -141,6 +141,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { formatDateFr } from '~/shared/utils/date'
|
||||||
|
|
||||||
interface FilterOption {
|
interface FilterOption {
|
||||||
value: string
|
value: string
|
||||||
@@ -235,20 +236,6 @@ function isValidityExpired(item: Record<string, unknown>): boolean {
|
|||||||
return date.getTime() < today.getTime()
|
return date.getTime() < today.getTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format court francais JJ-MM-AAAA (spec M4). Chaine vide si date absente / invalide. */
|
|
||||||
function formatDateFr(value: string | null | undefined): string {
|
|
||||||
if (!value) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const day = String(date.getDate()).padStart(2, '0')
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
return `${day}-${month}-${date.getFullYear()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clic sur une ligne → ecran Consultation (route a plat /carriers/{id}). */
|
/** Clic sur une ligne → ecran Consultation (route a plat /carriers/{id}). */
|
||||||
function onRowClick(item: Record<string, unknown>): void {
|
function onRowClick(item: Record<string, unknown>): void {
|
||||||
router.push(`/carriers/${item.id}`)
|
router.push(`/carriers/${item.id}`)
|
||||||
|
|||||||
@@ -180,6 +180,8 @@
|
|||||||
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
|
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
|
||||||
<CarrierAddressBlock
|
<CarrierAddressBlock
|
||||||
:model-value="address"
|
:model-value="address"
|
||||||
|
:title="t('transport.carriers.form.address.title')"
|
||||||
|
:last="true"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:disabled="isQualimat || isValidated('addresses')"
|
:disabled="isQualimat || isValidated('addresses')"
|
||||||
:errors="addressErrors"
|
:errors="addressErrors"
|
||||||
@@ -207,7 +209,9 @@
|
|||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
|
:title="t('transport.carriers.form.contact.title', { n: index + 1 })"
|
||||||
:removable="isRowRemovable(contacts, index)"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
|
:last="index === contacts.length - 1"
|
||||||
:disabled="isValidated('contacts')"
|
:disabled="isValidated('contacts')"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -240,11 +244,13 @@
|
|||||||
v-for="(price, index) in prices"
|
v-for="(price, index) in prices"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="price"
|
:model-value="price"
|
||||||
|
:title="t('transport.carriers.form.price.title', { n: index + 1 })"
|
||||||
:client-options="clientOptions"
|
:client-options="clientOptions"
|
||||||
:supplier-options="supplierOptions"
|
:supplier-options="supplierOptions"
|
||||||
:site-options="siteOptions"
|
:site-options="siteOptions"
|
||||||
:removable="!isValidated('prices')"
|
:removable="!isValidated('prices')"
|
||||||
:disabled="isValidated('prices')"
|
:disabled="isValidated('prices')"
|
||||||
|
:last="index === prices.length - 1"
|
||||||
:errors="priceErrors[index]"
|
:errors="priceErrors[index]"
|
||||||
@update:model-value="(v) => prices[index] = v"
|
@update:model-value="(v) => prices[index] = v"
|
||||||
@remove="askRemovePrice(index)"
|
@remove="askRemovePrice(index)"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { todayIso } from '../date'
|
import { formatDateFr, todayIso } from '../date'
|
||||||
|
|
||||||
describe('todayIso', () => {
|
describe('todayIso', () => {
|
||||||
it('formate la date locale en YYYY-MM-DD (zero-pad mois/jour)', () => {
|
it('formate la date locale en YYYY-MM-DD (zero-pad mois/jour)', () => {
|
||||||
@@ -17,3 +17,28 @@ describe('todayIso', () => {
|
|||||||
expect(todayIso(new Date(2026, 11, 31, 12, 0))).toBe('2026-12-31')
|
expect(todayIso(new Date(2026, 11, 31, 12, 0))).toBe('2026-12-31')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('formatDateFr', () => {
|
||||||
|
it('formate un datetime ISO avec offset en JJ-MM-AAAA', () => {
|
||||||
|
expect(formatDateFr('2026-06-17T09:12:00+02:00')).toBe('17-06-2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lit la date dans la CHAINE, sans decalage de fuseau (deterministe)', () => {
|
||||||
|
// Minuit UTC : une lecture via new Date().getDate() basculerait au 4 dans un
|
||||||
|
// fuseau negatif (ex. America). On lit la chaine -> reste le 05 partout.
|
||||||
|
expect(formatDateFr('2026-01-05T00:00:00Z')).toBe('05-01-2026')
|
||||||
|
// Idem juste avant minuit avec offset +02:00 : la date affichee est celle
|
||||||
|
// portee par la chaine (17), pas le 16 d'un runtime UTC.
|
||||||
|
expect(formatDateFr('2026-06-17T00:30:00+02:00')).toBe('17-06-2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepte une date nue YYYY-MM-DD', () => {
|
||||||
|
expect(formatDateFr('2026-03-07')).toBe('07-03-2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renvoie une chaine vide pour une valeur absente ou non ISO', () => {
|
||||||
|
expect(formatDateFr(null)).toBe('')
|
||||||
|
expect(formatDateFr(undefined)).toBe('')
|
||||||
|
expect(formatDateFr('pas-une-date')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -15,3 +15,37 @@ export function todayIso(now: Date = new Date()): string {
|
|||||||
const day = String(now.getDate()).padStart(2, '0')
|
const day = String(now.getDate()).padStart(2, '0')
|
||||||
return `${year}-${month}-${day}`
|
return `${year}-${month}-${day}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date-heure courante au format ISO LOCAL `YYYY-MM-DDTHH:mm:ss` (sans fuseau).
|
||||||
|
*
|
||||||
|
* C'est le format attendu par `MalioDateTime` (secondes incluses, pas d'offset
|
||||||
|
* horaire). Comme `todayIso`, on lit les composantes LOCALES (jamais
|
||||||
|
* `toISOString()`/UTC) pour ne pas décaler l'heure réelle. Paramètre `now`
|
||||||
|
* injectable pour les tests.
|
||||||
|
*/
|
||||||
|
export function nowIsoDateTime(now: Date = new Date()): string {
|
||||||
|
const hours = String(now.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(now.getMinutes()).padStart(2, '0')
|
||||||
|
const seconds = String(now.getSeconds()).padStart(2, '0')
|
||||||
|
return `${todayIso(now)}T${hours}:${minutes}:${seconds}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date courte française `JJ-MM-AAAA` à partir d'une valeur ISO (`YYYY-MM-DD` ou
|
||||||
|
* datetime `YYYY-MM-DDTHH:mm:ss±HH:mm`). Chaîne vide si absente ou non ISO.
|
||||||
|
*
|
||||||
|
* On lit les composantes DIRECTEMENT dans la chaîne (10 premiers caractères) au
|
||||||
|
* lieu de `new Date(value).getDate()` : un datetime porteur d'un offset (ex.
|
||||||
|
* `…T00:30:00+02:00`, ou `…Z`) basculerait d'un jour selon le fuseau du
|
||||||
|
* navigateur / du runner CI. Rendu ainsi déterministe et cohérent avec l'écran
|
||||||
|
* d'édition (slice de la chaîne brute) et l'export serveur (`format('d/m/Y')`).
|
||||||
|
*/
|
||||||
|
export function formatDateFr(value: string | null | undefined): string {
|
||||||
|
const match = value ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value) : null
|
||||||
|
if (!match) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const [, year, month, day] = match
|
||||||
|
return `${day}-${month}-${year}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export interface Persona {
|
|||||||
// sidebar-visibility pour driver la matrice. Les valeurs correspondent
|
// sidebar-visibility pour driver la matrice. Les valeurs correspondent
|
||||||
// aux slugs de route (`/admin/<slug>`), volontairement stables quand
|
// aux slugs de route (`/admin/<slug>`), volontairement stables quand
|
||||||
// la copie/i18n change.
|
// la copie/i18n change.
|
||||||
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories'>
|
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories' | 'products'>
|
||||||
}
|
}
|
||||||
|
|
||||||
const SHARED_PASSWORD = 'e2e-secret'
|
const SHARED_PASSWORD = 'e2e-secret'
|
||||||
@@ -47,7 +47,7 @@ export const personas: Record<PersonaKey, Persona> = {
|
|||||||
password: SHARED_PASSWORD,
|
password: SHARED_PASSWORD,
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'],
|
||||||
},
|
},
|
||||||
'user-full': {
|
'user-full': {
|
||||||
key: 'user-full',
|
key: 'user-full',
|
||||||
@@ -65,6 +65,12 @@ export const personas: Record<PersonaKey, Persona> = {
|
|||||||
'sites.bypass_scope',
|
'sites.bypass_scope',
|
||||||
'catalog.categories.view',
|
'catalog.categories.view',
|
||||||
'catalog.categories.manage',
|
'catalog.categories.manage',
|
||||||
|
// Catalogue produit (M6, ERP-197). Admin-only (matrice docx p.3) :
|
||||||
|
// mappe sur le persona "tout", pas de nouveau persona (regle ABSOLUE
|
||||||
|
// n°7). L'item vit dans la section Administration sur la route
|
||||||
|
// `/admin/products` -> ajoute le lien `products` a expectedAdminLinks.
|
||||||
|
'catalog.products.view',
|
||||||
|
'catalog.products.manage',
|
||||||
// Commercial — Repertoire clients (M1). Mappe ici sur le persona
|
// Commercial — Repertoire clients (M1). Mappe ici sur le persona
|
||||||
// "tout" en attendant les vrais roles metier (bureau/compta/
|
// "tout" en attendant les vrais roles metier (bureau/compta/
|
||||||
// commerciale/usine) seedes par ERP-74. Pas de nouveau persona
|
// commerciale/usine) seedes par ERP-74. Pas de nouveau persona
|
||||||
@@ -110,7 +116,7 @@ export const personas: Record<PersonaKey, Persona> = {
|
|||||||
'logistique.weighing_tickets.view',
|
'logistique.weighing_tickets.view',
|
||||||
'logistique.weighing_tickets.manage',
|
'logistique.weighing_tickets.manage',
|
||||||
],
|
],
|
||||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'],
|
||||||
},
|
},
|
||||||
'user-readonly': {
|
'user-readonly': {
|
||||||
key: 'user-readonly',
|
key: 'user-readonly',
|
||||||
@@ -155,4 +161,4 @@ export function getPersona(key: PersonaKey): Persona {
|
|||||||
return personas[key]
|
return personas[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'audit-log'] as const
|
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'] as const
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ COPY config config/
|
|||||||
COPY migrations migrations/
|
COPY migrations migrations/
|
||||||
COPY public public/
|
COPY public public/
|
||||||
COPY src src/
|
COPY src src/
|
||||||
|
COPY templates templates/
|
||||||
|
|
||||||
RUN composer dump-autoload --optimize --no-dev
|
RUN composer dump-autoload --optimize --no-dev
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ test-db-setup:
|
|||||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_provider_company_name_active ON provider (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_provider_company_name_active ON provider (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_carrier_name_active ON carrier (LOWER(name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_carrier_name_active ON carrier (LOWER(name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||||
|
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_product_code_active ON product (code) WHERE deleted_at IS NULL"
|
||||||
|
|
||||||
fixtures:
|
fixtures:
|
||||||
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M5 — Tickets de pesee (ERP-193) : cycle de vie brouillon -> valide.
|
||||||
|
*
|
||||||
|
* Le metier peut desormais enregistrer une pesee (bascule ou manuelle) SANS avoir
|
||||||
|
* rempli la contrepartie ni l'immatriculation : le ticket est cree « brouillon »
|
||||||
|
* des la 1ere pesee, puis « valide » (numero attribue, status VALIDATED) quand les
|
||||||
|
* 3 champs requis (type + champ contrepartie + immatriculation) ET les 2 pesees
|
||||||
|
* sont renseignes.
|
||||||
|
*
|
||||||
|
* Schema impacte :
|
||||||
|
* - `counterparty_type`, `immatriculation`, `number` passent NULLABLE (un brouillon
|
||||||
|
* n'a encore ni contrepartie, ni immat, ni numero — le numero n'est attribue
|
||||||
|
* qu'a la validation pour eviter les trous de sequence). Les CHECK de branche
|
||||||
|
* chk_wt_*_branch tolerent deja un counterparty_type NULL (NULL <> 'X' = NULL,
|
||||||
|
* donc CHECK non viole).
|
||||||
|
* - nouvelle colonne `status` (DRAFT|VALIDATED). Les tickets EXISTANTS (crees sous
|
||||||
|
* l'ancien flux, donc complets) sont retro-marques VALIDATED ; le defaut des
|
||||||
|
* nouvelles lignes est DRAFT.
|
||||||
|
*
|
||||||
|
* Namespace racine `DoctrineMigrations` (et non modulaire) : la migration ALTER une
|
||||||
|
* table creee par la migration racine Version20260617150000. Doctrine Migrations
|
||||||
|
* 3.x trie par FQCN alphabetique entre namespaces -> une migration modulaire
|
||||||
|
* `App\Module\...` passerait AVANT la racine sur base vide (make db-reset) et
|
||||||
|
* tenterait l'ALTER avant le CREATE. Le namespace racine garantit le tri par
|
||||||
|
* timestamp (regle ABSOLUE n°11, cf. Version20260617170000 pour site.code).
|
||||||
|
*/
|
||||||
|
final class Version20260624100000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'ERP-193 : weighing_ticket brouillon/valide (counterparty_type/immatriculation/number nullable + colonne status).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Brouillon : ni contrepartie, ni immat, ni numero tant que non valide.
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN counterparty_type DROP NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN immatriculation DROP NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN number DROP NOT NULL');
|
||||||
|
|
||||||
|
// Statut du cycle de vie. Colonne ajoutee nullable, retro-remplie a VALIDATED
|
||||||
|
// pour les tickets existants (complets), puis figee NOT NULL DEFAULT DRAFT.
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket ADD COLUMN status VARCHAR(12)');
|
||||||
|
$this->addSql("UPDATE weighing_ticket SET status = 'VALIDATED'");
|
||||||
|
$this->addSql("ALTER TABLE weighing_ticket ALTER COLUMN status SET DEFAULT 'DRAFT'");
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN status SET NOT NULL');
|
||||||
|
$this->addSql("ALTER TABLE weighing_ticket ADD CONSTRAINT chk_wt_status CHECK (status IN ('DRAFT','VALIDATED'))");
|
||||||
|
|
||||||
|
// Commentaires (regle ABSOLUE n°12).
|
||||||
|
$this->comment('weighing_ticket', 'status', "Cycle de vie : DRAFT (En attente, pesee enregistree sans contrepartie/immat) ou VALIDATED (Terminee, valide avec numero). Defaut DRAFT.");
|
||||||
|
$this->comment('weighing_ticket', 'number', "Numero {siteCode}-TP-{NNNN}, unique par site, immuable. NULL tant que le ticket est brouillon : attribue a la validation (RG-5.02, ERP-193).");
|
||||||
|
$this->comment('weighing_ticket', 'counterparty_type', "Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (RG-5.03). NULL tant que brouillon ; requise a la validation. Pilote l'obligation client_id / supplier_id / other_label.");
|
||||||
|
$this->comment('weighing_ticket', 'immatriculation', "Plaque du vehicule, partagee entre pesee vide et plein (RG-5.01). NULL tant que brouillon ; requise a la validation. Masque XX-000-XX sauf plate_free_format.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket DROP CONSTRAINT IF EXISTS chk_wt_status');
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket DROP COLUMN IF EXISTS status');
|
||||||
|
|
||||||
|
// Restauration NOT NULL : echoue s'il subsiste des brouillons (number /
|
||||||
|
// counterparty_type / immatriculation NULL) — irreversible en presence de
|
||||||
|
// donnees brouillon, ce qui est attendu (le down sert au dev sur base saine).
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN number SET NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN immatriculation SET NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN counterparty_type SET NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pose un COMMENT ON COLUMN en dollar-quoting Postgres ($_$...$_$) pour eviter
|
||||||
|
* tout echappement d'apostrophes dans les descriptions.
|
||||||
|
*/
|
||||||
|
private function comment(string $table, string $column, string $description): void
|
||||||
|
{
|
||||||
|
$this->addSql(sprintf(
|
||||||
|
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||||
|
'"'.str_replace('"', '""', $table).'"',
|
||||||
|
'"'.str_replace('"', '""', $column).'"',
|
||||||
|
$description,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M5 — Tickets de pesee (ERP-193) : suppression du « numero de pesee » manuel.
|
||||||
|
*
|
||||||
|
* En pesee manuelle, l'operateur saisit desormais directement le DSD (le numero du
|
||||||
|
* pont qu'il a reellement utilise), conserve tel quel. Le champ texte separe
|
||||||
|
* `*_manual_number` (« Numero de pesee ») devient redondant — pour le client c'est
|
||||||
|
* la meme chose que le DSD — et est supprime.
|
||||||
|
*
|
||||||
|
* Namespace racine `DoctrineMigrations` : ALTER d'une table creee par la migration
|
||||||
|
* racine (cf. Version20260624100000) — meme contrainte de tri (regle ABSOLUE n°11).
|
||||||
|
*/
|
||||||
|
final class Version20260624110000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'ERP-193 : suppression de weighing_ticket.empty_manual_number / full_manual_number (DSD saisi en manuel).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket DROP COLUMN empty_manual_number');
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket DROP COLUMN full_manual_number');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket ADD COLUMN empty_manual_number VARCHAR(50) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket ADD COLUMN full_manual_number VARCHAR(50) DEFAULT NULL');
|
||||||
|
$this->addSql("COMMENT ON COLUMN weighing_ticket.empty_manual_number IS \$_\$Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a vide (RG-5.04).\$_\$");
|
||||||
|
$this->addSql("COMMENT ON COLUMN weighing_ticket.full_manual_number IS \$_\$Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a plein (RG-5.04).\$_\$");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\ArrayParameterType;
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Taxonomie ADRESSE (module Catalog) — categories du champ « Categorie » des blocs adresse.
|
||||||
|
*
|
||||||
|
* Contexte : jusqu'ici le multi-select « Categorie » des blocs adresse reutilisait
|
||||||
|
* la taxonomie CLIENT (M1, codes DISTRIBUTEUR/COURTIER blacklistes par RG-1.29) ou
|
||||||
|
* FOURNISSEUR (M2, RG-2.10). On introduit un type dedie ADRESSE : les blocs adresse
|
||||||
|
* client (ClientAddress) et fournisseur (SupplierAddress) ne referencent plus que
|
||||||
|
* des `Category` rattachees au type ADRESSE (validation whitelist par type).
|
||||||
|
*
|
||||||
|
* Cette migration :
|
||||||
|
* 1. cree le `category_type` ADRESSE (code ADRESSE, label « Adresse ») ;
|
||||||
|
* 2. seede 6 `Category` rattachees a ce type via la jonction ManyToMany
|
||||||
|
* `category_category_type` (modele courant depuis Version20260608120000 ;
|
||||||
|
* la colonne ManyToOne `category.category_type_id` n'existe plus).
|
||||||
|
*
|
||||||
|
* Aucune colonne creee/modifiee -> pas de `COMMENT ON COLUMN` (regle ABSOLUE n°12) :
|
||||||
|
* la migration ne fait que des INSERT de donnees de reference.
|
||||||
|
*
|
||||||
|
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire :
|
||||||
|
* garantit l'ordre par timestamp avant les migrations modulaires sur base vide.
|
||||||
|
*
|
||||||
|
* Idempotence : `INSERT ... ON CONFLICT (code) DO NOTHING` pour le type,
|
||||||
|
* `INSERT ... SELECT ... WHERE NOT EXISTS` pour chaque categorie et chaque ligne
|
||||||
|
* de jonction (aligne sur le pattern PRESTATAIRE / Version20260612080000). En prod
|
||||||
|
* la table `category` est vide (aucune fixture metier) ; en dev/test le purger
|
||||||
|
* Doctrine vide `category` / `category_type` avant les fixtures qui reproduisent le
|
||||||
|
* meme etat final (CategoryTypeFixtures / CategoryFixtures etendus a ADRESSE).
|
||||||
|
*/
|
||||||
|
final class Version20260625100000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Categories de demonstration du type ADRESSE : nom => code stable. Le code est
|
||||||
|
* la cle metier (slug MAJUSCULE du nom, miroir du CategoryCodeGenerator) et reste
|
||||||
|
* unique parmi les actifs (uq_category_code). Le nom est unique GLOBALEMENT parmi
|
||||||
|
* les actifs (uq_category_name_active) : aucune collision avec les categories
|
||||||
|
* deja seedees (CLIENT / FOURNISSEUR / PRESTATAIRE).
|
||||||
|
*/
|
||||||
|
private const array ADDRESS_CATEGORIES = [
|
||||||
|
'Siège' => 'SIEGE',
|
||||||
|
'Contact issues' => 'CONTACT_ISSUES',
|
||||||
|
'Facturation' => 'FACTURATION',
|
||||||
|
'Livraison' => 'LIVRAISON',
|
||||||
|
'Approvisionnement' => 'APPROVISIONNEMENT',
|
||||||
|
'Méthaniseur' => 'METHANISEUR',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Taxonomie ADRESSE : cree le CategoryType ADRESSE + seed des categories adresse (Siege, Contact issues, Facturation, Livraison, Approvisionnement, Methaniseur).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// 1. Type ADRESSE (idempotent via l'index unique uq_category_type_code).
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO category_type (code, label) VALUES ('ADRESSE', 'Adresse')
|
||||||
|
ON CONFLICT (code) DO NOTHING
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
foreach (self::ADDRESS_CATEGORIES as $name => $code) {
|
||||||
|
// 2a. Categorie sous ADRESSE (si le code est libre parmi les actifs).
|
||||||
|
// created_at/updated_at NOT NULL -> NOW() ; le blame reste null
|
||||||
|
// (seed hors contexte HTTP, libelle « Systeme » cote front).
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO category (name, code, created_at, updated_at)
|
||||||
|
SELECT :name, :code, NOW(), NOW()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM category c WHERE c.code = :code AND c.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
SQL, ['name' => $name, 'code' => $code]);
|
||||||
|
|
||||||
|
// 2b. Jonction M2M categorie <-> type ADRESSE (modele courant).
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO category_category_type (category_id, category_type_id)
|
||||||
|
SELECT c.id, ct.id
|
||||||
|
FROM category c
|
||||||
|
CROSS JOIN category_type ct
|
||||||
|
WHERE c.code = :code AND c.deleted_at IS NULL
|
||||||
|
AND ct.code = 'ADRESSE'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM category_category_type cct
|
||||||
|
WHERE cct.category_id = c.id AND cct.category_type_id = ct.id
|
||||||
|
)
|
||||||
|
SQL, ['code' => $code]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Best-effort : on retire d'abord les categories seedees (par code) — la FK
|
||||||
|
// category_category_type est ON DELETE CASCADE cote category, donc les lignes
|
||||||
|
// de jonction partent avec —, puis le type s'il n'est plus reference.
|
||||||
|
$this->addSql(
|
||||||
|
'DELETE FROM category WHERE code IN (:codes) '
|
||||||
|
.'AND id IN (SELECT category_id FROM category_category_type cct '
|
||||||
|
."JOIN category_type ct ON ct.id = cct.category_type_id WHERE ct.code = 'ADRESSE')",
|
||||||
|
['codes' => array_values(self::ADDRESS_CATEGORIES)],
|
||||||
|
['codes' => ArrayParameterType::STRING],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM category_type
|
||||||
|
WHERE code = 'ADRESSE'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM category_category_type cct WHERE cct.category_type_id = category_type.id
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M6 — Catalogue produit (ERP-198) : creation du schema BDD du module.
|
||||||
|
*
|
||||||
|
* Objets crees (spec-back § 3.2) :
|
||||||
|
* - storage_type : referentiel PROVISOIRE des types de stockage (en attente de la
|
||||||
|
* liste definitive d'Aurore — § 2.4 / RG-6.06). Lecture seule au M6.
|
||||||
|
* - storage_type_site : jonction M2M storage_type <-> site (sur quels sites un type
|
||||||
|
* de stockage est disponible — alimente le filtrage du multi-select par site).
|
||||||
|
* - product : table principale (code unique global parmi les actifs, etats
|
||||||
|
* multi-valeur JSONB, champs conditionnels SALE, categorie de type PRODUIT,
|
||||||
|
* soft-delete prepare + Timestampable/Blamable).
|
||||||
|
* - product_site : jonction M2M product <-> site (sites de disponibilite, RG-6.04).
|
||||||
|
* - product_storage_type : jonction M2M product <-> storage_type (RG-6.06).
|
||||||
|
*
|
||||||
|
* Seed : ajout du `category_type` PRODUIT (miroir CategoryTypeFixtures, comme
|
||||||
|
* CLIENT/FOURNISSEUR/PRESTATAIRE/ADRESSE — § 2.5). Les `Category` de type PRODUIT et
|
||||||
|
* le seed Figma du referentiel storage_type suivent au ticket ERP-201.
|
||||||
|
*
|
||||||
|
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire :
|
||||||
|
* la table product porte des FK cross-module (user, site, category). Le tri par
|
||||||
|
* timestamp au sein du namespace racine garantit l'ordre apres la creation de ces
|
||||||
|
* tables sur base vide ; un namespace modulaire casserait `make db-reset` (cf.
|
||||||
|
* Version20260617150000 pour le M5).
|
||||||
|
*
|
||||||
|
* Convention IDs (spec § 2.2) : `INT GENERATED BY DEFAULT AS IDENTITY`,
|
||||||
|
* horodatages `TIMESTAMP(0) WITHOUT TIME ZONE` (le TimestampableBlamableTrait mappe
|
||||||
|
* `datetime_immutable`). Chaque colonne porte son `COMMENT ON COLUMN` (regle n°12).
|
||||||
|
*
|
||||||
|
* NB schema:update (test-db-setup) : product / storage_type et leurs jonctions seront
|
||||||
|
* mappes en ORM au ticket suivant (entites Product + StorageType, ERP-199). D'ici la,
|
||||||
|
* `schema:update --force` les drope sur la base de TEST uniquement (sans impact :
|
||||||
|
* aucun test ne les reference encore, et dev/prod ne lancent jamais schema:update).
|
||||||
|
* Leurs descriptions seront ajoutees a ColumnCommentsCatalog au ticket entites (comme
|
||||||
|
* weighing_ticket : migration ERP-182, catalogue ERP-183).
|
||||||
|
*/
|
||||||
|
final class Version20260625110000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'ERP-198 (M6) : storage_type (+ jonction site) + product (+ jonctions site/stockage) + seed category_type PRODUIT.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->createStorageType();
|
||||||
|
$this->createStorageTypeSite();
|
||||||
|
$this->createProduct();
|
||||||
|
$this->createProductSite();
|
||||||
|
$this->createProductStorageType();
|
||||||
|
$this->seedCategoryTypeProduit();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Ordre inverse des dependances FK.
|
||||||
|
$this->addSql('DROP TABLE IF EXISTS product_storage_type');
|
||||||
|
$this->addSql('DROP TABLE IF EXISTS product_site');
|
||||||
|
$this->addSql('DROP TABLE IF EXISTS product');
|
||||||
|
$this->addSql('DROP TABLE IF EXISTS storage_type_site');
|
||||||
|
$this->addSql('DROP TABLE IF EXISTS storage_type');
|
||||||
|
// Retrait du type seede (best-effort : echoue si des categories le referencent
|
||||||
|
// encore — attendu, le down sert au dev sur base saine).
|
||||||
|
$this->addSql("DELETE FROM category_type WHERE code = 'PRODUIT'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Referentiel des types de stockage (PROVISOIRE) — § 2.4 / RG-6.06
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private function createStorageType(): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE storage_type (
|
||||||
|
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
code VARCHAR(40) NOT NULL,
|
||||||
|
label VARCHAR(120) NOT NULL,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX uq_storage_type_code ON storage_type (code)');
|
||||||
|
|
||||||
|
$this->comment('storage_type', '_table', 'Referentiel des types de stockage (PROVISOIRE, en attente liste Aurore) — Boisseau, Cellule, Tas, Cuve melasse… (RG-6.06). Lecture seule au M6.');
|
||||||
|
$this->comment('storage_type', 'id', 'Identifiant interne auto-incremente.');
|
||||||
|
$this->comment('storage_type', 'code', 'Code stable MAJUSCULE du type de stockage (ex. TAS, CUVE_MELASSE). Unique (uq_storage_type_code).');
|
||||||
|
$this->comment('storage_type', 'label', 'Libelle FR affiche du type de stockage (ex. « Cuve melasse »).');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createStorageTypeSite(): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE storage_type_site (
|
||||||
|
storage_type_id INT NOT NULL,
|
||||||
|
site_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (storage_type_id, site_id),
|
||||||
|
CONSTRAINT fk_storage_type_site_type
|
||||||
|
FOREIGN KEY (storage_type_id) REFERENCES storage_type (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_storage_type_site_site
|
||||||
|
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql('CREATE INDEX idx_storage_type_site_site ON storage_type_site (site_id)');
|
||||||
|
|
||||||
|
$this->comment('storage_type_site', '_table', 'Jointure M2M storage_type <-> site (Sites) — sites sur lesquels un type de stockage est disponible (alimente le filtrage du multi-select par site, RG-6.06).');
|
||||||
|
$this->comment('storage_type_site', 'storage_type_id', 'FK -> storage_type.id, ON DELETE CASCADE — type de stockage disponible.');
|
||||||
|
$this->comment('storage_type_site', 'site_id', 'FK -> site.id, ON DELETE CASCADE — site ou le type de stockage est disponible.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Table principale `product`
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private function createProduct(): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE product (
|
||||||
|
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
code VARCHAR(50) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
-- Pas de DEFAULT : un tableau vide violerait chk_product_states_not_empty
|
||||||
|
-- (RG-6.02). La colonne est toujours renseignee par l'app (Processor/ORM).
|
||||||
|
states JSONB NOT NULL,
|
||||||
|
manufactured BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
contains_molasses BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
category_id INT NOT NULL,
|
||||||
|
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
created_by INT DEFAULT NULL,
|
||||||
|
updated_by INT DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
CONSTRAINT chk_product_states_not_empty
|
||||||
|
CHECK (jsonb_array_length(states) >= 1),
|
||||||
|
CONSTRAINT fk_product_category
|
||||||
|
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_product_created_by
|
||||||
|
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_product_updated_by
|
||||||
|
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// Unicite GLOBALE du code parmi les actifs (soft-delete tolere) — index partiel.
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX uq_product_code_active ON product (code) WHERE deleted_at IS NULL');
|
||||||
|
$this->addSql('CREATE INDEX idx_product_category ON product (category_id)');
|
||||||
|
$this->addSql('CREATE INDEX idx_product_deleted_at ON product (deleted_at)');
|
||||||
|
$this->addSql('CREATE INDEX idx_product_created_by ON product (created_by)');
|
||||||
|
$this->addSql('CREATE INDEX idx_product_updated_by ON product (updated_by)');
|
||||||
|
|
||||||
|
$this->comment('product', '_table', 'Produits du catalogue (M6 Catalog) — etat Achat/Vendu/Autre, sites de disponibilite, categorie produit, types de stockage.');
|
||||||
|
$this->comment('product', 'id', 'Identifiant interne auto-incremente.');
|
||||||
|
$this->comment('product', 'code', 'Code produit (= « Numero » de la liste), saisi, unique global parmi les actifs (RG-6.01). Index partiel uq_product_code_active. Normalise serveur (trim/UPPER).');
|
||||||
|
$this->comment('product', 'name', 'Nom du produit (≤ 255). Normalise serveur (trim).');
|
||||||
|
$this->comment('product', 'states', 'Etats du produit (JSON) : sous-ensemble non vide de PURCHASE|SALE|OTHER, multi-select (RG-6.02, chk_product_states_not_empty). Pilote les champs conditionnels.');
|
||||||
|
$this->comment('product', 'manufactured', '« Fabrique » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).');
|
||||||
|
$this->comment('product', 'contains_molasses', '« Contient de la melasse » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).');
|
||||||
|
$this->comment('product', 'category_id', 'Categorie produit (FK -> category.id, ON DELETE RESTRICT) — type PRODUIT, obligatoire, validee applicativement (RG-6.05).');
|
||||||
|
$this->comment('product', 'deleted_at', 'Horodatage du soft-delete technique — non expose au M6 ; la liste exclut les produits supprimes (§ 2.7). Null = ligne active.');
|
||||||
|
$this->addTimestampableBlamableComments('product');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createProductSite(): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE product_site (
|
||||||
|
product_id INT NOT NULL,
|
||||||
|
site_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (product_id, site_id),
|
||||||
|
CONSTRAINT fk_product_site_product
|
||||||
|
FOREIGN KEY (product_id) REFERENCES product (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_product_site_site
|
||||||
|
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql('CREATE INDEX idx_product_site_site ON product_site (site_id)');
|
||||||
|
|
||||||
|
$this->comment('product_site', '_table', 'Jointure M2M product <-> site (Sites) — sites de disponibilite du produit (>= 1 obligatoire, RG-6.04).');
|
||||||
|
$this->comment('product_site', 'product_id', 'FK -> product.id, ON DELETE CASCADE — produit concerne.');
|
||||||
|
$this->comment('product_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site de disponibilite rattache au produit.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createProductStorageType(): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE product_storage_type (
|
||||||
|
product_id INT NOT NULL,
|
||||||
|
storage_type_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (product_id, storage_type_id),
|
||||||
|
CONSTRAINT fk_product_storage_type_product
|
||||||
|
FOREIGN KEY (product_id) REFERENCES product (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_product_storage_type_type
|
||||||
|
FOREIGN KEY (storage_type_id) REFERENCES storage_type (id) ON DELETE RESTRICT
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql('CREATE INDEX idx_product_storage_type_type ON product_storage_type (storage_type_id)');
|
||||||
|
|
||||||
|
$this->comment('product_storage_type', '_table', 'Jointure M2M product <-> storage_type — types de stockage du produit (>= 1 obligatoire, filtres par les sites selectionnes, RG-6.06).');
|
||||||
|
$this->comment('product_storage_type', 'product_id', 'FK -> product.id, ON DELETE CASCADE — produit concerne.');
|
||||||
|
$this->comment('product_storage_type', 'storage_type_id', 'FK -> storage_type.id, ON DELETE RESTRICT — type de stockage rattache au produit.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Seed du type de categorie PRODUIT (§ 2.5) — miroir CategoryTypeFixtures
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private function seedCategoryTypeProduit(): void
|
||||||
|
{
|
||||||
|
// Idempotent via l'index unique uq_category_type_code (comme CLIENT/FOURNISSEUR/
|
||||||
|
// PRESTATAIRE/ADRESSE). Les Category de type PRODUIT suivent en ERP-201.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO category_type (code, label) VALUES ('PRODUIT', 'Produit')
|
||||||
|
ON CONFLICT (code) DO NOTHING
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Helpers (identiques au M5 Version20260617150000)
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
|
||||||
|
* en reutilisant le catalogue partage (source unique, ERP-67).
|
||||||
|
*/
|
||||||
|
private function addTimestampableBlamableComments(string $table): void
|
||||||
|
{
|
||||||
|
foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
|
||||||
|
$this->comment($table, $column, $description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou `COMMENT ON COLUMN`
|
||||||
|
* en dollar-quoting Postgres ($_$...$_$) pour eviter tout echappement d apostrophe.
|
||||||
|
*/
|
||||||
|
private function comment(string $table, string $column, string $description): void
|
||||||
|
{
|
||||||
|
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||||
|
|
||||||
|
if ('_table' === $column) {
|
||||||
|
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addSql(sprintf(
|
||||||
|
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||||
|
$quotedTable,
|
||||||
|
'"'.str_replace('"', '""', $column).'"',
|
||||||
|
$description,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M6 Catalog — `storage_type` devient un referentiel PLAT + seed prod-safe.
|
||||||
|
*
|
||||||
|
* Contexte : le rattachement « tel type de stockage dispo sur tel site » ne releve
|
||||||
|
* PAS du referentiel `storage_type`. Il sera porte par la future entite Stockage
|
||||||
|
* (module Stockage : un stockage = 1 site + 1 type) et derive des stockages reels.
|
||||||
|
* On retire donc la jointure M2M `storage_type_site` (creee par Version20260625110000)
|
||||||
|
* et le filtrage `?siteId[]=` du multi-select produit (RG-6.06 revue : le select liste
|
||||||
|
* desormais TOUS les types).
|
||||||
|
*
|
||||||
|
* Seed : `storage_type` n'avait jusqu'ici qu'une fixture (purge Doctrine), donc une
|
||||||
|
* table VIDE en prod (les fixtures n'y tournent pas). On aligne sur les referentiels
|
||||||
|
* comptables (payment_type / bank / country, Version20260601000000 / ...100000) :
|
||||||
|
* un `INSERT ... ON CONFLICT (code) DO NOTHING` idempotent qui seede prod ET survit a
|
||||||
|
* tout. En dev/test, StorageTypeFixtures re-seede apres la purge (source unique : les
|
||||||
|
* 10 memes valeurs Figma, PROVISOIRE — HP-M6-02 / ERP-201).
|
||||||
|
*
|
||||||
|
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) : la table `storage_type`
|
||||||
|
* et la jointure droppee ici ont ete creees au namespace racine (Version20260625110000) ;
|
||||||
|
* un namespace modulaire trierait par FQCN alphabetique AVANT et casserait l'ordre sur
|
||||||
|
* base vide (drop d'une table pas encore creee).
|
||||||
|
*/
|
||||||
|
final class Version20260626100000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'M6 Catalog : storage_type referentiel plat (drop storage_type_site) + seed idempotent des types de stockage (prod-safe).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// 1. storage_type devient plat : la dispo par site releve du futur module Stockage.
|
||||||
|
$this->addSql('DROP TABLE IF EXISTS storage_type_site');
|
||||||
|
|
||||||
|
// 2. Seed idempotent (miroir StorageTypeFixtures) : alimente la prod ou les
|
||||||
|
// fixtures ne tournent pas. ON CONFLICT (code) -> rejouable sans doublon
|
||||||
|
// (s'appuie sur l'index unique uq_storage_type_code).
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO storage_type (code, label) VALUES
|
||||||
|
('BOISSEAU', 'Boisseau'),
|
||||||
|
('BOISSEAU_DOSAGE', 'Boisseau dosage'),
|
||||||
|
('CASE', 'Case'),
|
||||||
|
('CELLULE', 'Cellule'),
|
||||||
|
('CONTAINER', 'Container'),
|
||||||
|
('CUVE_MELASSE', 'Cuve mélasse'),
|
||||||
|
('STOCKAGE_BIG_BAG', 'Stockage big bag'),
|
||||||
|
('STOCKAGE_PALETTE', 'Stockage palette'),
|
||||||
|
('TAS', 'Tas'),
|
||||||
|
('ZONE', 'Zone')
|
||||||
|
ON CONFLICT (code) DO NOTHING
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Retire uniquement les 10 types seedes ET restes orphelins (aucun produit ne
|
||||||
|
// les reference via product_storage_type). Sans le NOT EXISTS, le DELETE casse
|
||||||
|
// sur la FK RESTRICT product_storage_type.storage_type_id. Symetrique du
|
||||||
|
// ON CONFLICT DO NOTHING du up().
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM storage_type
|
||||||
|
WHERE code IN (
|
||||||
|
'BOISSEAU', 'BOISSEAU_DOSAGE', 'CASE', 'CELLULE', 'CONTAINER',
|
||||||
|
'CUVE_MELASSE', 'STOCKAGE_BIG_BAG', 'STOCKAGE_PALETTE', 'TAS', 'ZONE'
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM product_storage_type pst WHERE pst.storage_type_id = storage_type.id
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// Recree la jointure M2M storage_type <-> site (etat anterieur a cette migration).
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE storage_type_site (
|
||||||
|
storage_type_id INT NOT NULL,
|
||||||
|
site_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (storage_type_id, site_id),
|
||||||
|
CONSTRAINT fk_storage_type_site_type
|
||||||
|
FOREIGN KEY (storage_type_id) REFERENCES storage_type (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_storage_type_site_site
|
||||||
|
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX idx_storage_type_site_site ON storage_type_site (site_id)');
|
||||||
|
|
||||||
|
$this->addSql('COMMENT ON TABLE "storage_type_site" IS $_$Jointure M2M storage_type <-> site (Sites) — sites sur lesquels un type de stockage est disponible (alimente le filtrage du multi-select par site, RG-6.06).$_$');
|
||||||
|
$this->addSql('COMMENT ON COLUMN "storage_type_site"."storage_type_id" IS $_$FK -> storage_type.id, ON DELETE CASCADE — type de stockage disponible.$_$');
|
||||||
|
$this->addSql('COMMENT ON COLUMN "storage_type_site"."site_id" IS $_$FK -> site.id, ON DELETE CASCADE — site ou le type de stockage est disponible.$_$');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\ArrayParameterType;
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M6 Catalog — seed prod-safe des `Category` de type PRODUIT.
|
||||||
|
*
|
||||||
|
* Contexte : le `CategoryType` PRODUIT est seede en migration (Version20260625110000),
|
||||||
|
* mais ses `Category` (Cereales, Oleagineux, Aliments du betail, Engrais) ne vivaient
|
||||||
|
* que dans `CategoryFixtures` (dev/test) — table `category` VIDE en prod, donc le
|
||||||
|
* select « Categorie » du formulaire produit serait vide. On aligne sur les autres
|
||||||
|
* taxonomies (CLIENT / FOURNISSEUR / PRESTATAIRE / ADRESSE, deja seedees en migration)
|
||||||
|
* : seed idempotent ici (prod), re-seed dev/test par les fixtures apres purge.
|
||||||
|
*
|
||||||
|
* Aucune colonne creee/modifiee -> pas de `COMMENT ON COLUMN` (regle ABSOLUE n°12) :
|
||||||
|
* la migration ne fait que des INSERT de donnees de reference.
|
||||||
|
*
|
||||||
|
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) : depend de
|
||||||
|
* `category` / `category_type` / `category_category_type` (creees au namespace racine)
|
||||||
|
* et du type PRODUIT (Version20260625110000) ; le tri par timestamp garantit l'ordre.
|
||||||
|
*
|
||||||
|
* Idempotence : `INSERT ... SELECT ... WHERE NOT EXISTS` pour chaque categorie et
|
||||||
|
* chaque ligne de jonction (miroir Version20260612080000 / ERP-84). Codes = slug
|
||||||
|
* MAJUSCULE deterministe (meme sortie que CategoryCodeGenerator), provisoires — a
|
||||||
|
* affiner avec le metier (ERP-201).
|
||||||
|
*/
|
||||||
|
final class Version20260626110000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Categories produit (provisoires, Figma/metier) : nom => code stable. Le code
|
||||||
|
* reste unique parmi les actifs (uq_category_code) et le nom unique globalement
|
||||||
|
* (uq_category_name_active) — aucune collision avec les taxonomies existantes.
|
||||||
|
*/
|
||||||
|
private const array PRODUCT_CATEGORIES = [
|
||||||
|
'Céréales' => 'CEREALES',
|
||||||
|
'Oléagineux' => 'OLEAGINEUX',
|
||||||
|
'Aliments du bétail' => 'ALIMENTS_DU_BETAIL',
|
||||||
|
'Engrais' => 'ENGRAIS',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'M6 Catalog : seed prod-safe des categories de type PRODUIT (Cereales, Oleagineux, Aliments du betail, Engrais).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Le type PRODUIT existe deja (Version20260625110000) ; re-assert defensif
|
||||||
|
// et idempotent pour rendre cette migration auto-portante.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO category_type (code, label) VALUES ('PRODUIT', 'Produit')
|
||||||
|
ON CONFLICT (code) DO NOTHING
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
foreach (self::PRODUCT_CATEGORIES as $name => $code) {
|
||||||
|
// 1. Categorie (si le code est libre parmi les actifs). created_at/updated_at
|
||||||
|
// NOT NULL -> NOW() ; le blame reste null (seed hors contexte HTTP).
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO category (name, code, created_at, updated_at)
|
||||||
|
SELECT :name, :code, NOW(), NOW()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM category c WHERE c.code = :code AND c.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
SQL, ['name' => $name, 'code' => $code]);
|
||||||
|
|
||||||
|
// 2. Jonction M2M categorie <-> type PRODUIT (modele courant).
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO category_category_type (category_id, category_type_id)
|
||||||
|
SELECT c.id, ct.id
|
||||||
|
FROM category c
|
||||||
|
CROSS JOIN category_type ct
|
||||||
|
WHERE c.code = :code AND c.deleted_at IS NULL
|
||||||
|
AND ct.code = 'PRODUIT'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM category_category_type cct
|
||||||
|
WHERE cct.category_id = c.id AND cct.category_type_id = ct.id
|
||||||
|
)
|
||||||
|
SQL, ['code' => $code]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Best-effort : retire les categories seedees (par code) rattachees au type
|
||||||
|
// PRODUIT — la jonction part en CASCADE cote category. Echoue si un produit
|
||||||
|
// reference encore l'une d'elles (FK RESTRICT product.category_id), attendu.
|
||||||
|
$this->addSql(
|
||||||
|
'DELETE FROM category WHERE code IN (:codes) '
|
||||||
|
.'AND id IN (SELECT category_id FROM category_category_type cct '
|
||||||
|
."JOIN category_type ct ON ct.id = cct.category_type_id WHERE ct.code = 'PRODUIT')",
|
||||||
|
['codes' => array_values(self::PRODUCT_CATEGORIES)],
|
||||||
|
['codes' => ArrayParameterType::STRING],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Application\Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalisation serveur des champs texte d'un Product, appliquee par le
|
||||||
|
* ProductProcessor AVANT l'unicite du code et la persistance (RG-6.07, spec-back
|
||||||
|
* M6 § 6). Jumeau du CarrierFieldNormalizer (M4), recentre sur les deux champs
|
||||||
|
* texte du produit.
|
||||||
|
*
|
||||||
|
* - code : trim + UPPER (cohérent avec la stratégie de codes stables du Catalog —
|
||||||
|
* le code produit fait office de cle metier saisie, unique global parmi les
|
||||||
|
* actifs RG-6.01).
|
||||||
|
* - name : trim simple (pas de changement de casse — libelle affiche).
|
||||||
|
*
|
||||||
|
* Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide apres
|
||||||
|
* trim devient null. En pratique le ProductProcessor n'appelle ces methodes
|
||||||
|
* qu'apres validation (NotBlank deja joue par API Platform), donc le code et le
|
||||||
|
* name sont non vides a ce stade — le retour null reste un garde-fou.
|
||||||
|
*/
|
||||||
|
final class ProductFieldNormalizer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Code produit en majuscules (RG-6.07) : " ble-01 " -> "BLE-01". Conserve
|
||||||
|
* null tel quel ; une chaine vide apres trim devient null (c'est l'Assert\NotBlank
|
||||||
|
* de l'entite qui rejette le vide, pas le normalizer).
|
||||||
|
*/
|
||||||
|
public function normalizeCode(?string $value): ?string
|
||||||
|
{
|
||||||
|
if (null === $value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
return '' === $value ? null : mb_strtoupper($value, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nom du produit trimme (RG-6.07), sans changement de casse. Une chaine vide
|
||||||
|
* apres trim devient null.
|
||||||
|
*/
|
||||||
|
public function normalizeName(?string $value): ?string
|
||||||
|
{
|
||||||
|
if (null === $value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
return '' === $value ? null : $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,10 @@ final class CatalogModule
|
|||||||
// sans donner l'acces d'administration `.view` (qui ouvre la page Catalogue
|
// sans donner l'acces d'administration `.view` (qui ouvre la page Catalogue
|
||||||
// dans la sidebar). Accordee aux roles metier via la matrice RBAC § 2.7.
|
// dans la sidebar). Accordee aux roles metier via la matrice RBAC § 2.7.
|
||||||
['code' => 'catalog.categories.read_ref', 'label' => 'Lire le referentiel categories (transverse, lecture seule)'],
|
['code' => 'catalog.categories.read_ref', 'label' => 'Lire le referentiel categories (transverse, lecture seule)'],
|
||||||
|
// Catalogue produit (M6, ERP-197) : admin-only (matrice docx p.3, C7).
|
||||||
|
// Item sidebar dans la section Administration, sous « Repertoire transporteurs ».
|
||||||
|
['code' => 'catalog.products.view', 'label' => 'Voir les produits'],
|
||||||
|
['code' => 'catalog.products.manage', 'label' => 'Gérer les produits (créer, éditer)'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\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\Catalog\Infrastructure\ApiPlatform\State\Processor\ProductProcessor;
|
||||||
|
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\ProductProvider;
|
||||||
|
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository;
|
||||||
|
use App\Module\Sites\Domain\Entity\Site;
|
||||||
|
use App\Shared\Domain\Attribute\Auditable;
|
||||||
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
|
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||||
|
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
|
|
||||||
|
use function in_array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produit du catalogue (M6 Catalog) — entite racine du module produit, jumelle de
|
||||||
|
* Category (#[Auditable], TimestampableBlamable, soft-delete) cote pattern et de
|
||||||
|
* Carrier (M4) / WeighingTicket (M5) cote contrat de serialisation (RETEX M1,
|
||||||
|
* 3 maillons — spec § 4.0).
|
||||||
|
*
|
||||||
|
* Contrat de serialisation :
|
||||||
|
* - LISTE (product:read + category:read + site:read + storage_type:read +
|
||||||
|
* default:read) : code (« Numero »), name, states, manufactured,
|
||||||
|
* containsMolasses, category embarquee, createdAt/updatedAt (via default:read).
|
||||||
|
* - DETAIL (+ product:item:read) : ajoute sites + storageTypes embarques
|
||||||
|
* (ensembles bornes -> embed autorise, ne viole pas la regle n°13). Le groupe
|
||||||
|
* product:item:read est reserve pour d'eventuels champs detail-only ulterieurs.
|
||||||
|
*
|
||||||
|
* Regles de gestion (renvoyees au Processor/Provider, ERP-200) :
|
||||||
|
* - RG-6.01 : `code` unique global parmi les actifs, normalise serveur (trim/UPPER),
|
||||||
|
* 409 sur doublon (index partiel uq_product_code_active).
|
||||||
|
* - RG-6.02 : `states` = sous-ensemble non vide de {PURCHASE, SALE, OTHER}.
|
||||||
|
* - RG-6.03 : `manufactured` / `containsMolasses` saisis uniquement si states
|
||||||
|
* contient SALE, sinon forces false serveur.
|
||||||
|
* - RG-6.04 : `sites` >= 1.
|
||||||
|
* - RG-6.05 : `category` de type PRODUIT (validee applicativement, Callback ERP-200).
|
||||||
|
* - RG-6.06 : `storageTypes` >= 1 (referentiel plat — plus de filtrage par site).
|
||||||
|
*
|
||||||
|
* Soft-delete prepare via `deletedAt` (non expose au M6, § 2.7) : pas de Delete
|
||||||
|
* dans les operations, la liste exclut les produits supprimes (Provider, ERP-200).
|
||||||
|
*
|
||||||
|
* Les RG inter-champs (RG-6.03/6.05) et l'unicite du code passent par le
|
||||||
|
* Processor + une contrainte d'entite Assert\Callback en ERP-200 (chaque 422
|
||||||
|
* porte un propertyPath exploitable par useFormErrors — mapping inline, ERP-101).
|
||||||
|
*
|
||||||
|
* NB : `Site` appartient au module Sites, consomme en relation ORM partagee
|
||||||
|
* (§ 2.1) — on reutilise son read-group `site:read`, sans logique inter-module.
|
||||||
|
* `Category` et `StorageType` sont dans le meme module Catalog.
|
||||||
|
*
|
||||||
|
* @see ProductProvider Lecture (liste paginee filtree soft-delete + item) — ERP-200.
|
||||||
|
* @see ProductProcessor Ecriture (normalisation, unicite code, RG-6.03/05/06) — ERP-200.
|
||||||
|
*/
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(
|
||||||
|
security: "is_granted('catalog.products.view')",
|
||||||
|
normalizationContext: ['groups' => ['product:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||||
|
provider: ProductProvider::class,
|
||||||
|
),
|
||||||
|
new Get(
|
||||||
|
security: "is_granted('catalog.products.view')",
|
||||||
|
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||||
|
provider: ProductProvider::class,
|
||||||
|
),
|
||||||
|
new Post(
|
||||||
|
security: "is_granted('catalog.products.manage')",
|
||||||
|
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||||
|
denormalizationContext: ['groups' => ['product:write']],
|
||||||
|
// Convertit les erreurs de denormalisation (type invalide / null sur une
|
||||||
|
// relation : category, sites, storageTypes) en violations 422 portant un
|
||||||
|
// propertyPath, au lieu d'un 400 qui court-circuite toute la validation
|
||||||
|
// (cf. Client/Supplier/WeighingTicket — mapping inline useFormErrors).
|
||||||
|
collectDenormalizationErrors: true,
|
||||||
|
processor: ProductProcessor::class,
|
||||||
|
),
|
||||||
|
new Patch(
|
||||||
|
security: "is_granted('catalog.products.manage')",
|
||||||
|
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||||
|
denormalizationContext: ['groups' => ['product:write']],
|
||||||
|
collectDenormalizationErrors: true,
|
||||||
|
provider: ProductProvider::class,
|
||||||
|
processor: ProductProcessor::class,
|
||||||
|
),
|
||||||
|
// Pas de Delete au M6 (docx) ; soft-delete prepare non expose (§ 2.7).
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ORM\Entity(repositoryClass: DoctrineProductRepository::class)]
|
||||||
|
#[ORM\Table(name: 'product')]
|
||||||
|
// Index nommes pour matcher la migration (cf. Category). L'index unique partiel
|
||||||
|
// `uq_product_code_active` (code WHERE deleted_at IS NULL — unicite GLOBALE du
|
||||||
|
// code parmi les actifs, RG-6.01) reste possede par la seule migration :
|
||||||
|
// Doctrine ORM ne sait pas exprimer un index partiel via attribut.
|
||||||
|
#[ORM\Index(name: 'idx_product_category', columns: ['category_id'])]
|
||||||
|
#[ORM\Index(name: 'idx_product_deleted_at', columns: ['deleted_at'])]
|
||||||
|
#[ORM\Index(name: 'idx_product_created_by', columns: ['created_by'])]
|
||||||
|
#[ORM\Index(name: 'idx_product_updated_by', columns: ['updated_by'])]
|
||||||
|
#[Auditable]
|
||||||
|
class Product implements TimestampableInterface, BlamableInterface
|
||||||
|
{
|
||||||
|
// === Timestampable + Blamable ===
|
||||||
|
// Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs
|
||||||
|
// getters/setters viennent du Trait Shared, remplies automatiquement par le
|
||||||
|
// TimestampableBlamableSubscriber au prePersist / preUpdate.
|
||||||
|
use TimestampableBlamableTrait;
|
||||||
|
|
||||||
|
/** Etats du produit (RG-6.02) — valeurs autorisees de la colonne JSONB `states`. */
|
||||||
|
public const string STATE_PURCHASE = 'PURCHASE';
|
||||||
|
public const string STATE_SALE = 'SALE';
|
||||||
|
public const string STATE_OTHER = 'OTHER';
|
||||||
|
|
||||||
|
/** Code de type de categorie autorise pour un produit (RG-6.05). */
|
||||||
|
private const string PRODUCT_CATEGORY_TYPE_CODE = 'PRODUIT';
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['product:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
// Code produit (= « Numero » de la liste), saisi, unique global parmi les
|
||||||
|
// actifs (RG-6.01). Normalise serveur (trim/UPPER) par le ProductProcessor.
|
||||||
|
#[ORM\Column(length: 50)]
|
||||||
|
#[Assert\NotBlank(message: 'Le code produit est obligatoire.', normalizer: 'trim')]
|
||||||
|
#[Assert\Length(max: 50, maxMessage: 'Le code produit ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||||
|
#[Groups(['product:read', 'product:write'])]
|
||||||
|
private ?string $code = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
#[Assert\NotBlank(message: 'Le nom du produit est obligatoire.', normalizer: 'trim')]
|
||||||
|
#[Assert\Length(max: 255, maxMessage: 'Le nom du produit ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||||
|
#[Groups(['product:read', 'product:write'])]
|
||||||
|
private ?string $name = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Etats du produit (multi-select), sous-ensemble non vide de
|
||||||
|
* {PURCHASE, SALE, OTHER} (RG-6.02). Stocke en JSONB (tableau de chaines),
|
||||||
|
* non-vacuite garantie aussi par le CHECK chk_product_states_not_empty.
|
||||||
|
*
|
||||||
|
* Validation des valeurs via Assert\Choice(multiple: true) plutot que
|
||||||
|
* Assert\All([Choice]) : equivalent fonctionnel, et seul Choice est gere par
|
||||||
|
* le garde-fou EntityConstraintsHaveFrenchMessageTest.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
// jsonb (pas json) : aligne le mapping ORM sur la colonne JSONB creee par la
|
||||||
|
// migration (spec § 2.3 + CHECK chk_product_states_not_empty via
|
||||||
|
// jsonb_array_length). Sans `options: ['jsonb' => true]`, schema:update tente
|
||||||
|
// un ALTER states TYPE JSON qui casse le CHECK (jsonb_array_length(json) inconnu)
|
||||||
|
// et fait echouer make db-reset / test-db-setup.
|
||||||
|
#[ORM\Column(type: 'json', options: ['jsonb' => true])]
|
||||||
|
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état (Achat, Vendu ou Autre).')]
|
||||||
|
#[Assert\Choice(
|
||||||
|
choices: [self::STATE_PURCHASE, self::STATE_SALE, self::STATE_OTHER],
|
||||||
|
multiple: true,
|
||||||
|
message: 'État de produit invalide.',
|
||||||
|
multipleMessage: 'État de produit invalide.',
|
||||||
|
)]
|
||||||
|
#[Groups(['product:read', 'product:write'])]
|
||||||
|
private array $states = [];
|
||||||
|
|
||||||
|
// « Fabrique » : saisi uniquement si states contient SALE, sinon force false
|
||||||
|
// serveur (RG-6.03).
|
||||||
|
#[ORM\Column(options: ['default' => false])]
|
||||||
|
#[Groups(['product:read', 'product:write'])]
|
||||||
|
private bool $manufactured = false;
|
||||||
|
|
||||||
|
// « Contient de la melasse » : saisi uniquement si states contient SALE,
|
||||||
|
// sinon force false serveur (RG-6.03).
|
||||||
|
#[ORM\Column(name: 'contains_molasses', options: ['default' => false])]
|
||||||
|
#[Groups(['product:read', 'product:write'])]
|
||||||
|
private bool $containsMolasses = false;
|
||||||
|
|
||||||
|
// Categorie produit (obligatoire). Limitee aux categories de type PRODUIT,
|
||||||
|
// validee applicativement (RG-6.05, Callback ERP-200). FK ON DELETE RESTRICT :
|
||||||
|
// une categorie referencee par un produit ne peut etre supprimee.
|
||||||
|
#[ORM\ManyToOne(targetEntity: Category::class)]
|
||||||
|
#[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||||
|
#[Assert\NotNull(message: 'La catégorie produit est obligatoire.')]
|
||||||
|
#[Groups(['product:read', 'product:write'])]
|
||||||
|
private ?Category $category = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sites de disponibilite du produit (>= 1, RG-6.04). Relation ORM partagee
|
||||||
|
* vers Site (module Sites, § 2.1). Cote inverse en ON DELETE RESTRICT : un
|
||||||
|
* site reference par un produit ne peut etre supprime.
|
||||||
|
*
|
||||||
|
* @var Collection<int, Site>
|
||||||
|
*/
|
||||||
|
#[ORM\ManyToMany(targetEntity: Site::class)]
|
||||||
|
#[ORM\JoinTable(name: 'product_site')]
|
||||||
|
#[ORM\JoinColumn(name: 'product_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||||
|
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||||
|
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un site.')]
|
||||||
|
#[Groups(['product:read', 'product:write'])]
|
||||||
|
private Collection $sites;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types de stockage du produit (>= 1, RG-6.06). Referentiel plat : tous les
|
||||||
|
* types sont selectionnables (plus de filtrage par site). Cote inverse en
|
||||||
|
* ON DELETE RESTRICT : un type reference par un produit ne peut etre supprime.
|
||||||
|
*
|
||||||
|
* @var Collection<int, StorageType>
|
||||||
|
*/
|
||||||
|
#[ORM\ManyToMany(targetEntity: StorageType::class)]
|
||||||
|
#[ORM\JoinTable(name: 'product_storage_type')]
|
||||||
|
#[ORM\JoinColumn(name: 'product_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||||
|
#[ORM\InverseJoinColumn(name: 'storage_type_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||||
|
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de stockage.')]
|
||||||
|
#[Groups(['product:read', 'product:write'])]
|
||||||
|
private Collection $storageTypes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete technique : null = actif, valeur = supprime logiquement le {date}.
|
||||||
|
* Non expose au M6 (§ 2.7, aucun groupe) : prepare pour une future suppression
|
||||||
|
* (HP-M6-04). La liste exclut par defaut les produits supprimes (Provider).
|
||||||
|
*/
|
||||||
|
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
|
||||||
|
private ?DateTimeImmutable $deletedAt = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->sites = new ArrayCollection();
|
||||||
|
$this->storageTypes = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCode(): ?string
|
||||||
|
{
|
||||||
|
return $this->code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCode(string $code): static
|
||||||
|
{
|
||||||
|
$this->code = $code;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function getStates(): array
|
||||||
|
{
|
||||||
|
return $this->states;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $states
|
||||||
|
*/
|
||||||
|
public function setStates(array $states): static
|
||||||
|
{
|
||||||
|
$this->states = $states;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isManufactured(): bool
|
||||||
|
{
|
||||||
|
return $this->manufactured;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setManufactured(bool $manufactured): static
|
||||||
|
{
|
||||||
|
$this->manufactured = $manufactured;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isContainsMolasses(): bool
|
||||||
|
{
|
||||||
|
return $this->containsMolasses;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setContainsMolasses(bool $containsMolasses): static
|
||||||
|
{
|
||||||
|
$this->containsMolasses = $containsMolasses;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCategory(): ?Category
|
||||||
|
{
|
||||||
|
return $this->category;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCategory(?Category $category): static
|
||||||
|
{
|
||||||
|
$this->category = $category;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Site>
|
||||||
|
*/
|
||||||
|
public function getSites(): Collection
|
||||||
|
{
|
||||||
|
return $this->sites;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addSite(Site $site): static
|
||||||
|
{
|
||||||
|
if (!$this->sites->contains($site)) {
|
||||||
|
$this->sites->add($site);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeSite(Site $site): static
|
||||||
|
{
|
||||||
|
$this->sites->removeElement($site);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, StorageType>
|
||||||
|
*/
|
||||||
|
public function getStorageTypes(): Collection
|
||||||
|
{
|
||||||
|
return $this->storageTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addStorageType(StorageType $storageType): static
|
||||||
|
{
|
||||||
|
if (!$this->storageTypes->contains($storageType)) {
|
||||||
|
$this->storageTypes->add($storageType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeStorageType(StorageType $storageType): static
|
||||||
|
{
|
||||||
|
$this->storageTypes->removeElement($storageType);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeletedAt(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->deletedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
|
||||||
|
{
|
||||||
|
$this->deletedAt = $deletedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-6.05 : la categorie d'un produit doit etre de type PRODUIT. Validee
|
||||||
|
* applicativement (pas de contrainte SQL au referentiel, § 2.5) via Callback
|
||||||
|
* + ->atPath('category') pour que la 422 porte un propertyPath consommable par
|
||||||
|
* useFormErrors (mapping inline, ERP-101). Le NotNull gere l'absence : on ne
|
||||||
|
* leve que si une categorie est presente ET non-PRODUIT.
|
||||||
|
*/
|
||||||
|
#[Assert\Callback]
|
||||||
|
public function validateCategoryIsProductType(ExecutionContextInterface $context): void
|
||||||
|
{
|
||||||
|
if (null === $this->category) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array(self::PRODUCT_CATEGORY_TYPE_CODE, $this->category->getCategoryTypeCodes(), true)) {
|
||||||
|
$context->buildViolation('La catégorie sélectionnée doit être de type Produit.')
|
||||||
|
->atPath('category')
|
||||||
|
->addViolation()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Domain\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\StorageTypeProvider;
|
||||||
|
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageTypeRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type de stockage : referentiel PROVISOIRE classifiant ou un produit peut etre
|
||||||
|
* stocke (ex: TAS, CELLULE, CUVE_MELASSE). Cree au M6 en attendant la liste
|
||||||
|
* definitive d'Aurore (HP-M6-02 / ERP-201).
|
||||||
|
*
|
||||||
|
* Referentiel PLAT : un type de stockage n'est PAS rattache a des sites. La
|
||||||
|
* disponibilite « tel type sur tel site » releve de la future entite Stockage
|
||||||
|
* (module Stockage : un stockage = 1 site + 1 type) et sera derivee des stockages
|
||||||
|
* reels, pas portee par ce referentiel. Le multi-select « Type de stockage » du
|
||||||
|
* formulaire produit liste donc TOUS les types, sans filtrage par site (RG-6.06).
|
||||||
|
*
|
||||||
|
* Lecture seule au M6 : seules les operations GetCollection et Get sont exposees
|
||||||
|
* (CRUD admin = hors perimetre HP-M6-03), sous la permission `catalog.products.view`
|
||||||
|
* (referentiel servant le formulaire produit — § 4.2).
|
||||||
|
*
|
||||||
|
* Referentiel statique : pas de Timestampable/Blamable ni `#[Auditable]`
|
||||||
|
* (whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED, miroir
|
||||||
|
* CategoryType — cree par migration/seed, pas pilote utilisateur). Le groupe
|
||||||
|
* `storage_type:read` est porte par chaque propriete affichee pour que le type
|
||||||
|
* soit embarque dans la reponse d'un Product (cf. .claude/rules/backend.md
|
||||||
|
* § Serialization).
|
||||||
|
*/
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
// Tri label ASC porte par le StorageTypeProvider : alimente le multi-select
|
||||||
|
// « Type de stockage » du formulaire produit (TOUS les types — referentiel
|
||||||
|
// plat). Pagination Hydra + echappatoire ?pagination=false (referentiel borne).
|
||||||
|
new GetCollection(
|
||||||
|
security: "is_granted('catalog.products.view')",
|
||||||
|
normalizationContext: ['groups' => ['storage_type:read']],
|
||||||
|
provider: StorageTypeProvider::class,
|
||||||
|
),
|
||||||
|
new Get(
|
||||||
|
security: "is_granted('catalog.products.view')",
|
||||||
|
normalizationContext: ['groups' => ['storage_type:read']],
|
||||||
|
provider: StorageTypeProvider::class,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ORM\Entity(repositoryClass: DoctrineStorageTypeRepository::class)]
|
||||||
|
#[ORM\Table(name: 'storage_type')]
|
||||||
|
// Contrainte d'unicite nommee pour matcher la migration (cf. CategoryType).
|
||||||
|
#[ORM\UniqueConstraint(name: 'uq_storage_type_code', columns: ['code'])]
|
||||||
|
class StorageType
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['storage_type:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 40)]
|
||||||
|
#[Groups(['storage_type:read'])]
|
||||||
|
private ?string $code = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 120)]
|
||||||
|
#[Groups(['storage_type:read'])]
|
||||||
|
private ?string $label = null;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCode(): ?string
|
||||||
|
{
|
||||||
|
return $this->code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCode(string $code): static
|
||||||
|
{
|
||||||
|
$this->code = $code;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLabel(): ?string
|
||||||
|
{
|
||||||
|
return $this->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLabel(string $label): static
|
||||||
|
{
|
||||||
|
$this->label = $label;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Domain\Repository;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Entity\Product;
|
||||||
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
|
||||||
|
interface ProductRepositoryInterface
|
||||||
|
{
|
||||||
|
public function findById(int $id): ?Product;
|
||||||
|
|
||||||
|
public function save(Product $product): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai si un produit actif (deleted_at IS NULL) porte deja ce code.
|
||||||
|
* `$excludeId` exclut un produit precis du test (cas PATCH). Garantit
|
||||||
|
* l'unicite GLOBALE du code parmi les actifs (RG-6.01, index partiel
|
||||||
|
* uq_product_code_active). Un code reutilisable apres soft-delete (le test
|
||||||
|
* ignore les supprimes).
|
||||||
|
*/
|
||||||
|
public function existsActiveByCode(string $code, ?int $excludeId = null): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QueryBuilder de la liste produits (consomme par le ProductProvider) : exclut
|
||||||
|
* par defaut les soft-deleted (RG-6.09), trie par name ASC (defaut spec § 4.1)
|
||||||
|
* et applique les filtres optionnels du drawer « Filtrer » :
|
||||||
|
* - `$search` : recherche partielle case-insensitive sur `code` + `name`.
|
||||||
|
* - `$categoryId` : restreint a une categorie precise (par id).
|
||||||
|
* - `$categoryCode` : restreint a une categorie precise (par code stable).
|
||||||
|
* - `$state` : appartenance a la colonne JSONB `states` (PURCHASE|SALE|OTHER).
|
||||||
|
* - `$siteIds` : produit disponible sur AU MOINS UN des sites passes.
|
||||||
|
*
|
||||||
|
* @param list<int> $siteIds
|
||||||
|
*/
|
||||||
|
public function createListQueryBuilder(
|
||||||
|
bool $includeDeleted = false,
|
||||||
|
?string $search = null,
|
||||||
|
?int $categoryId = null,
|
||||||
|
?string $categoryCode = null,
|
||||||
|
?string $state = null,
|
||||||
|
array $siteIds = [],
|
||||||
|
): QueryBuilder;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Domain\Repository;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||||
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
|
||||||
|
interface StorageTypeRepositoryInterface
|
||||||
|
{
|
||||||
|
public function findById(int $id): ?StorageType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tous les types de stockage tries par libelle (alimente le multi-select du
|
||||||
|
* formulaire produit — § 4.2).
|
||||||
|
*
|
||||||
|
* @return list<StorageType>
|
||||||
|
*/
|
||||||
|
public function findAllOrderedByLabel(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QueryBuilder de la liste des types de stockage (consomme par le
|
||||||
|
* StorageTypeProvider) : tri `label ASC` (defaut spec § 4.2). Referentiel plat :
|
||||||
|
* plus de filtrage par site (la dispo par site releve du futur module Stockage).
|
||||||
|
*/
|
||||||
|
public function createListQueryBuilder(): QueryBuilder;
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Module\Catalog\Application\Service\ProductFieldNormalizer;
|
||||||
|
use App\Module\Catalog\Domain\Entity\Product;
|
||||||
|
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
|
||||||
|
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
use function in_array;
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processor d'ecriture du produit (M6, POST / PATCH). Cf. spec-back M6 § 4.3 /
|
||||||
|
* § 4.4 + RG-6.01 / RG-6.03 / RG-6.07. Jumeau du CategoryProcessor (409 doublon)
|
||||||
|
* et du CarrierProcessor (normalisation serveur).
|
||||||
|
*
|
||||||
|
* Sequence (POST / PATCH) :
|
||||||
|
* 1. Normalisation serveur (RG-6.07) via ProductFieldNormalizer : code trim+UPPER,
|
||||||
|
* name trim. Jouee AVANT l'unicite et la persistance ; la validation
|
||||||
|
* (NotBlank/Length + Callback RG-6.05/6.06) a deja joue cote API Platform sur
|
||||||
|
* la saisie brute.
|
||||||
|
* 2. RG-6.03 : champs conditionnels SALE. Si `states` ne contient pas SALE,
|
||||||
|
* `manufactured` et `containsMolasses` sont forces false serveur (ils ne sont
|
||||||
|
* saisissables que si l'etat contient SALE).
|
||||||
|
* 3. RG-6.01 : unicite GLOBALE du `code` parmi les actifs. Pre-check deterministe
|
||||||
|
* (excluant le produit courant en PATCH) -> 409 ; l'index partiel
|
||||||
|
* uq_product_code_active reste le filet anti-race au flush.
|
||||||
|
* 4. Persistance via le persist_processor Doctrine ORM.
|
||||||
|
*
|
||||||
|
* Mode strict PATCH (RETEX M1) : la security d'operation exige deja
|
||||||
|
* `catalog.products.manage` pour TOUS les champs ecrivables (un seul niveau de
|
||||||
|
* permission au M6 — § 5.2 admin-only). Il n'existe donc aucun champ « hors-permission »
|
||||||
|
* a re-gater finement (contrairement a l'archivage Carrier RG-4.14 ou au split
|
||||||
|
* comptable Client RG-1.28) : le 403 global est porte par la security d'operation,
|
||||||
|
* pas par un guard de champ ici.
|
||||||
|
*
|
||||||
|
* Les RG inter-champs RG-6.05 (categorie de type PRODUIT) et RG-6.06 (types de
|
||||||
|
* stockage disponibles sur les sites choisis) sont portees par des Assert\Callback
|
||||||
|
* + ->atPath() sur l'entite Product (jouees par API Platform AVANT ce processor),
|
||||||
|
* pour que chaque 422 porte un propertyPath consommable par useFormErrors (mapping
|
||||||
|
* inline, pas un toast — convention ERP-101).
|
||||||
|
*
|
||||||
|
* @implements ProcessorInterface<Product, Product>
|
||||||
|
*/
|
||||||
|
final class ProductProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||||
|
private readonly ProcessorInterface $persistProcessor,
|
||||||
|
private readonly ProductFieldNormalizer $normalizer,
|
||||||
|
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository')]
|
||||||
|
private readonly ProductRepositoryInterface $repository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||||
|
{
|
||||||
|
if (!$data instanceof Product) {
|
||||||
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. RG-6.07 : normalisation serveur (code trim+UPPER, name trim).
|
||||||
|
$this->normalize($data);
|
||||||
|
|
||||||
|
// 2. RG-6.03 : si l'etat ne contient pas SALE, les champs conditionnels
|
||||||
|
// « Fabrique » / « Contient de la melasse » sont forces false serveur.
|
||||||
|
if (!in_array(Product::STATE_SALE, $data->getStates(), true)) {
|
||||||
|
$data->setManufactured(false);
|
||||||
|
$data->setContainsMolasses(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. RG-6.01 : unicite GLOBALE du code parmi les actifs (exclut le produit
|
||||||
|
// courant en PATCH). Pre-check explicite -> 409 deterministe.
|
||||||
|
$code = (string) $data->getCode();
|
||||||
|
if ('' !== $code && $this->repository->existsActiveByCode($code, $data->getId())) {
|
||||||
|
throw $this->duplicateCodeConflict($code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Persistance, avec filet anti-race sur l'index partiel.
|
||||||
|
try {
|
||||||
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
} catch (UniqueConstraintViolationException $e) {
|
||||||
|
// Insertion concurrente du meme code entre le pre-check et le flush
|
||||||
|
// (collision sur uq_product_code_active — unicite parmi les actifs).
|
||||||
|
throw $this->duplicateCodeConflict($code, $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalisation serveur du produit (RG-6.07). Les setters ne sont touches que si
|
||||||
|
* une valeur est presente, pour ne jamais ecraser l'existant lors d'un PATCH
|
||||||
|
* partiel. Les casts (string) sont surs : NotBlank a deja rejete le vide en amont.
|
||||||
|
*/
|
||||||
|
private function normalize(Product $data): void
|
||||||
|
{
|
||||||
|
if (null !== $data->getCode()) {
|
||||||
|
$data->setCode((string) $this->normalizer->normalizeCode($data->getCode()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $data->getName()) {
|
||||||
|
$data->setName((string) $this->normalizer->normalizeName($data->getName()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-6.01 : 409 sur doublon de code produit. Le front mappe ce conflit sur le
|
||||||
|
* champ `code` (setError('code', ...) + toast — convention useFormErrors ERP-101
|
||||||
|
* / useCategoryForm RG-1.07) : le propertyPath exploitable est `code`.
|
||||||
|
*/
|
||||||
|
private function duplicateCodeConflict(string $code, ?Throwable $previous = null): ConflictHttpException
|
||||||
|
{
|
||||||
|
return new ConflictHttpException(
|
||||||
|
sprintf('Le code produit « %s » est déjà utilisé par un autre produit.', $code),
|
||||||
|
$previous,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||||
|
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\Pagination\Pagination;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Module\Catalog\Domain\Entity\Product;
|
||||||
|
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
|
||||||
|
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
use function in_array;
|
||||||
|
use function is_int;
|
||||||
|
use function is_string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider Product (lecture, ERP-200) :
|
||||||
|
* - LISTE : exclut par defaut les produits soft-deleted (RG-6.09), trie par
|
||||||
|
* name ASC (defaut spec § 4.1), applique les filtres du drawer « Filtrer »
|
||||||
|
* (?search, ?categoryId / ?categoryCode, ?state, ?siteId[]) et renvoie une
|
||||||
|
* collection PAGINEE Hydra (regle ABSOLUE n°13 : jamais d'array brut sur une
|
||||||
|
* operation de collection — on enveloppe le QueryBuilder dans le Paginator ORM).
|
||||||
|
* Echappatoire ?pagination=false respectee (alimentation d'un select).
|
||||||
|
* - ITEM : recharge le produit puis renvoie null (404) s'il est soft-deleted —
|
||||||
|
* le soft-delete n'est jamais expose au M6 (§ 2.7), aucun flag includeDeleted.
|
||||||
|
*
|
||||||
|
* @implements ProviderInterface<Product>
|
||||||
|
*/
|
||||||
|
final class ProductProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
/** Etats valides du filtre ?state= (enum borne, RG-6.02). */
|
||||||
|
private const array VALID_STATES = [Product::STATE_PURCHASE, Product::STATE_SALE, Product::STATE_OTHER];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository')]
|
||||||
|
private readonly ProductRepositoryInterface $repository,
|
||||||
|
private readonly Pagination $pagination,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Product|null
|
||||||
|
{
|
||||||
|
if ($operation instanceof CollectionOperationInterface) {
|
||||||
|
// includeDeleted toujours false : le soft-delete n'est pas expose au M6.
|
||||||
|
$qb = $this->repository->createListQueryBuilder(
|
||||||
|
false,
|
||||||
|
$this->readSearch($context),
|
||||||
|
$this->readCategoryId($context),
|
||||||
|
$this->readCategoryCode($context),
|
||||||
|
$this->readState($context),
|
||||||
|
$this->readSiteIds($context),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Echappatoire ?pagination=false : collection complete sans Paginator.
|
||||||
|
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||||
|
return $qb->getQuery()->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branche paginee standard : offset/limit via Pagination, enveloppe dans
|
||||||
|
// le Paginator ORM (fetchJoinCollection: true pour compter correctement
|
||||||
|
// malgre les fetch-joins to-many sites/storageTypes du QueryBuilder).
|
||||||
|
$limit = $this->pagination->getLimit($operation, $context);
|
||||||
|
$page = max(1, $this->pagination->getPage($context));
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
|
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||||
|
|
||||||
|
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete.
|
||||||
|
$id = $uriVariables['id'] ?? null;
|
||||||
|
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$product = $this->repository->findById((int) $id);
|
||||||
|
if (null === $product) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// § 2.7 : un produit soft-deleted n'est jamais expose (404).
|
||||||
|
if (null !== $product->getDeletedAt()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $product;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit le filtre `?search=` (recherche partielle code + name). Renvoie la valeur
|
||||||
|
* trimmee ou null si absente / vide.
|
||||||
|
*/
|
||||||
|
private function readSearch(array $context): ?string
|
||||||
|
{
|
||||||
|
$raw = $context['filters']['search'] ?? null;
|
||||||
|
|
||||||
|
if (!is_string($raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = trim($raw);
|
||||||
|
|
||||||
|
return '' === $raw ? null : $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit le filtre `?categoryId=` (drawer « Filtrer »). Renvoie l'id entier ou null
|
||||||
|
* si absent / non numerique.
|
||||||
|
*/
|
||||||
|
private function readCategoryId(array $context): ?int
|
||||||
|
{
|
||||||
|
$raw = $context['filters']['categoryId'] ?? null;
|
||||||
|
|
||||||
|
if (is_int($raw)) {
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_string($raw) && ctype_digit($raw) ? (int) $raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit le filtre `?categoryCode=` (drawer « Filtrer »). Renvoie le code trimme ou
|
||||||
|
* null si absent / vide.
|
||||||
|
*/
|
||||||
|
private function readCategoryCode(array $context): ?string
|
||||||
|
{
|
||||||
|
$raw = $context['filters']['categoryCode'] ?? null;
|
||||||
|
|
||||||
|
if (!is_string($raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = trim($raw);
|
||||||
|
|
||||||
|
return '' === $raw ? null : $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit le filtre `?state=` (PURCHASE / SALE / OTHER). Normalise en majuscules et
|
||||||
|
* n'accepte qu'une valeur de l'enum borne ; toute autre valeur est ignoree (null).
|
||||||
|
*/
|
||||||
|
private function readState(array $context): ?string
|
||||||
|
{
|
||||||
|
$raw = $context['filters']['state'] ?? null;
|
||||||
|
|
||||||
|
if (!is_string($raw) || '' === trim($raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = mb_strtoupper(trim($raw), 'UTF-8');
|
||||||
|
|
||||||
|
return in_array($state, self::VALID_STATES, true) ? $state : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit le filtre `?siteId[]=` : ids des sites coches (OR). Tolere une valeur
|
||||||
|
* scalaire unique (`?siteId=1`) ou un tableau. Ignore les entrees non numeriques.
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function readSiteIds(array $context): array
|
||||||
|
{
|
||||||
|
$raw = $context['filters']['siteId'] ?? null;
|
||||||
|
|
||||||
|
if (null === $raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$values = is_array($raw) ? $raw : [$raw];
|
||||||
|
|
||||||
|
$ids = [];
|
||||||
|
foreach ($values as $value) {
|
||||||
|
if (is_int($value) || (is_string($value) && ctype_digit($value))) {
|
||||||
|
$ids[] = (int) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($ids));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||||
|
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\Pagination\Pagination;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||||
|
use App\Module\Catalog\Domain\Repository\StorageTypeRepositoryInterface;
|
||||||
|
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
use function is_int;
|
||||||
|
use function is_string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider StorageType (referentiel plat lecture seule) :
|
||||||
|
* - LISTE : tri `label ASC` (defaut spec § 4.2) et collection PAGINEE Hydra
|
||||||
|
* (regle ABSOLUE n°13). Echappatoire `?pagination=false` respectee pour
|
||||||
|
* alimenter le multi-select « Type de stockage » du formulaire produit avec
|
||||||
|
* TOUS les types (referentiel borne — pagination_client_enabled). Plus de
|
||||||
|
* filtrage par site : la dispo par site releve du futur module Stockage.
|
||||||
|
* - ITEM : lookup simple par id.
|
||||||
|
*
|
||||||
|
* @implements ProviderInterface<StorageType>
|
||||||
|
*/
|
||||||
|
final class StorageTypeProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageTypeRepository')]
|
||||||
|
private readonly StorageTypeRepositoryInterface $repository,
|
||||||
|
private readonly Pagination $pagination,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|StorageType|null
|
||||||
|
{
|
||||||
|
if ($operation instanceof CollectionOperationInterface) {
|
||||||
|
$qb = $this->repository->createListQueryBuilder();
|
||||||
|
|
||||||
|
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||||
|
// (alimentation du multi-select, referentiel borne).
|
||||||
|
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||||
|
return $qb->getQuery()->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = $this->pagination->getLimit($operation, $context);
|
||||||
|
$page = max(1, $this->pagination->getPage($context));
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
|
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||||
|
|
||||||
|
// Pas de fetch-join to-many (sites non serialisee) -> Paginator simple.
|
||||||
|
return new Paginator(new DoctrinePaginator($qb->getQuery()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unitaire.
|
||||||
|
$id = $uriVariables['id'] ?? null;
|
||||||
|
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->repository->findById((int) $id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Infrastructure\Controller;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Entity\Product;
|
||||||
|
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||||
|
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
|
||||||
|
use App\Module\Sites\Domain\Entity\Site;
|
||||||
|
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
use function in_array;
|
||||||
|
use function is_int;
|
||||||
|
use function is_string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export XLSX du catalogue produits (M6, spec-back § 4.5). Jumeau des controllers
|
||||||
|
* d'export ClientExportController (M1) / CarrierExportController (M4) — references
|
||||||
|
* en prose volontairement (pas de {@see} inter-module : violerait la regle
|
||||||
|
* ABSOLUE n°1).
|
||||||
|
*
|
||||||
|
* Controller Symfony custom (et non operation API Platform) car il produit un
|
||||||
|
* binaire de fichier, pas une representation Hydra. `priority: 1` est OBLIGATOIRE
|
||||||
|
* sur la route : sans cela API Platform capterait `/api/products/export.xlsx`
|
||||||
|
* comme l'item `GET /api/products/{id}.{_format}` (id="export", _format="xlsx")
|
||||||
|
* — cf. CLAUDE.md « controller custom sous /api ».
|
||||||
|
*
|
||||||
|
* Separation des responsabilites :
|
||||||
|
* - le COMMENT (generation du fichier) est delegue au service Shared
|
||||||
|
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
|
||||||
|
* - le QUOI vit ICI : selection des produits (MEMES filtres que
|
||||||
|
* `GET /api/products` via {@see ProductProvider}, deleguee a
|
||||||
|
* {@see ProductRepositoryInterface::createListQueryBuilder()} — l'export
|
||||||
|
* reflete exactement ce que l'utilisateur voit a l'ecran) et mapping metier
|
||||||
|
* des colonnes. Les produits soft-deleted (RG-6.09) sont toujours exclus, comme
|
||||||
|
* en liste (le M6 n'expose jamais le soft-delete, § 2.7).
|
||||||
|
*/
|
||||||
|
#[AsController]
|
||||||
|
final class ProductExportController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Libelles FR des etats (RG-6.02) pour la colonne « États ». L'ordre des cles
|
||||||
|
* fixe l'ordre d'affichage (Achat, Vendu, Autre) independamment de l'ordre de
|
||||||
|
* stockage en base.
|
||||||
|
*/
|
||||||
|
private const array STATE_LABELS = [
|
||||||
|
Product::STATE_PURCHASE => 'Achat',
|
||||||
|
Product::STATE_SALE => 'Vendu',
|
||||||
|
Product::STATE_OTHER => 'Autre',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository')]
|
||||||
|
private readonly ProductRepositoryInterface $repository,
|
||||||
|
private readonly SpreadsheetExporterInterface $exporter,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route('/api/products/export.xlsx', name: 'catalog_products_export_xlsx', methods: ['GET'], priority: 1)]
|
||||||
|
#[IsGranted('catalog.products.view')]
|
||||||
|
public function __invoke(Request $request): Response
|
||||||
|
{
|
||||||
|
// Memes filtres que la vue liste (ProductProvider) pour que l'export
|
||||||
|
// reflete exactement ce que l'utilisateur voit a l'ecran : recherche
|
||||||
|
// (?search), categorie (?categoryId / ?categoryCode), etat (?state),
|
||||||
|
// sites (?siteId[]). includeDeleted reste false : le soft-delete n'est
|
||||||
|
// jamais expose au M6 (§ 2.7).
|
||||||
|
$search = $request->query->getString('search') ?: null;
|
||||||
|
$categoryId = $this->readIntOrNull($request->query->get('categoryId'));
|
||||||
|
$categoryCode = $request->query->getString('categoryCode') ?: null;
|
||||||
|
$state = $this->readState($request->query->get('state'));
|
||||||
|
$siteIds = $this->readIntList($request->query->all()['siteId'] ?? []);
|
||||||
|
|
||||||
|
/** @var list<Product> $products */
|
||||||
|
$products = $this->repository
|
||||||
|
->createListQueryBuilder(false, $search, $categoryId, $categoryCode, $state, $siteIds)
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
|
||||||
|
$binary = $this->exporter->export(
|
||||||
|
'Catalogue produits',
|
||||||
|
$this->buildHeaders(),
|
||||||
|
$this->buildRows($products),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->buildResponse($binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colonnes de l'export (spec § 4.5).
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function buildHeaders(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Numéro',
|
||||||
|
'Nom',
|
||||||
|
'États',
|
||||||
|
'Catégorie',
|
||||||
|
'Sites',
|
||||||
|
'Types de stockage',
|
||||||
|
'Fabriqué',
|
||||||
|
'Contient mélasse',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Product> $products
|
||||||
|
*
|
||||||
|
* @return iterable<list<null|scalar>>
|
||||||
|
*/
|
||||||
|
private function buildRows(array $products): iterable
|
||||||
|
{
|
||||||
|
foreach ($products as $product) {
|
||||||
|
yield [
|
||||||
|
$product->getCode(),
|
||||||
|
$product->getName(),
|
||||||
|
$this->formatStates($product),
|
||||||
|
$product->getCategory()?->getName(),
|
||||||
|
$this->formatSites($product),
|
||||||
|
$this->formatStorageTypes($product),
|
||||||
|
$product->isManufactured() ? 'Oui' : 'Non',
|
||||||
|
$product->isContainsMolasses() ? 'Oui' : 'Non',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Libelles FR des etats du produit, dans l'ordre canonique (Achat, Vendu,
|
||||||
|
* Autre), joints par virgule. Une valeur inattendue est ignoree.
|
||||||
|
*/
|
||||||
|
private function formatStates(Product $product): string
|
||||||
|
{
|
||||||
|
$states = $product->getStates();
|
||||||
|
|
||||||
|
$labels = [];
|
||||||
|
foreach (self::STATE_LABELS as $code => $label) {
|
||||||
|
if (in_array($code, $states, true)) {
|
||||||
|
$labels[] = $label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(', ', $labels);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Libelles des sites de disponibilite du produit, dedupliques, tries, joints
|
||||||
|
* par virgule.
|
||||||
|
*/
|
||||||
|
private function formatSites(Product $product): string
|
||||||
|
{
|
||||||
|
$names = [];
|
||||||
|
foreach ($product->getSites() as $site) {
|
||||||
|
// @var Site $site
|
||||||
|
$name = $site->getName();
|
||||||
|
if (null !== $name && '' !== $name) {
|
||||||
|
$names[$name] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->joinSorted($names);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Libelles des types de stockage du produit, dedupliques, tries, joints par
|
||||||
|
* virgule.
|
||||||
|
*/
|
||||||
|
private function formatStorageTypes(Product $product): string
|
||||||
|
{
|
||||||
|
$labels = [];
|
||||||
|
foreach ($product->getStorageTypes() as $storageType) {
|
||||||
|
// @var StorageType $storageType
|
||||||
|
$label = $storageType->getLabel();
|
||||||
|
if (null !== $label && '' !== $label) {
|
||||||
|
$labels[$label] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->joinSorted($labels);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, true> $names ensemble de libelles (cles)
|
||||||
|
*/
|
||||||
|
private function joinSorted(array $names): string
|
||||||
|
{
|
||||||
|
$list = array_keys($names);
|
||||||
|
sort($list);
|
||||||
|
|
||||||
|
return implode(', ', $list);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildResponse(string $binary): Response
|
||||||
|
{
|
||||||
|
$filename = sprintf('catalogue-produits-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
|
||||||
|
|
||||||
|
$response = new Response($binary);
|
||||||
|
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit le filtre `?state=` comme le ProductProvider : normalise en majuscules
|
||||||
|
* et n'accepte qu'une valeur de l'enum borne {PURCHASE, SALE, OTHER} ; toute
|
||||||
|
* autre valeur est ignoree (null).
|
||||||
|
*/
|
||||||
|
private function readState(mixed $raw): ?string
|
||||||
|
{
|
||||||
|
if (!is_string($raw) || '' === trim($raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = mb_strtoupper(trim($raw), 'UTF-8');
|
||||||
|
|
||||||
|
return in_array($state, array_keys(self::STATE_LABELS), true) ? $state : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit un identifiant entier positif unique (`?categoryId=`). Aligne sur
|
||||||
|
* ProductProvider (tolere int ou chaine numerique).
|
||||||
|
*/
|
||||||
|
private function readIntOrNull(mixed $raw): ?int
|
||||||
|
{
|
||||||
|
if (is_int($raw)) {
|
||||||
|
return $raw > 0 ? $raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_string($raw) && ctype_digit($raw) && (int) $raw > 0 ? (int) $raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise un filtre en liste d'identifiants entiers positifs (valeur unique
|
||||||
|
* ou liste, `?siteId[]=`). Aligne sur ProductProvider.
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function readIntList(mixed $raw): array
|
||||||
|
{
|
||||||
|
$values = is_array($raw) ? $raw : [$raw];
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($values as $value) {
|
||||||
|
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
|
||||||
|
$out[] = (int) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,8 +18,11 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|||||||
* a leur CategoryType. Le type CLIENT porte ~11 categories clients (refonte
|
* a leur CategoryType. Le type CLIENT porte ~11 categories clients (refonte
|
||||||
* taxonomie ERP-78) ; le type FOURNISSEUR porte les categories fournisseurs
|
* taxonomie ERP-78) ; le type FOURNISSEUR porte les categories fournisseurs
|
||||||
* (ERP-84 : Negociant, Cooperative...) ; le type PRESTATAIRE porte les categories
|
* (ERP-84 : Negociant, Cooperative...) ; le type PRESTATAIRE porte les categories
|
||||||
* prestataires (M3 1.1 : Maintenance industrielle, Nettoyage, Transport). Chaque
|
* prestataires (M3 1.1 : Maintenance industrielle, Nettoyage, Transport) ; le type
|
||||||
* categorie porte un `code` stable.
|
* ADRESSE porte les categories des blocs adresse (Siege, Contact issues,
|
||||||
|
* Facturation, Livraison, Approvisionnement, Methaniseur) ; le type PRODUIT porte
|
||||||
|
* les categories produit du catalogue (M6 ERP-201 : Cereales, Oleagineux, Aliments
|
||||||
|
* du betail, Engrais). Chaque categorie porte un `code` stable.
|
||||||
* Alimente le repertoire clients (ClientFixtures, module Commercial) avec des
|
* Alimente le repertoire clients (ClientFixtures, module Commercial) avec des
|
||||||
* donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29
|
* donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29
|
||||||
* (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2).
|
* (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2).
|
||||||
@@ -78,6 +81,23 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
|
|||||||
'Nettoyage' => 'NETTOYAGE',
|
'Nettoyage' => 'NETTOYAGE',
|
||||||
'Transport' => 'TRANSPORT',
|
'Transport' => 'TRANSPORT',
|
||||||
],
|
],
|
||||||
|
'ADRESSE' => [
|
||||||
|
'Siège' => 'SIEGE',
|
||||||
|
'Contact issues' => 'CONTACT_ISSUES',
|
||||||
|
'Facturation' => 'FACTURATION',
|
||||||
|
'Livraison' => 'LIVRAISON',
|
||||||
|
'Approvisionnement' => 'APPROVISIONNEMENT',
|
||||||
|
'Méthaniseur' => 'METHANISEUR',
|
||||||
|
],
|
||||||
|
// M6 (ERP-201) : categories produit alimentant le select du formulaire
|
||||||
|
// produit (filtre ?typeCode=PRODUIT). Codes = slug MAJUSCULE deterministe
|
||||||
|
// (meme sortie que CategoryCodeGenerator). Provisoires, a affiner avec le metier.
|
||||||
|
'PRODUIT' => [
|
||||||
|
'Céréales' => 'CEREALES',
|
||||||
|
'Oléagineux' => 'OLEAGINEUX',
|
||||||
|
'Aliments du bétail' => 'ALIMENTS_DU_BETAIL',
|
||||||
|
'Engrais' => 'ENGRAIS',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ use Doctrine\Persistence\ObjectManager;
|
|||||||
* taxonomie distincte des prestataires (Maintenance industrielle, Nettoyage,
|
* taxonomie distincte des prestataires (Maintenance industrielle, Nettoyage,
|
||||||
* Transport). Mirroir de la migration Version20260612080000.
|
* Transport). Mirroir de la migration Version20260612080000.
|
||||||
*
|
*
|
||||||
|
* ADRESSE : ajout du type ADRESSE (code ADRESSE, label « Adresse »), taxonomie
|
||||||
|
* dediee au champ « Categorie » des blocs adresse (client + fournisseur). Mirroir
|
||||||
|
* de la migration Version20260625100000.
|
||||||
|
*
|
||||||
|
* M6 (ERP-201) : ajout du type PRODUIT (code PRODUIT, label « Produit »), taxonomie
|
||||||
|
* des categories produit du catalogue (Cereales, Oleagineux...). Mirroir du seed de
|
||||||
|
* la migration Version20260625110000 (ERP-198) : re-aligne dev/test apres purge.
|
||||||
|
*
|
||||||
* Pourquoi une fixture EN PLUS du seed de la migration : `category_type` est une
|
* Pourquoi une fixture EN PLUS du seed de la migration : `category_type` est une
|
||||||
* entite managee par l ORM, donc le purger Doctrine la vide avant chaque
|
* entite managee par l ORM, donc le purger Doctrine la vide avant chaque
|
||||||
* `doctrine:fixtures:load`. Sans cette fixture, le type CLIENT seede par la
|
* `doctrine:fixtures:load`. Sans cette fixture, le type CLIENT seede par la
|
||||||
@@ -41,12 +49,15 @@ class CategoryTypeFixtures extends Fixture
|
|||||||
/**
|
/**
|
||||||
* Source unique des types : code technique => libelle FR. Doit rester aligne
|
* Source unique des types : code technique => libelle FR. Doit rester aligne
|
||||||
* sur le seed des migrations Version20260602100000 (CLIENT),
|
* sur le seed des migrations Version20260602100000 (CLIENT),
|
||||||
* Version20260605120000 (FOURNISSEUR) et Version20260612080000 (PRESTATAIRE).
|
* Version20260605120000 (FOURNISSEUR), Version20260612080000 (PRESTATAIRE),
|
||||||
|
* Version20260625100000 (ADRESSE) et Version20260625110000 (PRODUIT, ERP-198).
|
||||||
*/
|
*/
|
||||||
private const TYPES = [
|
private const TYPES = [
|
||||||
'CLIENT' => 'Client',
|
'CLIENT' => 'Client',
|
||||||
'FOURNISSEUR' => 'Fournisseur',
|
'FOURNISSEUR' => 'Fournisseur',
|
||||||
'PRESTATAIRE' => 'Prestataire',
|
'PRESTATAIRE' => 'Prestataire',
|
||||||
|
'ADRESSE' => 'Adresse',
|
||||||
|
'PRODUIT' => 'Produit',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Infrastructure\DataFixtures;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||||
|
use App\Module\Catalog\Domain\Repository\StorageTypeRepositoryInterface;
|
||||||
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixtures du module Catalog : seed du referentiel PLAT `storage_type` (M6).
|
||||||
|
*
|
||||||
|
* ⚠ PROVISOIRE (decision Matthieu 24/06, HP-M6-02) : codes et libelles ci-dessous
|
||||||
|
* sont a REVALIDER / RE-SEEDER quand Aurore livrera la liste definitive (ERP-201).
|
||||||
|
* La liste actuelle reprend les 10 valeurs de la maquette Figma (node 1503-34285).
|
||||||
|
*
|
||||||
|
* Referentiel PLAT : un type de stockage n'est plus rattache a des sites (la dispo
|
||||||
|
* par site releve du futur module Stockage). Cette fixture ne seede donc que les
|
||||||
|
* lignes `storage_type` ; la voie prod-safe est l'INSERT idempotent de la migration
|
||||||
|
* Version20260626100000 (les fixtures ne tournent pas en prod). Source unique : les
|
||||||
|
* memes 10 valeurs ici et dans la migration.
|
||||||
|
*
|
||||||
|
* Pourquoi une fixture EN PLUS de la migration : `storage_type` est une entite
|
||||||
|
* managee par l'ORM, donc le purger Doctrine la vide avant chaque
|
||||||
|
* `doctrine:fixtures:load`. Cette fixture re-aligne dev ET test apres la purge
|
||||||
|
* (referentiel necessaire au formulaire produit et a ses tests). Elle tourne dans
|
||||||
|
* TOUS les environnements (referentiel, pas une donnee de demo — miroir
|
||||||
|
* CategoryTypeFixtures).
|
||||||
|
*
|
||||||
|
* Idempotence : lookup par `code` parmi les types existants avant insertion
|
||||||
|
* (miroir CategoryTypeFixtures). Rejouable sans doublon meme si le purger Doctrine
|
||||||
|
* est desactive.
|
||||||
|
*/
|
||||||
|
class StorageTypeFixtures extends Fixture
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Seed PROVISOIRE (Figma node 1503-34285) : code MAJUSCULE stable => libelle FR.
|
||||||
|
* A re-seeder a reception de la liste Aurore (HP-M6-02). Doit rester aligne sur
|
||||||
|
* la migration Version20260626100000.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private const TYPES = [
|
||||||
|
'BOISSEAU' => 'Boisseau',
|
||||||
|
'BOISSEAU_DOSAGE' => 'Boisseau dosage',
|
||||||
|
'CASE' => 'Case',
|
||||||
|
'CELLULE' => 'Cellule',
|
||||||
|
'CONTAINER' => 'Container',
|
||||||
|
'CUVE_MELASSE' => 'Cuve mélasse',
|
||||||
|
'STOCKAGE_BIG_BAG' => 'Stockage big bag',
|
||||||
|
'STOCKAGE_PALETTE' => 'Stockage palette',
|
||||||
|
'TAS' => 'Tas',
|
||||||
|
'ZONE' => 'Zone',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly StorageTypeRepositoryInterface $storageTypeRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function load(ObjectManager $manager): void
|
||||||
|
{
|
||||||
|
// Index des types deja presents par code, pour ne pas creer de doublon.
|
||||||
|
$existingByCode = [];
|
||||||
|
foreach ($this->storageTypeRepository->findAllOrderedByLabel() as $type) {
|
||||||
|
$existingByCode[$type->getCode()] = $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::TYPES as $code => $label) {
|
||||||
|
$storageType = $existingByCode[$code] ?? new StorageType();
|
||||||
|
$storageType->setCode($code);
|
||||||
|
$storageType->setLabel($label);
|
||||||
|
|
||||||
|
$manager->persist($storageType);
|
||||||
|
}
|
||||||
|
|
||||||
|
$manager->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Infrastructure\Doctrine;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Entity\Product;
|
||||||
|
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Product>
|
||||||
|
*/
|
||||||
|
class DoctrineProductRepository extends ServiceEntityRepository implements ProductRepositoryInterface
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Product::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findById(int $id): ?Product
|
||||||
|
{
|
||||||
|
return $this->find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(Product $product): void
|
||||||
|
{
|
||||||
|
$this->getEntityManager()->persist($product);
|
||||||
|
$this->getEntityManager()->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function existsActiveByCode(string $code, ?int $excludeId = null): bool
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('p')
|
||||||
|
->select('1')
|
||||||
|
->andWhere('p.code = :code')
|
||||||
|
->andWhere('p.deletedAt IS NULL')
|
||||||
|
->setParameter('code', $code)
|
||||||
|
->setMaxResults(1)
|
||||||
|
;
|
||||||
|
|
||||||
|
if (null !== $excludeId) {
|
||||||
|
$qb->andWhere('p.id != :excludeId')->setParameter('excludeId', $excludeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [] !== $qb->getQuery()->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createListQueryBuilder(
|
||||||
|
bool $includeDeleted = false,
|
||||||
|
?string $search = null,
|
||||||
|
?int $categoryId = null,
|
||||||
|
?string $categoryCode = null,
|
||||||
|
?string $state = null,
|
||||||
|
array $siteIds = [],
|
||||||
|
): QueryBuilder {
|
||||||
|
// Eager-load des relations embarquees en liste (product:read) pour eviter
|
||||||
|
// un N+1 par produit : category (ManyToOne, sur), sites et storageTypes
|
||||||
|
// (ManyToMany BORNES — embed autorise, ne viole pas la regle n°13). Le
|
||||||
|
// provider enveloppe la requete dans un Paginator(fetchJoinCollection: true),
|
||||||
|
// compatible avec ces fetch-joins to-many (comptage par sous-requete d'ids).
|
||||||
|
$qb = $this->createQueryBuilder('p')
|
||||||
|
->leftJoin('p.category', 'cat')->addSelect('cat')
|
||||||
|
->leftJoin('p.sites', 's')->addSelect('s')
|
||||||
|
->leftJoin('p.storageTypes', 'stp')->addSelect('stp')
|
||||||
|
->orderBy('p.name', 'ASC')
|
||||||
|
;
|
||||||
|
|
||||||
|
// RG-6.09 : la liste exclut par defaut les produits soft-deleted.
|
||||||
|
if (!$includeDeleted) {
|
||||||
|
$qb->andWhere('p.deletedAt IS NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?search= : recherche partielle case-insensitive sur code + name. Les
|
||||||
|
// metacaracteres LIKE (%, _, \) sont echappes pour rester litteraux. Les
|
||||||
|
// deux LIKE sont parenthese pour ne pas casser la precedence AND/OR avec
|
||||||
|
// les autres filtres (AND lie plus fort que OR en DQL).
|
||||||
|
if (null !== $search && '' !== trim($search)) {
|
||||||
|
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
||||||
|
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
||||||
|
$qb->andWhere('(LOWER(p.code) LIKE :search OR LOWER(p.name) LIKE :search)')
|
||||||
|
->setParameter('search', $pattern)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?categoryId= : filtre par categorie precise (id).
|
||||||
|
if (null !== $categoryId) {
|
||||||
|
$qb->andWhere('cat.id = :categoryId')->setParameter('categoryId', $categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?categoryCode= : filtre par categorie precise (code stable).
|
||||||
|
if (null !== $categoryCode && '' !== trim($categoryCode)) {
|
||||||
|
$qb->andWhere('cat.code = :categoryCode')->setParameter('categoryCode', trim($categoryCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?state= : appartenance a la colonne JSONB `states`. DQL ne sait pas
|
||||||
|
// exprimer la containment jsonb -> on resout les ids matchant en SQL natif
|
||||||
|
// (operateur @>), puis on contraint le QueryBuilder. Ids vides -> condition
|
||||||
|
// toujours fausse (aucun produit), sans casser le reste de la requete.
|
||||||
|
if (null !== $state) {
|
||||||
|
$stateIds = $this->matchingStateIds($state);
|
||||||
|
if ([] === $stateIds) {
|
||||||
|
$qb->andWhere('1 = 0');
|
||||||
|
} else {
|
||||||
|
$qb->andWhere('p.id IN (:stateIds)')->setParameter('stateIds', $stateIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?siteId[]= : produit disponible sur AU MOINS UN des sites passes (OR).
|
||||||
|
// Sous-requete EXISTS correlee pour ne PAS restreindre la collection sites
|
||||||
|
// eager-loadee `s` (sinon les autres sites du produit disparaitraient du
|
||||||
|
// JSON) et eviter les lignes dupliquees (cf. DoctrineCategoryRepository).
|
||||||
|
if ([] !== $siteIds) {
|
||||||
|
$sub = $this->getEntityManager()->createQueryBuilder()
|
||||||
|
->select('1')
|
||||||
|
->from(Product::class, 'p_si')
|
||||||
|
->join('p_si.sites', 's_si')
|
||||||
|
->where('p_si = p')
|
||||||
|
->andWhere('s_si.id IN (:siteIds)')
|
||||||
|
;
|
||||||
|
$qb->andWhere($qb->expr()->exists($sub->getDQL()))
|
||||||
|
->setParameter('siteIds', $siteIds)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ids des produits dont la colonne JSONB `states` contient l'etat donne, via
|
||||||
|
* l'operateur de containment Postgres `@>`. L'etat est borne a l'enum
|
||||||
|
* {PURCHASE, SALE, OTHER} en amont (ProductProvider) — pas de saisie libre ici.
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function matchingStateIds(string $state): array
|
||||||
|
{
|
||||||
|
$rows = $this->getEntityManager()->getConnection()
|
||||||
|
->executeQuery(
|
||||||
|
'SELECT id FROM product WHERE states @> CAST(:state AS JSONB)',
|
||||||
|
['state' => (string) json_encode([$state])],
|
||||||
|
)
|
||||||
|
->fetchFirstColumn()
|
||||||
|
;
|
||||||
|
|
||||||
|
return array_map(static fn (mixed $id): int => (int) $id, $rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Infrastructure\Doctrine;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||||
|
use App\Module\Catalog\Domain\Repository\StorageTypeRepositoryInterface;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<StorageType>
|
||||||
|
*/
|
||||||
|
class DoctrineStorageTypeRepository extends ServiceEntityRepository implements StorageTypeRepositoryInterface
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, StorageType::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findById(int $id): ?StorageType
|
||||||
|
{
|
||||||
|
return $this->find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<StorageType>
|
||||||
|
*/
|
||||||
|
public function findAllOrderedByLabel(): array
|
||||||
|
{
|
||||||
|
return $this->findBy([], ['label' => 'ASC']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createListQueryBuilder(): QueryBuilder
|
||||||
|
{
|
||||||
|
// Tri alphabetique stable (multi-select du formulaire produit, § 4.2).
|
||||||
|
// Referentiel plat : tous les types, plus de filtrage par site.
|
||||||
|
return $this->createQueryBuilder('st')
|
||||||
|
->orderBy('st.label', 'ASC')
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
* - sites : SiteInterface (module Sites) via resolve_target_entities
|
* - sites : SiteInterface (module Sites) via resolve_target_entities
|
||||||
* - contacts : ClientContact (meme module)
|
* - contacts : ClientContact (meme module)
|
||||||
* - categories : CategoryInterface (module Catalog) via resolve_target_entities
|
* - categories : CategoryInterface (module Catalog) via resolve_target_entities
|
||||||
* — codes DISTRIBUTEUR/COURTIER interdits (RG-1.29, validateCategoryCodes, ERP-78)
|
* — type ADRESSE attendu (validateCategoryType)
|
||||||
*
|
*
|
||||||
* Audite (#[Auditable]) + Timestampable/Blamable.
|
* Audite (#[Auditable]) + Timestampable/Blamable.
|
||||||
*
|
*
|
||||||
@@ -96,11 +96,11 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Client
|
|||||||
use TimestampableBlamableTrait;
|
use TimestampableBlamableTrait;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RG-1.29 (ERP-78) : ces codes de categorie decrivent une relation entre
|
* Seules les categories PORTANT ce type sont autorisees sur une adresse client.
|
||||||
* clients (distributeur / courtier) et n'ont pas de sens sur une adresse.
|
* S'appuie sur CategoryInterface::getCategoryTypeCodes() (multi-type — pas
|
||||||
* Toute autre categorie du type CLIENT est autorisee.
|
* d'import du module Catalog, regle ABSOLUE n°1).
|
||||||
*/
|
*/
|
||||||
private const array FORBIDDEN_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER'];
|
private const string REQUIRED_CATEGORY_TYPE_CODE = 'ADRESSE';
|
||||||
|
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
@@ -215,7 +215,7 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Client
|
|||||||
private Collection $contacts;
|
private Collection $contacts;
|
||||||
|
|
||||||
// Au moins une categorie est obligatoire sur une adresse (spec-front § Adresse).
|
// Au moins une categorie est obligatoire sur une adresse (spec-front § Adresse).
|
||||||
// RG-1.29 : categories de code DISTRIBUTEUR/COURTIER interdites (validateCategoryCodes).
|
// Categories de type ADRESSE uniquement (validateCategoryType).
|
||||||
/** @var Collection<int, CategoryInterface> */
|
/** @var Collection<int, CategoryInterface> */
|
||||||
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
||||||
#[ORM\JoinTable(name: 'client_address_category')]
|
#[ORM\JoinTable(name: 'client_address_category')]
|
||||||
@@ -335,20 +335,19 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Client
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RG-1.29 (ERP-78) : une adresse interdit les categories de code
|
* Toute categorie posee sur une adresse client doit etre de type ADRESSE ->
|
||||||
* DISTRIBUTEUR / COURTIER — elles decrivent une relation entre clients
|
* sinon 422 avec violation sur le champ `categories`. S'appuie sur
|
||||||
* (RG-1.03) et n'ont pas de sens sur une adresse physique -> 422 avec
|
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
|
||||||
* violation sur le champ `categories`. Toute autre categorie (type unique
|
* acceptee des qu'elle PORTE le type ADRESSE ; pas d'import du module Catalog,
|
||||||
* CLIENT) est acceptee. S'appuie sur CategoryInterface::getCode() (pas
|
* regle ABSOLUE n°1).
|
||||||
* d'import du module Catalog — regle ABSOLUE n°1).
|
|
||||||
*/
|
*/
|
||||||
#[Assert\Callback]
|
#[Assert\Callback]
|
||||||
public function validateCategoryCodes(ExecutionContextInterface $context): void
|
public function validateCategoryType(ExecutionContextInterface $context): void
|
||||||
{
|
{
|
||||||
foreach ($this->categories as $category) {
|
foreach ($this->categories as $category) {
|
||||||
if ($category instanceof CategoryInterface
|
if ($category instanceof CategoryInterface
|
||||||
&& in_array($category->getCode(), self::FORBIDDEN_CATEGORY_CODES, true)) {
|
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
|
||||||
$context->buildViolation('Type de catégorie non autorisé sur une adresse.')
|
$context->buildViolation('Type de catégorie non autorisé (ADRESSE attendu).')
|
||||||
->atPath('categories')
|
->atPath('categories')
|
||||||
->addViolation()
|
->addViolation()
|
||||||
;
|
;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
* un site obligatoire (RG-2.06, Assert\Count). Site n'a pas de `code`.
|
* un site obligatoire (RG-2.06, Assert\Count). Site n'a pas de `code`.
|
||||||
* - contacts : SupplierContact (meme module).
|
* - contacts : SupplierContact (meme module).
|
||||||
* - categories : CategoryInterface (module Catalog) via resolve_target_entities —
|
* - categories : CategoryInterface (module Catalog) via resolve_target_entities —
|
||||||
* type FOURNISSEUR attendu (RG-2.10, Assert\Callback validateCategoryType).
|
* type ADRESSE attendu (Assert\Callback validateCategoryType).
|
||||||
*
|
*
|
||||||
* Embarquee sous `supplier.addresses` au detail (groupe supplier:item:read,
|
* Embarquee sous `supplier.addresses` au detail (groupe supplier:item:read,
|
||||||
* maillon (a)).
|
* maillon (a)).
|
||||||
@@ -110,11 +110,11 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
|
|||||||
public const array ADDRESS_TYPES = ['PROSPECT', 'DEPART', 'RENDU'];
|
public const array ADDRESS_TYPES = ['PROSPECT', 'DEPART', 'RENDU'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RG-2.10 : seules les categories PORTANT ce type sont autorisees sur une
|
* Seules les categories PORTANT ce type sont autorisees sur une adresse
|
||||||
* adresse fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCodes()
|
* fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCodes() (pas
|
||||||
* (pas d'import du module Catalog — regle ABSOLUE n°1).
|
* d'import du module Catalog — regle ABSOLUE n°1).
|
||||||
*/
|
*/
|
||||||
private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
|
private const string REQUIRED_CATEGORY_TYPE_CODE = 'ADRESSE';
|
||||||
|
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
@@ -208,8 +208,8 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
|
|||||||
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
||||||
private Collection $contacts;
|
private Collection $contacts;
|
||||||
|
|
||||||
// RG-2.10 : au moins une categorie de type FOURNISSEUR par adresse (le type est
|
// Au moins une categorie de type ADRESSE par adresse (le type est controle par
|
||||||
// controle par validateCategoryType ; le minimum par Assert\Count, miroir sites).
|
// validateCategoryType ; le minimum par Assert\Count, miroir sites).
|
||||||
/** @var Collection<int, CategoryInterface> */
|
/** @var Collection<int, CategoryInterface> */
|
||||||
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
||||||
#[ORM\JoinTable(name: 'supplier_address_category')]
|
#[ORM\JoinTable(name: 'supplier_address_category')]
|
||||||
@@ -227,12 +227,12 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RG-2.10 : toute categorie posee sur une adresse fournisseur doit etre de
|
* Toute categorie posee sur une adresse fournisseur doit etre de type ADRESSE
|
||||||
* type FOURNISSEUR -> sinon 422 avec violation sur le champ `categories`
|
* -> sinon 422 avec violation sur le champ `categories` (propertyPath aligne
|
||||||
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur
|
* ERP-101, message FR ERP-107). S'appuie sur
|
||||||
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
|
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
|
||||||
* acceptee des qu'elle PORTE le type FOURNISSEUR ; pas d'import du module
|
* acceptee des qu'elle PORTE le type ADRESSE ; pas d'import du module Catalog,
|
||||||
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
|
* regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
|
||||||
*/
|
*/
|
||||||
#[Assert\Callback]
|
#[Assert\Callback]
|
||||||
public function validateCategoryType(ExecutionContextInterface $context): void
|
public function validateCategoryType(ExecutionContextInterface $context): void
|
||||||
@@ -240,7 +240,7 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
|
|||||||
foreach ($this->categories as $category) {
|
foreach ($this->categories as $category) {
|
||||||
if ($category instanceof CategoryInterface
|
if ($category instanceof CategoryInterface
|
||||||
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
|
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
|
||||||
$context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).')
|
$context->buildViolation('Type de catégorie non autorisé (ADRESSE attendu).')
|
||||||
->atPath('categories')
|
->atPath('categories')
|
||||||
->addViolation()
|
->addViolation()
|
||||||
;
|
;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user