Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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",
|
||||||
|
|||||||
+21
-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).
|
||||||
//
|
//
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.147'
|
app.version: '0.1.150'
|
||||||
|
|||||||
@@ -183,6 +183,7 @@
|
|||||||
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||||
},
|
},
|
||||||
"accounting": {
|
"accounting": {
|
||||||
|
"infoTitle": "Informations",
|
||||||
"siren": "SIREN",
|
"siren": "SIREN",
|
||||||
"accountNumber": "Numéro de compte",
|
"accountNumber": "Numéro de compte",
|
||||||
"tvaMode": "Mode de TVA",
|
"tvaMode": "Mode de TVA",
|
||||||
@@ -190,6 +191,7 @@
|
|||||||
"paymentDelay": "Délai de règlement",
|
"paymentDelay": "Délai de règlement",
|
||||||
"paymentType": "Type de règlement",
|
"paymentType": "Type de règlement",
|
||||||
"bank": "Banque",
|
"bank": "Banque",
|
||||||
|
"ribTitle": "RIB {n}",
|
||||||
"ribLabel": "Libellé",
|
"ribLabel": "Libellé",
|
||||||
"ribBic": "BIC",
|
"ribBic": "BIC",
|
||||||
"ribIban": "IBAN",
|
"ribIban": "IBAN",
|
||||||
@@ -350,6 +352,7 @@
|
|||||||
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||||
},
|
},
|
||||||
"accounting": {
|
"accounting": {
|
||||||
|
"infoTitle": "Informations",
|
||||||
"siren": "SIREN",
|
"siren": "SIREN",
|
||||||
"accountNumber": "Numéro de compte",
|
"accountNumber": "Numéro de compte",
|
||||||
"tvaMode": "Mode de TVA",
|
"tvaMode": "Mode de TVA",
|
||||||
@@ -441,6 +444,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 +456,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 +470,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 +478,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 +635,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 +645,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 +663,7 @@
|
|||||||
"confirm": "Supprimer"
|
"confirm": "Supprimer"
|
||||||
},
|
},
|
||||||
"price": {
|
"price": {
|
||||||
|
"title": "Prix {n}",
|
||||||
"direction": "Sens",
|
"direction": "Sens",
|
||||||
"directionClient": "Client",
|
"directionClient": "Client",
|
||||||
"directionSupplier": "Fournisseur",
|
"directionSupplier": "Fournisseur",
|
||||||
@@ -691,6 +701,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",
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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')"
|
||||||
|
|||||||
@@ -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')"
|
||||||
|
|||||||
@@ -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,6 +180,7 @@
|
|||||||
:key="address.id ?? `new-${index}`"
|
:key="address.id ?? `new-${index}`"
|
||||||
:model-value="address"
|
:model-value="address"
|
||||||
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
|
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
|
||||||
|
:last="index === addresses.length - 1"
|
||||||
:category-options="mainCategoryOptions"
|
:category-options="mainCategoryOptions"
|
||||||
:site-options="siteOptions"
|
:site-options="siteOptions"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
@@ -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')"
|
||||||
|
|||||||
@@ -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,6 +178,7 @@
|
|||||||
:key="index"
|
:key="index"
|
||||||
:model-value="address"
|
:model-value="address"
|
||||||
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
|
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
|
||||||
|
:last="index === addresses.length - 1"
|
||||||
:category-options="referentials.categories.value"
|
:category-options="referentials.categories.value"
|
||||||
:site-options="referentials.sites.value"
|
:site-options="referentials.sites.value"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { useWeighingTicketsRepository, type WeighingTicket } from '../useWeighingTicketsRepository'
|
||||||
|
|
||||||
|
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||||
|
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests du repertoire des tickets de pesee (M5, ERP-188).
|
||||||
|
*
|
||||||
|
* `useWeighingTicketsRepository` est une fine enveloppe de
|
||||||
|
* `usePaginatedList<WeighingTicket>` sur `/weighing_tickets`. Les invariants
|
||||||
|
* generiques de pagination sont deja couverts par `usePaginatedList.test.ts` ;
|
||||||
|
* on verifie ici le CONTRAT propre au repertoire :
|
||||||
|
* - la ressource ciblee est bien `/weighing_tickets` ;
|
||||||
|
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
|
||||||
|
* renvoie un tableau plat sans pagination) ;
|
||||||
|
* - DEFAUT 25 ITEMS/PAGE : la liste etant consultee en volume, le premier
|
||||||
|
* fetch demande 25 items (et non le defaut 10) — l'utilisateur peut toujours
|
||||||
|
* rebasculer via le selecteur.
|
||||||
|
*/
|
||||||
|
describe('useWeighingTicketsRepository', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockApiGet.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Une page de tickets Hydra minimale. */
|
||||||
|
const PAGE: WeighingTicket[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
status: 'VALIDATED',
|
||||||
|
number: '86-TP-0001',
|
||||||
|
client: { id: 7, companyName: 'ACME' },
|
||||||
|
supplier: null,
|
||||||
|
otherLabel: null,
|
||||||
|
displayDate: '2026-06-17T09:12:00+02:00',
|
||||||
|
netWeight: 7150,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
it('cible /weighing_tickets en Hydra avec 25 items/page par defaut', async () => {
|
||||||
|
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||||
|
const repo = useWeighingTicketsRepository()
|
||||||
|
|
||||||
|
await repo.fetch()
|
||||||
|
|
||||||
|
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
||||||
|
const [url, query, opts] = mockApiGet.mock.calls[0]
|
||||||
|
expect(url).toBe('/weighing_tickets')
|
||||||
|
expect(query).toMatchObject({ page: 1, itemsPerPage: 25 })
|
||||||
|
expect(opts).toMatchObject({
|
||||||
|
toast: false,
|
||||||
|
headers: { Accept: 'application/ld+json' },
|
||||||
|
})
|
||||||
|
expect(repo.itemsPerPage.value).toBe(25)
|
||||||
|
expect(repo.items.value).toEqual(PAGE)
|
||||||
|
expect(repo.totalItems.value).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Pesée au pont bascule (M5, ERP-189) — déclenche une lecture de poids via
|
||||||
|
* `POST /api/weighbridge_readings` (spec-back § 4.2). Action autonome : le ticket
|
||||||
|
* n'existe pas encore quand on pèse depuis le formulaire principal.
|
||||||
|
*
|
||||||
|
* Deux modes :
|
||||||
|
* - AUTO (« Pesée bascule ») : le serveur résout le site courant, lit le poids
|
||||||
|
* (stub aléatoire au M5) et alloue le DSD. Peut échouer (RG-5.06 → 503) : le
|
||||||
|
* pont est indisponible, on invite l'utilisateur à passer en pesée manuelle.
|
||||||
|
* - MANUAL (« Pesée manuelle ») : poids + DSD saisis par l'opérateur ; le serveur
|
||||||
|
* les conserve tels quels — plus d'auto-incrément (ERP-193).
|
||||||
|
*
|
||||||
|
* Composable UI-agnostique : il appelle l'API (`useApi`, jamais `$fetch`) et
|
||||||
|
* renvoie la lecture, ou lève l'erreur — la gestion de la modal/de l'affichage
|
||||||
|
* reste à la charge de l'écran. `extractWeighbridgeError` factorise la lecture
|
||||||
|
* du message d'erreur 503 (RG-5.06) pour l'afficher dans la modal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Mode de pesée — miroir de l'enum back. */
|
||||||
|
export type WeighbridgeMode = 'AUTO' | 'MANUAL'
|
||||||
|
|
||||||
|
/** Lecture renvoyée par le pont bascule (spec-back § 4.2). */
|
||||||
|
export interface WeighbridgeReading {
|
||||||
|
weight: number
|
||||||
|
dsd: number
|
||||||
|
mode: WeighbridgeMode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWeighbridge() {
|
||||||
|
const api = useApi()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pesée bascule (AUTO). Le site courant est résolu serveur — rien à envoyer.
|
||||||
|
* `toast: false` : l'erreur (RG-5.06) est affichée inline dans la modal, pas
|
||||||
|
* en toast global.
|
||||||
|
*/
|
||||||
|
async function triggerAuto(): Promise<WeighbridgeReading> {
|
||||||
|
return await api.post<WeighbridgeReading>(
|
||||||
|
'/weighbridge_readings',
|
||||||
|
{ mode: 'AUTO' },
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pesée manuelle (MANUAL). Le poids ET le DSD sont saisis par l'opérateur (le
|
||||||
|
* DSD = numéro du pont réellement utilisé) et conservés tels quels (ERP-193).
|
||||||
|
*/
|
||||||
|
async function triggerManual(weight: number, dsd: number): Promise<WeighbridgeReading> {
|
||||||
|
return await api.post<WeighbridgeReading>(
|
||||||
|
'/weighbridge_readings',
|
||||||
|
{ mode: 'MANUAL', weight, dsd },
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message d'erreur de pesée bascule (RG-5.06). Le back renvoie un 503
|
||||||
|
* `{ title, detail }` (« Pont bascule indisponible » / « Passez en pesée
|
||||||
|
* manuelle. ») — on privilégie le `detail`, puis le `title`, sinon un libellé
|
||||||
|
* générique invitant à la pesée manuelle.
|
||||||
|
*/
|
||||||
|
function extractWeighbridgeError(error: unknown): string {
|
||||||
|
const data = (error as { response?: { _data?: unknown } })?.response?._data as
|
||||||
|
| { detail?: string, title?: string }
|
||||||
|
| undefined
|
||||||
|
return data?.detail || data?.title || t('logistique.weighingTickets.form.weighbridge.unavailable')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { triggerAuto, triggerManual, extractWeighbridgeError }
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge'
|
||||||
|
import type { CounterpartyType, WeighingTicketStatus } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détail d'un ticket de pesée (`GET /api/weighing_tickets/{id}`, spec-back
|
||||||
|
* § 4.0.bis). Champs null OMIS du JSON (`skip_null_values`) → tous optionnels,
|
||||||
|
* lus avec un défaut côté hydratation du formulaire.
|
||||||
|
*/
|
||||||
|
export interface WeighingTicketDetail {
|
||||||
|
id: number
|
||||||
|
/** Cycle de vie (DRAFT/VALIDATED, ERP-193). */
|
||||||
|
status?: WeighingTicketStatus
|
||||||
|
/** Numéro `{siteCode}-TP-{NNNN}` — null tant que brouillon, immuable ensuite (RG-5.09). */
|
||||||
|
number?: string | null
|
||||||
|
/** Site rattaché (embarqué) — immuable (RG-5.09). */
|
||||||
|
site?: { id: number, name: string, code: string } | null
|
||||||
|
counterpartyType?: CounterpartyType | null
|
||||||
|
client?: { '@id': string, companyName: string } | null
|
||||||
|
supplier?: { '@id': string, companyName: string } | null
|
||||||
|
otherLabel?: string | null
|
||||||
|
immatriculation?: string | null
|
||||||
|
plateFreeFormat?: boolean
|
||||||
|
// Pesée à vide
|
||||||
|
emptyDate?: string | null
|
||||||
|
emptyWeight?: number | null
|
||||||
|
emptyDsd?: number | null
|
||||||
|
emptyMode?: WeighbridgeMode | null
|
||||||
|
// Pesée à plein
|
||||||
|
fullDate?: string | null
|
||||||
|
fullWeight?: number | null
|
||||||
|
fullDsd?: number | null
|
||||||
|
fullMode?: WeighbridgeMode | null
|
||||||
|
netWeight?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge le détail d'un ticket de pesée pour l'écran de modification (M5,
|
||||||
|
* ERP-190). `Accept: application/ld+json` impose l'enveloppe Hydra (relations
|
||||||
|
* embarquées : client/supplier/site). Appel via `useApi()` (jamais `$fetch`).
|
||||||
|
*/
|
||||||
|
export function useWeighingTicket() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function fetchTicket(id: number | string): Promise<WeighingTicketDetail> {
|
||||||
|
return await api.get<WeighingTicketDetail>(
|
||||||
|
`/weighing_tickets/${id}`,
|
||||||
|
{},
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fetchTicket }
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { nowIsoDateTime } from '~/shared/utils/date'
|
||||||
|
import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* État et logique du formulaire « Ajouter / Modifier un ticket de pesée » (M5,
|
||||||
|
* ERP-189). L'écran est composé de DEUX blocs empilés — pesée à vide puis pesée
|
||||||
|
* à plein — qui partagent un même véhicule.
|
||||||
|
*
|
||||||
|
* Points clés (spec-front § Écran Ajouter, spec-back § 2.4 / 2.9 / 2.10) :
|
||||||
|
* - **Contrepartie conditionnelle (RG-5.03)** : `counterpartyType` (CLIENT /
|
||||||
|
* FOURNISSEUR / AUTRE) pilote le champ requis (client / supplier / otherLabel).
|
||||||
|
* Changer de type purge les champs des autres types — aucune donnée fantôme.
|
||||||
|
* - **Immatriculation + « Tout format »** font partie des 4 champs du haut, hors
|
||||||
|
* blocs (ERP-193). Une seule valeur, partagée entre les 2 pesées (RG-5.01).
|
||||||
|
* - **Cycle brouillon -> validé (ERP-193)** : `buildDraftPayload()` persiste l'état
|
||||||
|
* courant (pesée enregistrée dès la validation de sa modale, même sans
|
||||||
|
* contrepartie/immat) via POST (création du brouillon) puis PATCH ; quand les 3
|
||||||
|
* champs du haut + les 2 pesées sont là, `buildValidatePayload()` finalise via
|
||||||
|
* `PATCH /weighing_tickets/{id}/validate` (numéro attribué, status VALIDATED).
|
||||||
|
*
|
||||||
|
* Composable UI-agnostique et testable : aucune dépendance API ici (les appels
|
||||||
|
* vivent dans l'écran via `useApi`). Instancié PAR écran (refs locales).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Type de contrepartie — miroir de l'enum back (spec-back § 2.9). */
|
||||||
|
export type CounterpartyType = 'CLIENT' | 'FOURNISSEUR' | 'AUTRE'
|
||||||
|
|
||||||
|
/** Saisie d'une pesée (bloc vide OU bloc plein). */
|
||||||
|
export interface WeighingBlockState {
|
||||||
|
/** Date/heure de la pesée (ISO local `YYYY-MM-DDTHH:mm:ss`) — date du jour + heure courante par défaut (RG-5.07). */
|
||||||
|
date: string | null
|
||||||
|
/** Poids en kg — readonly, rempli par la pesée (bascule ou manuelle). */
|
||||||
|
weight: number | null
|
||||||
|
/** DSD — pesée bascule : fourni par le pont ; pesée manuelle : saisi (RG-5.04, ERP-193). */
|
||||||
|
dsd: number | null
|
||||||
|
/** Mode de la dernière pesée appliquée au bloc. */
|
||||||
|
mode: WeighbridgeMode | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cycle de vie du ticket (miroir back, ERP-193). */
|
||||||
|
export type WeighingTicketStatus = 'DRAFT' | 'VALIDATED'
|
||||||
|
|
||||||
|
/** Forme minimale d'un détail de ticket consommée par `hydrate` (cf. useWeighingTicket). */
|
||||||
|
export interface WeighingTicketHydration {
|
||||||
|
id: number
|
||||||
|
status?: WeighingTicketStatus
|
||||||
|
counterpartyType?: CounterpartyType | null
|
||||||
|
client?: { '@id': string } | null
|
||||||
|
supplier?: { '@id': string } | null
|
||||||
|
otherLabel?: string | null
|
||||||
|
immatriculation?: string | null
|
||||||
|
plateFreeFormat?: boolean
|
||||||
|
emptyDate?: string | null
|
||||||
|
emptyWeight?: number | null
|
||||||
|
emptyDsd?: number | null
|
||||||
|
emptyMode?: WeighbridgeMode | null
|
||||||
|
fullDate?: string | null
|
||||||
|
fullWeight?: number | null
|
||||||
|
fullDsd?: number | null
|
||||||
|
fullMode?: WeighbridgeMode | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ramène une chaîne ISO datetime du back (`2026-06-17T09:00:00+02:00`) au format
|
||||||
|
* local `YYYY-MM-DDTHH:mm:ss` attendu par MalioDateTime (secondes, sans fuseau) :
|
||||||
|
* on garde les 19 premiers caractères (date + heure), on retire l'offset. Null si
|
||||||
|
* absente.
|
||||||
|
*/
|
||||||
|
function toLocalIsoDateTime(value: string | null | undefined): string | null {
|
||||||
|
return value ? value.slice(0, 19) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retire les clés à valeur `null` d'un payload (pattern « omission des requis
|
||||||
|
* vides » M1). Avec `collectDenormalizationErrors` côté back, envoyer `null` sur
|
||||||
|
* un scalaire requis (ex. `counterpartyType`) produit une violation de TYPE
|
||||||
|
* opaque (« Cette valeur doit être de type string. ») au lieu du message métier
|
||||||
|
* `NotBlank` : une clé ABSENTE laisse au contraire jouer la contrainte `NotBlank`
|
||||||
|
* et son message FR. On omet donc les null ; les champs réellement requis non
|
||||||
|
* remplis déclenchent leur vrai message, les optionnels restent simplement absents.
|
||||||
|
*/
|
||||||
|
function compact(payload: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== null))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Crée l'état initial d'un bloc de pesée (date/heure = maintenant, RG-5.07). */
|
||||||
|
function emptyBlock(now: string): WeighingBlockState {
|
||||||
|
return {
|
||||||
|
date: now,
|
||||||
|
weight: null,
|
||||||
|
dsd: null,
|
||||||
|
mode: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWeighingTicketForm() {
|
||||||
|
const now = nowIsoDateTime()
|
||||||
|
|
||||||
|
// ── Contrepartie (RG-5.03) ───────────────────────────────────────────────
|
||||||
|
const counterpartyType = ref<CounterpartyType | null>(null)
|
||||||
|
const clientIri = ref<string | null>(null)
|
||||||
|
const supplierIri = ref<string | null>(null)
|
||||||
|
const otherLabel = ref<string | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change le type de contrepartie et purge les champs devenus hors-sujet :
|
||||||
|
* un seul de client / supplier / otherLabel est conservé selon le type
|
||||||
|
* (RG-5.03 — pas de FK fantôme envoyée au back).
|
||||||
|
*/
|
||||||
|
function setCounterpartyType(type: CounterpartyType | null): void {
|
||||||
|
counterpartyType.value = type
|
||||||
|
if (type !== 'CLIENT') clientIri.value = null
|
||||||
|
if (type !== 'FOURNISSEUR') supplierIri.value = null
|
||||||
|
if (type !== 'AUTRE') otherLabel.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Véhicule : partagé entre les 2 blocs (RG-5.01) ────────────────────────
|
||||||
|
// Refs UNIQUES : les 2 blocs bindent la même valeur → connexion automatique.
|
||||||
|
const immatriculation = ref<string | null>(null)
|
||||||
|
const plateFreeFormat = ref<boolean>(false)
|
||||||
|
|
||||||
|
// ── Les deux pesées ───────────────────────────────────────────────────────
|
||||||
|
const empty = reactive<WeighingBlockState>(emptyBlock(now))
|
||||||
|
const full = reactive<WeighingBlockState>(emptyBlock(now))
|
||||||
|
|
||||||
|
// Id du ticket persisté (POST du 1er enregistrement de pesée) — pilote ensuite
|
||||||
|
// les PATCH (brouillon) puis la validation. Null tant que rien n'est persisté.
|
||||||
|
const ticketId = ref<number | null>(null)
|
||||||
|
|
||||||
|
// Cycle de vie courant (DRAFT tant que non validé, ERP-193).
|
||||||
|
const status = ref<WeighingTicketStatus>('DRAFT')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champ de contrepartie attendu selon le type courant — utilisé par l'écran
|
||||||
|
* pour afficher conditionnellement le bon champ (RG-5.03).
|
||||||
|
*/
|
||||||
|
const counterpartyField = computed<'client' | 'supplier' | 'other' | null>(() => {
|
||||||
|
switch (counterpartyType.value) {
|
||||||
|
case 'CLIENT': return 'client'
|
||||||
|
case 'FOURNISSEUR': return 'supplier'
|
||||||
|
case 'AUTRE': return 'other'
|
||||||
|
default: return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champs de pesée manquants d'un bloc (Poids / DSD), RG-5.07. Le back rend ces
|
||||||
|
* colonnes nullable (workflow 2 temps) : l'obligation « une pesée a été
|
||||||
|
* effectuée » est donc portée côté front (règle front-only, ERP-101). Renvoie
|
||||||
|
* les `propertyPath` manquants (ex. `['emptyWeight', 'emptyDsd']`), prêts à
|
||||||
|
* être posés en erreur inline via `useFormErrors.setError`.
|
||||||
|
*/
|
||||||
|
function missingWeighingFields(which: 'empty' | 'full'): string[] {
|
||||||
|
const block = which === 'empty' ? empty : full
|
||||||
|
const missing: string[] = []
|
||||||
|
if (block.weight === null) missing.push(`${which}Weight`)
|
||||||
|
if (block.dsd === null) missing.push(`${which}Dsd`)
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applique une lecture de pesée (bascule/manuelle) à un bloc. La pesée étant
|
||||||
|
* effectuée À CET INSTANT, on (ré)horodate le bloc à maintenant : la date/heure
|
||||||
|
* du ticket reflète le moment réel de la pesée validée, pas l'ouverture du
|
||||||
|
* formulaire (RG-5.07).
|
||||||
|
*/
|
||||||
|
function applyReading(
|
||||||
|
block: WeighingBlockState,
|
||||||
|
reading: { weight: number, dsd: number, mode: WeighbridgeMode },
|
||||||
|
): void {
|
||||||
|
block.date = nowIsoDateTime()
|
||||||
|
block.weight = reading.weight
|
||||||
|
block.dsd = reading.dsd
|
||||||
|
block.mode = reading.mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Partie « contrepartie » du payload (FK en IRI ou libellé libre). */
|
||||||
|
function counterpartyPayload(): Record<string, unknown> {
|
||||||
|
switch (counterpartyType.value) {
|
||||||
|
case 'CLIENT': return { client: clientIri.value }
|
||||||
|
case 'FOURNISSEUR': return { supplier: supplierIri.value }
|
||||||
|
case 'AUTRE': return { otherLabel: otherLabel.value || null }
|
||||||
|
default: return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrepartie d'un BROUILLON : on n'envoie le type QUE si son champ associé est
|
||||||
|
* renseigné. Un type sans son champ (l'opérateur a ouvert le menu avant de
|
||||||
|
* choisir) est une contrepartie incohérente que le back devrait retirer (sinon
|
||||||
|
* les CHECK chk_wt_*_branch lèvent une 500). On évite donc de l'émettre côté
|
||||||
|
* front. La cohérence reste exigée à la validation : `buildValidatePayload()`
|
||||||
|
* envoie toujours le type, pour déclencher la 422 métier sur le champ manquant.
|
||||||
|
*/
|
||||||
|
function draftCounterpartyPayload(): Record<string, unknown> {
|
||||||
|
switch (counterpartyType.value) {
|
||||||
|
case 'CLIENT':
|
||||||
|
return clientIri.value ? { counterpartyType: 'CLIENT', client: clientIri.value } : {}
|
||||||
|
case 'FOURNISSEUR':
|
||||||
|
return supplierIri.value ? { counterpartyType: 'FOURNISSEUR', supplier: supplierIri.value } : {}
|
||||||
|
case 'AUTRE':
|
||||||
|
return otherLabel.value && otherLabel.value.trim() !== ''
|
||||||
|
? { counterpartyType: 'AUTRE', otherLabel: otherLabel.value }
|
||||||
|
: {}
|
||||||
|
default:
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champs d'un bloc de pesée, UNIQUEMENT s'il a été pesé (poids renseigné) — on
|
||||||
|
* n'envoie pas la date par défaut d'un bloc vierge (sinon le back stockerait une
|
||||||
|
* date de pesée sans poids). Noms de clés alignés sur les `propertyPath` back.
|
||||||
|
*/
|
||||||
|
function blockPayload(prefix: 'empty' | 'full', block: WeighingBlockState): Record<string, unknown> {
|
||||||
|
if (block.weight === null) return {}
|
||||||
|
return {
|
||||||
|
[`${prefix}Date`]: block.date,
|
||||||
|
[`${prefix}Weight`]: block.weight,
|
||||||
|
[`${prefix}Dsd`]: block.dsd,
|
||||||
|
[`${prefix}Mode`]: block.mode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload de BROUILLON (POST création / PATCH mise à jour, ERP-193) : l'état
|
||||||
|
* courant complet (4 champs du haut + pesées effectuées). Aucun champ n'est
|
||||||
|
* requis ici (le back valide en mode relâché) — une pesée s'enregistre sans
|
||||||
|
* contrepartie ni immatriculation. Numéro/site/net attribués serveur.
|
||||||
|
*/
|
||||||
|
function buildDraftPayload(): Record<string, unknown> {
|
||||||
|
return compact({
|
||||||
|
...draftCounterpartyPayload(),
|
||||||
|
immatriculation: immatriculation.value || null,
|
||||||
|
plateFreeFormat: plateFreeFormat.value,
|
||||||
|
...blockPayload('empty', empty),
|
||||||
|
...blockPayload('full', full),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pré-remplit le formulaire à partir du détail d'un ticket existant (écran
|
||||||
|
* Modification, ERP-190). Le numéro et le site sont immuables (RG-5.09) →
|
||||||
|
* non repris dans l'état éditable (affichés en lecture seule par l'écran).
|
||||||
|
* Les dates ISO du back (datetime + fuseau) sont ramenées au format local
|
||||||
|
* `YYYY-MM-DDTHH:mm:ss` attendu par MalioDateTime (heure conservée).
|
||||||
|
*/
|
||||||
|
function hydrate(detail: WeighingTicketHydration): void {
|
||||||
|
ticketId.value = detail.id
|
||||||
|
status.value = detail.status ?? 'DRAFT'
|
||||||
|
counterpartyType.value = detail.counterpartyType ?? null
|
||||||
|
clientIri.value = detail.client?.['@id'] ?? null
|
||||||
|
supplierIri.value = detail.supplier?.['@id'] ?? null
|
||||||
|
otherLabel.value = detail.otherLabel ?? null
|
||||||
|
immatriculation.value = detail.immatriculation ?? null
|
||||||
|
plateFreeFormat.value = detail.plateFreeFormat ?? false
|
||||||
|
|
||||||
|
empty.date = toLocalIsoDateTime(detail.emptyDate) ?? now
|
||||||
|
empty.weight = detail.emptyWeight ?? null
|
||||||
|
empty.dsd = detail.emptyDsd ?? null
|
||||||
|
empty.mode = detail.emptyMode ?? null
|
||||||
|
|
||||||
|
full.date = toLocalIsoDateTime(detail.fullDate) ?? now
|
||||||
|
full.weight = detail.fullWeight ?? null
|
||||||
|
full.dsd = detail.fullDsd ?? null
|
||||||
|
full.mode = detail.fullMode ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload de VALIDATION (PATCH /weighing_tickets/{id}/validate, ERP-193) : les
|
||||||
|
* 4 champs du haut (contrepartie + immatriculation + « Tout format »). Les pesées
|
||||||
|
* sont déjà persistées par les enregistrements brouillon ; le back rejoue ici la
|
||||||
|
* validation stricte (groupe `finalize` : 3 champs requis + 2 pesées) et attribue
|
||||||
|
* le numéro. Les `propertyPath` des 422 sont mappés inline par useFormErrors.
|
||||||
|
*/
|
||||||
|
function buildValidatePayload(): Record<string, unknown> {
|
||||||
|
return compact({
|
||||||
|
counterpartyType: counterpartyType.value,
|
||||||
|
...counterpartyPayload(),
|
||||||
|
immatriculation: immatriculation.value || null,
|
||||||
|
plateFreeFormat: plateFreeFormat.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// contrepartie
|
||||||
|
counterpartyType,
|
||||||
|
counterpartyField,
|
||||||
|
clientIri,
|
||||||
|
supplierIri,
|
||||||
|
otherLabel,
|
||||||
|
setCounterpartyType,
|
||||||
|
// véhicule partagé
|
||||||
|
immatriculation,
|
||||||
|
plateFreeFormat,
|
||||||
|
// pesées
|
||||||
|
empty,
|
||||||
|
full,
|
||||||
|
applyReading,
|
||||||
|
missingWeighingFields,
|
||||||
|
// workflow
|
||||||
|
ticketId,
|
||||||
|
status,
|
||||||
|
hydrate,
|
||||||
|
buildDraftPayload,
|
||||||
|
buildValidatePayload,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Référentiels alimentant les selects de contrepartie de l'écran « Ticket de
|
||||||
|
* pesée » (M5, ERP-189) : liste des clients (M1) et des fournisseurs (M2).
|
||||||
|
*
|
||||||
|
* Collections récupérées en entier via l'échappatoire `?pagination=false`
|
||||||
|
* (référentiels de quelques dizaines d'entrées), avec l'en-tête
|
||||||
|
* `Accept: application/ld+json` imposé par API Platform 4 pour obtenir
|
||||||
|
* l'enveloppe Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`) —
|
||||||
|
* renvoyée telle quelle dans le payload POST/PATCH (relation ManyToOne).
|
||||||
|
*
|
||||||
|
* Miroir de `useClientReferentials` (M1). État 100 % local à l'instance.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Option au format attendu par MalioSelect ({ label, value }). */
|
||||||
|
export interface RefOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PartyMember {
|
||||||
|
'@id': string
|
||||||
|
companyName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||||
|
|
||||||
|
export function useWeighingTicketReferentials() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const clients = ref<RefOption[]>([])
|
||||||
|
const suppliers = ref<RefOption[]>([])
|
||||||
|
|
||||||
|
/** Récupère une collection complète (pagination désactivée) en Hydra. */
|
||||||
|
async function fetchAll(url: string): Promise<PartyMember[]> {
|
||||||
|
const res = await api.get<{ member?: PartyMember[] }>(
|
||||||
|
url,
|
||||||
|
{ pagination: 'false' },
|
||||||
|
{ headers: LD_JSON_HEADERS, toast: false },
|
||||||
|
)
|
||||||
|
return res.member ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge en parallèle clients + fournisseurs (résilient : un référentiel en
|
||||||
|
* échec — ex. 403 selon le rôle — laisse simplement son select vide sans
|
||||||
|
* faire échouer l'autre).
|
||||||
|
*/
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
await Promise.allSettled([
|
||||||
|
fetchAll('/clients').then((list) => {
|
||||||
|
clients.value = list.map(c => ({ value: c['@id'], label: c.companyName }))
|
||||||
|
}),
|
||||||
|
fetchAll('/suppliers').then((list) => {
|
||||||
|
suppliers.value = list.map(s => ({ value: s['@id'], label: s.companyName }))
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return { clients, suppliers, load }
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||||
|
import type { WeighingTicketStatus } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue MINIMALE d'une contrepartie embarquee (Client M1 ou Fournisseur M2) dans la
|
||||||
|
* LISTE des tickets de pesee. Seul `companyName` alimente les colonnes
|
||||||
|
* « Client » / « Fournisseur » ; l'objet sort embarque (`client:read` /
|
||||||
|
* `supplier:read`) ou est carrement absent du JSON quand null (`skip_null_values`,
|
||||||
|
* spec-back § 4.0.bis) — d'ou le `?? null` systematique cote page.
|
||||||
|
*/
|
||||||
|
export interface WeighingTicketParty {
|
||||||
|
id: number
|
||||||
|
companyName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue MINIMALE d'un ticket de pesee pour la datatable (M5, ERP-188). Volontairement
|
||||||
|
* partielle : seuls les champs des colonnes (docx p.3) + l'id (navigation) sont
|
||||||
|
* types. Le detail complet (pesees vide/plein, immatriculation, site, DSD) releve
|
||||||
|
* de l'ecran Modification (ERP-190) — hors perimetre de cet ecran.
|
||||||
|
*
|
||||||
|
* Contrepartie mutuellement exclusive (RG-5.03) : un seul de `client` / `supplier`
|
||||||
|
* / `otherLabel` est renseigne ; les deux autres sont omis du JSON (null).
|
||||||
|
* `displayDate` = getter serveur `fullDate ?? emptyDate` (spec-back § 4.0).
|
||||||
|
* `netWeight` = plein − vide en kg (RG-5.05).
|
||||||
|
*/
|
||||||
|
export interface WeighingTicket {
|
||||||
|
id: number
|
||||||
|
/** Cycle de vie : DRAFT (« En attente ») ou VALIDATED (« Terminée ») — ERP-193. */
|
||||||
|
status: WeighingTicketStatus
|
||||||
|
/** Numero metier `{siteCode}-TP-{NNNN}` — null tant que brouillon (RG-5.02). */
|
||||||
|
number: string | null
|
||||||
|
/** Embarque uniquement si contrepartie = Client (RG-5.03), sinon absent. */
|
||||||
|
client: WeighingTicketParty | null
|
||||||
|
/** Embarque uniquement si contrepartie = Fournisseur (RG-5.03), sinon absent. */
|
||||||
|
supplier: WeighingTicketParty | null
|
||||||
|
/** Libelle libre si contrepartie = Autre (RG-5.03), sinon absent. */
|
||||||
|
otherLabel: string | null
|
||||||
|
/** Date ISO du ticket (`fullDate ?? emptyDate`) — colonne « Date ». */
|
||||||
|
displayDate: string | null
|
||||||
|
/** Poids net en kg (= plein − vide, RG-5.05) — colonne « Poids ». */
|
||||||
|
netWeight: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtres de la liste des tickets de pesee, branches sur les query params de
|
||||||
|
* `GET /api/weighing_tickets` (spec-back § 4.1). La liste est par ailleurs
|
||||||
|
* cloisonnee par site courant cote back (`SiteScopedQueryExtension`, § 2.3) — le
|
||||||
|
* front n'a pas a envoyer le site.
|
||||||
|
*/
|
||||||
|
export interface WeighingTicketFilters {
|
||||||
|
search?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste des tickets de pesee (M5, ERP-188) — simple enveloppe de
|
||||||
|
* `usePaginatedList<WeighingTicket>` sur la ressource `/weighing_tickets`
|
||||||
|
* (URL API en snake_case ; la route Nuxt reste `/weighing-tickets`). Pagination
|
||||||
|
* serveur obligatoire (regle ABSOLUE n°13), etat 100 % local (regle ABSOLUE n°6).
|
||||||
|
*
|
||||||
|
* Miroir de `useCarriersRepository` (M4). Volontairement PAR INSTANCE (pas de
|
||||||
|
* singleton) : l'etat tableau est propre a l'ecran et meurt avec lui.
|
||||||
|
*/
|
||||||
|
export function useWeighingTicketsRepository() {
|
||||||
|
// Defaut 25 items/page (au lieu de 10) : la liste des tickets de pesee est
|
||||||
|
// consultee en volume. 25 fait partie des options [10, 25, 50] et reste sous le
|
||||||
|
// max serveur (50). L'utilisateur peut toujours basculer via le selecteur.
|
||||||
|
return usePaginatedList<WeighingTicket, WeighingTicketFilters>({
|
||||||
|
url: '/weighing_tickets',
|
||||||
|
defaultItemsPerPage: 25,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, ref, reactive, Suspense } from 'vue'
|
||||||
|
|
||||||
|
// ── Mocks des composables modules (le form RÉEL est conservé pour vérifier le
|
||||||
|
// pré-remplissage via hydrate). ─────────────────────────────────────────────
|
||||||
|
const mockFetchTicket = vi.hoisted(() => vi.fn())
|
||||||
|
const mockPatch = vi.hoisted(() => vi.fn())
|
||||||
|
const mockPush = vi.hoisted(() => vi.fn())
|
||||||
|
const mockOpen = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.mock('~/modules/logistique/composables/useWeighingTicket', () => ({
|
||||||
|
useWeighingTicket: () => ({ fetchTicket: mockFetchTicket }),
|
||||||
|
}))
|
||||||
|
vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({
|
||||||
|
useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: vi.fn().mockResolvedValue(undefined) }),
|
||||||
|
}))
|
||||||
|
vi.mock('~/modules/logistique/composables/useWeighbridge', () => ({
|
||||||
|
useWeighbridge: () => ({ triggerAuto: vi.fn(), triggerManual: vi.fn(), extractWeighbridgeError: () => 'err' }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
vi.stubGlobal('useHead', () => undefined)
|
||||||
|
vi.stubGlobal('useApi', () => ({ get: vi.fn(), post: vi.fn(), patch: mockPatch }))
|
||||||
|
vi.stubGlobal('useRoute', () => ({ params: { id: '9' } }))
|
||||||
|
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||||
|
vi.stubGlobal('usePermissions', () => ({ can: () => true }))
|
||||||
|
vi.stubGlobal('navigateTo', vi.fn())
|
||||||
|
vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), setError: vi.fn(), clearErrors: vi.fn(), handleApiError: vi.fn() }))
|
||||||
|
globalThis.open = mockOpen
|
||||||
|
|
||||||
|
const EditPage = (await import('../weighing-tickets/[id]/edit.vue')).default
|
||||||
|
|
||||||
|
// ── Stubs de composants ──────────────────────────────────────────────────────
|
||||||
|
const ButtonStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||||
|
emits: ['click'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const InputStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, modelValue: { default: null } },
|
||||||
|
setup(props) {
|
||||||
|
return () => h('input', { 'data-label': props.label, 'value': props.modelValue as string })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// WeighingBlock stubbé (Date/Poids/DSD + boutons) — la contrepartie vit désormais
|
||||||
|
// dans les 4 champs du haut, hors bloc (ERP-193).
|
||||||
|
const BlockStub = defineComponent({
|
||||||
|
setup() { return () => h('div', { 'data-testid': 'block' }) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const ModalStub = defineComponent({
|
||||||
|
props: { modelValue: { type: Boolean, default: false } },
|
||||||
|
setup(_, { slots }) { return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()]) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const stubs = {
|
||||||
|
MalioButtonIcon: ButtonStub,
|
||||||
|
MalioButton: ButtonStub,
|
||||||
|
MalioInputText: InputStub,
|
||||||
|
MalioInputNumber: InputStub,
|
||||||
|
MalioSelect: InputStub,
|
||||||
|
MalioDateTime: InputStub,
|
||||||
|
MalioCheckbox: InputStub,
|
||||||
|
MalioModal: ModalStub,
|
||||||
|
WeighingBlock: BlockStub,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monte la page (setup async : top-level await) via Suspense.
|
||||||
|
async function mountPage() {
|
||||||
|
const wrapper = mount(defineComponent({
|
||||||
|
components: { EditPage },
|
||||||
|
setup: () => () => h(Suspense, null, { default: () => h(EditPage) }),
|
||||||
|
}), { global: { stubs } })
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
const DETAIL = {
|
||||||
|
id: 9,
|
||||||
|
status: 'VALIDATED',
|
||||||
|
number: '86-TP-0001',
|
||||||
|
site: { id: 1, name: 'Chatellerault', code: '86' },
|
||||||
|
counterpartyType: 'CLIENT',
|
||||||
|
client: { '@id': '/api/clients/629', companyName: 'NÉGOCE MÉTAUX ATLANTIQUE' },
|
||||||
|
immatriculation: 'AB-123-CD',
|
||||||
|
plateFreeFormat: false,
|
||||||
|
emptyDate: '2026-06-17T09:00:00+02:00', emptyWeight: 7150, emptyDsd: 1, emptyMode: 'AUTO',
|
||||||
|
fullDate: '2026-06-17T09:12:00+02:00', fullWeight: 14300, fullDsd: 2, fullMode: 'AUTO',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Écran Modification ticket de pesée (page /weighing-tickets/{id}/edit)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetchTicket.mockReset().mockResolvedValue({ ...DETAIL })
|
||||||
|
mockPatch.mockReset().mockResolvedValue({})
|
||||||
|
mockPush.mockReset()
|
||||||
|
mockOpen.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charge le ticket au montage (pré-remplissage via hydrate)', async () => {
|
||||||
|
await mountPage()
|
||||||
|
expect(mockFetchTicket).toHaveBeenCalledWith('9')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ticket validé : action principale « Enregistrer » + « Imprimer » (pas « Valider »)', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
// DETAIL.status = VALIDATED → l'action principale s'intitule « Enregistrer ».
|
||||||
|
expect(wrapper.find('[data-label="logistique.weighingTickets.form.save"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-label="logistique.weighingTickets.form.print"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('« Imprimer » ouvre le bon de pesée PDF servi par le back (RG-5.08)', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
await wrapper.find('[data-label="logistique.weighingTickets.form.print"]').trigger('click')
|
||||||
|
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/9/print.pdf', '_blank')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('« Enregistrer » : PATCH brouillon puis PATCH /validate, retour à la liste', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
await wrapper.find('[data-label="logistique.weighingTickets.form.save"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
// 1. Persistance de l'état courant (brouillon) avec les 2 pesées.
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/weighing_tickets/9',
|
||||||
|
expect.objectContaining({ counterpartyType: 'CLIENT', client: '/api/clients/629', fullWeight: 14300 }),
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
// 2. Validation (back autoritaire) — ne porte que les 4 champs du haut.
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/weighing_tickets/9/validate',
|
||||||
|
expect.objectContaining({ counterpartyType: 'CLIENT', immatriculation: 'AB-123-CD' }),
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
// « Enregistrer » ouvre aussi le bon de pesée PDF (RG-5.08).
|
||||||
|
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/9/print.pdf', '_blank')
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, ref, reactive, Suspense } from 'vue'
|
||||||
|
|
||||||
|
// ── Mocks des composables modules (le form RÉEL est conservé). ────────────────
|
||||||
|
const mockPost = vi.hoisted(() => vi.fn())
|
||||||
|
const mockPatch = vi.hoisted(() => vi.fn())
|
||||||
|
const mockPush = vi.hoisted(() => vi.fn())
|
||||||
|
const mockOpen = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({
|
||||||
|
useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: vi.fn().mockResolvedValue(undefined) }),
|
||||||
|
}))
|
||||||
|
vi.mock('~/modules/logistique/composables/useWeighbridge', () => ({
|
||||||
|
useWeighbridge: () => ({ triggerAuto: vi.fn(), triggerManual: vi.fn(), extractWeighbridgeError: () => 'err' }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ── Auto-imports Nuxt stubbés globalement ───────────────────────────────────
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
vi.stubGlobal('useHead', () => undefined)
|
||||||
|
vi.stubGlobal('useApi', () => ({ get: vi.fn(), post: mockPost, patch: mockPatch }))
|
||||||
|
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||||
|
vi.stubGlobal('usePermissions', () => ({ can: () => true }))
|
||||||
|
vi.stubGlobal('navigateTo', vi.fn())
|
||||||
|
vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), setError: vi.fn(), clearErrors: vi.fn(), handleApiError: vi.fn() }))
|
||||||
|
globalThis.open = mockOpen
|
||||||
|
|
||||||
|
const NewPage = (await import('../weighing-tickets/new.vue')).default
|
||||||
|
|
||||||
|
const ButtonStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||||
|
emits: ['click'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const InputStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, modelValue: { default: null } },
|
||||||
|
setup(props) { return () => h('input', { 'data-label': props.label, 'value': props.modelValue as string }) },
|
||||||
|
})
|
||||||
|
const BlockStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'block' }) } })
|
||||||
|
const ModalStub = defineComponent({
|
||||||
|
props: { modelValue: { type: Boolean, default: false } },
|
||||||
|
setup(_, { slots }) { return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()]) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const stubs = {
|
||||||
|
MalioButtonIcon: ButtonStub,
|
||||||
|
MalioButton: ButtonStub,
|
||||||
|
MalioInputText: InputStub,
|
||||||
|
MalioSelect: InputStub,
|
||||||
|
MalioDateTime: InputStub,
|
||||||
|
MalioCheckbox: InputStub,
|
||||||
|
MalioModal: ModalStub,
|
||||||
|
WeighingBlock: BlockStub,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountPage() {
|
||||||
|
const wrapper = mount(defineComponent({
|
||||||
|
components: { NewPage },
|
||||||
|
setup: () => () => h(Suspense, null, { default: () => h(NewPage) }),
|
||||||
|
}), { global: { stubs } })
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Écran Ajouter ticket de pesée (page /weighing-tickets/new)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPost.mockReset().mockResolvedValue({ id: 42 })
|
||||||
|
mockPatch.mockReset().mockResolvedValue({})
|
||||||
|
mockPush.mockReset()
|
||||||
|
mockOpen.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('un seul bouton « Valider » (pas de « Enregistrer » séparé)', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
expect(wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-label="logistique.weighingTickets.form.save"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('« Valider » : POST brouillon (création) puis PATCH /validate, PDF + retour liste', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
await wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// 1. Création du brouillon (POST) → récupère l'id.
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/weighing_tickets',
|
||||||
|
expect.any(Object),
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
// 2. Validation (back autoritaire) sur l'id retourné.
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/weighing_tickets/42/validate',
|
||||||
|
expect.any(Object),
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
// 3. Ouverture du bon de pesée PDF + retour à la liste.
|
||||||
|
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/42/print.pdf', '_blank')
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, ref } from 'vue'
|
||||||
|
|
||||||
|
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||||
|
// La page ne les importe pas (auto-import) : on les expose en globals pour le
|
||||||
|
// runtime de test (happy-dom). Meme philosophie que les specs M1→M4.
|
||||||
|
const mockPush = vi.hoisted(() => vi.fn())
|
||||||
|
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||||
|
const mockCan = vi.hoisted(() => vi.fn())
|
||||||
|
const mockFetch = vi.hoisted(() => vi.fn())
|
||||||
|
const mockReset = vi.hoisted(() => vi.fn())
|
||||||
|
const mockToastError = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
vi.stubGlobal('useHead', () => undefined)
|
||||||
|
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||||
|
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||||
|
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
|
||||||
|
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
||||||
|
// Site courant (switcher global) : ref pilotable pour simuler un changement de site.
|
||||||
|
const currentSiteRef = ref<{ id: number } | null>(null)
|
||||||
|
vi.stubGlobal('useCurrentSite', () => ({ currentSite: currentSiteRef }))
|
||||||
|
|
||||||
|
// Le repository est lui aussi un auto-import : on controle les items renvoyes.
|
||||||
|
// Contrepartie CLIENT (RG-5.03) → supplier / otherLabel absents (skip_null_values).
|
||||||
|
vi.stubGlobal('useWeighingTicketsRepository', () => ({
|
||||||
|
items: ref([
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
number: '86-TP-0001',
|
||||||
|
client: { id: 629, companyName: 'NÉGOCE MÉTAUX ATLANTIQUE' },
|
||||||
|
supplier: null,
|
||||||
|
otherLabel: null,
|
||||||
|
displayDate: '2026-06-17T09:12:00+02:00',
|
||||||
|
netWeight: 7150,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
totalItems: ref(1),
|
||||||
|
currentPage: ref(1),
|
||||||
|
itemsPerPage: ref(10),
|
||||||
|
itemsPerPageOptions: ref([10, 25, 50]),
|
||||||
|
fetch: mockFetch,
|
||||||
|
goToPage: vi.fn(),
|
||||||
|
setItemsPerPage: vi.fn(),
|
||||||
|
setFilters: vi.fn(),
|
||||||
|
reset: mockReset,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
|
||||||
|
// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse).
|
||||||
|
globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake')
|
||||||
|
globalThis.URL.revokeObjectURL = vi.fn()
|
||||||
|
|
||||||
|
// Import APRES les stubs (la page resout les auto-imports au top-level du module).
|
||||||
|
const WeighingTicketsIndex = (await import('../weighing-tickets/index.vue')).default
|
||||||
|
|
||||||
|
// ── Stubs de composants ──────────────────────────────────────────────────────
|
||||||
|
const ButtonStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||||
|
emits: ['click'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Capture les `items` (rows) passes par la page : on rend chaque ligne avec ses
|
||||||
|
// cellules formatees (date / poids) pour pouvoir asserter le mapping des colonnes.
|
||||||
|
const capturedRows = ref<Array<Record<string, unknown>>>([])
|
||||||
|
const DataTableStub = defineComponent({
|
||||||
|
props: { items: { type: Array, default: () => [] } },
|
||||||
|
emits: ['row-click', 'update:page', 'update:per-page'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => {
|
||||||
|
capturedRows.value = props.items as Array<Record<string, unknown>>
|
||||||
|
return h('div', { 'data-testid': 'datatable' },
|
||||||
|
(props.items as Array<Record<string, unknown>>).map(it =>
|
||||||
|
h('tr', { 'data-row-id': it.id as number, onClick: () => emit('row-click', it) }, [
|
||||||
|
h('td', { 'data-cell': 'displayDate' }, it.displayDate as string),
|
||||||
|
h('td', { 'data-cell': 'netWeight' }, it.netWeight as string),
|
||||||
|
h('td', { 'data-cell': 'client' }, it.client as string),
|
||||||
|
h('td', { 'data-cell': 'supplier' }, it.supplier as string),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const PageHeaderStub = defineComponent({
|
||||||
|
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Suivi des wrappers montés pour les démonter entre tests : sans cela, les
|
||||||
|
// watchers sur la ref module-level `currentSiteRef` (site courant) fuiteraient
|
||||||
|
// d'un test à l'autre et se déclencheraient en double.
|
||||||
|
const mountedWrappers: VueWrapper[] = []
|
||||||
|
|
||||||
|
function mountPage() {
|
||||||
|
const wrapper = mount(WeighingTicketsIndex, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
PageHeader: PageHeaderStub,
|
||||||
|
MalioButton: ButtonStub,
|
||||||
|
MalioDataTable: DataTableStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
mountedWrappers.push(wrapper)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Liste des tickets de pesée (page /weighing-tickets)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPush.mockReset()
|
||||||
|
mockApiGet.mockReset().mockResolvedValue(new Blob())
|
||||||
|
mockCan.mockReset().mockReturnValue(true)
|
||||||
|
mockFetch.mockReset()
|
||||||
|
mockReset.mockReset()
|
||||||
|
mockToastError.mockReset()
|
||||||
|
capturedRows.value = []
|
||||||
|
currentSiteRef.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Démonte les composants montés → libère leurs watchers (site courant).
|
||||||
|
while (mountedWrappers.length > 0) {
|
||||||
|
mountedWrappers.pop()?.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charge la liste au montage', async () => {
|
||||||
|
mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockFetch).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recharge la liste (page 1) quand le site courant change', async () => {
|
||||||
|
mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockReset).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
// Simule un switch de site via le switcher global.
|
||||||
|
currentSiteRef.value = { id: 2 }
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockReset).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formate la date au format JJ-MM-AAAA', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-cell="displayDate"]').text()).toBe('17-06-2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formate le poids net en kg avec separateur de milliers', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-cell="netWeight"]').text()).toBe('7 150 Kg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe la contrepartie Client (supplier vide car contrepartie ≠ Fournisseur)', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-cell="client"]').text()).toBe('NÉGOCE MÉTAUX ATLANTIQUE')
|
||||||
|
expect(wrapper.find('[data-cell="supplier"]').text()).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche « + Ajouter » uniquement avec la permission manage', async () => {
|
||||||
|
mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.manage')
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
|
||||||
|
mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.view')
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigue vers la modification au clic sur une ligne', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('tr[data-row-id="9"]').trigger('click')
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets/9/edit')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appelle l\'export XLSX sur /weighing_tickets/export.xlsx en blob', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('[data-label="logistique.weighingTickets.export"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockApiGet).toHaveBeenCalledWith(
|
||||||
|
'/weighing_tickets/export.xlsx',
|
||||||
|
expect.any(Object),
|
||||||
|
expect.objectContaining({ responseType: 'blob', toast: false }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- En-tête : retour vers la liste + titre. -->
|
||||||
|
<div class="flex items-center gap-3 pt-11">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
:title="t('logistique.weighingTickets.form.back')"
|
||||||
|
v-bind="{ ariaLabel: t('logistique.weighingTickets.form.back') }"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
|
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- États de chargement / introuvable. -->
|
||||||
|
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('logistique.weighingTickets.edit.loading') }}</p>
|
||||||
|
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('logistique.weighingTickets.edit.notFound') }}</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Form à plat, pleine largeur (sans box-shadow) : un filet noir 1px
|
||||||
|
sépare chacun des 3 blocs (divide-y). -->
|
||||||
|
<div class="mt-[48px] flex flex-col divide-y divide-black">
|
||||||
|
<!-- ── 4 champs du haut : contrepartie + immatriculation + « Tout
|
||||||
|
format » (ERP-193, hors blocs de pesée). 1er bloc : pas de
|
||||||
|
padding-top (marge titre→form = mt-[48px] standard). ───────── -->
|
||||||
|
<div class="pb-[20px]">
|
||||||
|
<div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="form.counterpartyType.value"
|
||||||
|
:options="counterpartyOptions"
|
||||||
|
:label="t('logistique.weighingTickets.form.counterparty.type')"
|
||||||
|
:required="true"
|
||||||
|
empty-option-label=""
|
||||||
|
:error="errors.counterpartyType"
|
||||||
|
@update:model-value="onCounterpartyTypeChange"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-if="form.counterpartyField.value === 'supplier'"
|
||||||
|
:model-value="form.supplierIri.value"
|
||||||
|
:options="referentials.suppliers.value"
|
||||||
|
:label="t('logistique.weighingTickets.form.counterparty.supplier')"
|
||||||
|
:required="true"
|
||||||
|
empty-option-label=""
|
||||||
|
:error="errors.supplier"
|
||||||
|
@update:model-value="(v: string | number | null) => form.supplierIri.value = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-else-if="form.counterpartyField.value === 'client'"
|
||||||
|
:model-value="form.clientIri.value"
|
||||||
|
:options="referentials.clients.value"
|
||||||
|
:label="t('logistique.weighingTickets.form.counterparty.client')"
|
||||||
|
:required="true"
|
||||||
|
empty-option-label=""
|
||||||
|
:error="errors.client"
|
||||||
|
@update:model-value="(v: string | number | null) => form.clientIri.value = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-else-if="form.counterpartyField.value === 'other'"
|
||||||
|
:model-value="form.otherLabel.value"
|
||||||
|
:label="t('logistique.weighingTickets.form.counterparty.other')"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.otherLabel"
|
||||||
|
@update:model-value="(v: string | null) => form.otherLabel.value = v"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Pas de cellule vide sans type sélectionné : immat et « Tout
|
||||||
|
format » se collent au type ; le champ conditionnel les
|
||||||
|
décale une fois un type choisi. -->
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="form.immatriculation.value"
|
||||||
|
:mask="form.plateFreeFormat.value ? FREE_PLATE_MASK : PLATE_MASK"
|
||||||
|
:label="t('logistique.weighingTickets.form.immatriculation')"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.immatriculation"
|
||||||
|
@update:model-value="(v: string | null) => form.immatriculation.value = v"
|
||||||
|
/>
|
||||||
|
<MalioCheckbox
|
||||||
|
id="plate-free-format"
|
||||||
|
:model-value="form.plateFreeFormat.value"
|
||||||
|
:label="t('logistique.weighingTickets.form.plateFreeFormat')"
|
||||||
|
group-class="self-center"
|
||||||
|
@update:model-value="(v: boolean) => form.plateFreeFormat.value = v"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Bloc « Poids à vide » ──────────────────────────────────── -->
|
||||||
|
<WeighingBlock
|
||||||
|
class="py-[20px]"
|
||||||
|
block-id="empty"
|
||||||
|
:title="t('logistique.weighingTickets.form.emptyBlock')"
|
||||||
|
:block="form.empty"
|
||||||
|
:errors="emptyBlockErrors"
|
||||||
|
@update:block="(field, value) => updateBlock('empty', field, value)"
|
||||||
|
@request-auto="openAuto('empty')"
|
||||||
|
@request-manual="openManual('empty')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- ── Bloc « Poids à plein » (dernier bloc : pas de padding-bottom,
|
||||||
|
pour ne pas écarter le bouton). ──────────────────────────── -->
|
||||||
|
<WeighingBlock
|
||||||
|
class="pt-[20px]"
|
||||||
|
block-id="full"
|
||||||
|
:title="t('logistique.weighingTickets.form.fullBlock')"
|
||||||
|
:block="form.full"
|
||||||
|
:errors="fullBlockErrors"
|
||||||
|
@update:block="(field, value) => updateBlock('full', field, value)"
|
||||||
|
@request-auto="openAuto('full')"
|
||||||
|
@request-manual="openManual('full')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bas d'écran : « Imprimer » (ouvre le PDF back) + action principale
|
||||||
|
(« Valider » si brouillon, « Enregistrer » si déjà validé). -->
|
||||||
|
<div class="mt-12 flex justify-center gap-6">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:printer-outline"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('logistique.weighingTickets.form.print')"
|
||||||
|
@click="printTicket"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="primaryLabel"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="submitPrimary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────-->
|
||||||
|
<MalioModal v-model="autoModal.open" modal-class="max-w-md" footer-class="justify-center pb-6">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
|
||||||
|
</template>
|
||||||
|
<p v-if="autoModal.error" class="text-m-danger">{{ autoModal.error }}</p>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('logistique.weighingTickets.form.weighbridge.validate')"
|
||||||
|
:disabled="autoModal.loading"
|
||||||
|
@click="confirmAuto"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
|
||||||
|
<!-- ── Modal « Pesée manuelle » ────────────────────────────────────────-->
|
||||||
|
<MalioModal
|
||||||
|
v-model="manualModal.open"
|
||||||
|
modal-class="max-w-md"
|
||||||
|
header-class="mx-7 px-0 pt-6 pb-3 border-b border-black"
|
||||||
|
body-class="px-7 pt-9"
|
||||||
|
footer-class="px-7 justify-center pb-6"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold uppercase">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
|
||||||
|
</template>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="manualModal.weight"
|
||||||
|
:mask="NUMERIC_MASK"
|
||||||
|
:label="t('logistique.weighingTickets.form.manual.weight')"
|
||||||
|
:required="true"
|
||||||
|
:error="manualModal.errors.weight"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="manualModal.dsd"
|
||||||
|
:mask="NUMERIC_MASK"
|
||||||
|
:label="t('logistique.weighingTickets.form.manual.dsd')"
|
||||||
|
:required="true"
|
||||||
|
:error="manualModal.errors.dsd"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('logistique.weighingTickets.form.manual.save')"
|
||||||
|
:disabled="manualModal.loading"
|
||||||
|
@click="confirmManual"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||||
|
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
|
||||||
|
import { useWeighingTicket } from '~/modules/logistique/composables/useWeighingTicket'
|
||||||
|
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
|
||||||
|
import { NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
|
||||||
|
import { mapViolationsToRecord } from '~/shared/utils/api'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const api = useApi()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
// Modification réservée à `manage` (Admin / Bureau / Usine) — sinon retour liste.
|
||||||
|
if (!can('logistique.weighing_tickets.manage')) {
|
||||||
|
await navigateTo('/weighing-tickets')
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketId = route.params.id as string
|
||||||
|
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
const weighbridge = useWeighbridge()
|
||||||
|
const referentials = useWeighingTicketReferentials()
|
||||||
|
const { fetchTicket } = useWeighingTicket()
|
||||||
|
const { errors, clearErrors, handleApiError } = useFormErrors()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
// Numéro immuable (RG-5.09), rappelé dans le titre — vide tant que brouillon.
|
||||||
|
const ticketNumber = ref<string>('')
|
||||||
|
|
||||||
|
const headerTitle = computed(() =>
|
||||||
|
ticketNumber.value
|
||||||
|
? t('logistique.weighingTickets.edit.title', { number: ticketNumber.value })
|
||||||
|
: t('logistique.weighingTickets.edit.titleFallback'),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Libellé de l'action principale : « Valider » pour un brouillon (finalisation),
|
||||||
|
// « Enregistrer » pour un ticket déjà validé (mise à jour, ERP-193).
|
||||||
|
const isValidated = computed(() => form.status.value === 'VALIDATED')
|
||||||
|
const primaryLabel = computed(() =>
|
||||||
|
isValidated.value
|
||||||
|
? t('logistique.weighingTickets.form.save')
|
||||||
|
: t('logistique.weighingTickets.form.validate'),
|
||||||
|
)
|
||||||
|
|
||||||
|
useHead({ title: t('logistique.weighingTickets.edit.titleFallback') })
|
||||||
|
|
||||||
|
/** Retour vers la liste (flèche d'en-tête). */
|
||||||
|
function goBack(): void {
|
||||||
|
router.push('/weighing-tickets')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contrepartie (RG-5.03) — ordre maquette : Fournisseur / Client / Autre. ───
|
||||||
|
const counterpartyOptions = computed<RefOption[]>(() => [
|
||||||
|
{ value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') },
|
||||||
|
{ value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') },
|
||||||
|
{ value: 'AUTRE', label: t('logistique.weighingTickets.form.counterparty.other') },
|
||||||
|
])
|
||||||
|
|
||||||
|
function onCounterpartyTypeChange(value: string | number | null): void {
|
||||||
|
const type = (value === null || value === '') ? null : (String(value) as 'CLIENT' | 'FOURNISSEUR' | 'AUTRE')
|
||||||
|
form.setCounterpartyType(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Erreurs par bloc (mapping propertyPath back → champs du composant) ────────
|
||||||
|
const emptyBlockErrors = computed<Record<string, string>>(() => ({
|
||||||
|
date: errors.emptyDate,
|
||||||
|
weight: errors.emptyWeight,
|
||||||
|
dsd: errors.emptyDsd,
|
||||||
|
}))
|
||||||
|
const fullBlockErrors = computed<Record<string, string>>(() => ({
|
||||||
|
date: errors.fullDate,
|
||||||
|
weight: errors.fullWeight,
|
||||||
|
dsd: errors.fullDsd,
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** Mute un champ d'un bloc de pesée (état centralisé dans le form). */
|
||||||
|
function updateBlock(target: 'empty' | 'full', field: keyof WeighingBlockState, value: unknown): void {
|
||||||
|
(form[target] as Record<string, unknown>)[field as string] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modal pesée bascule (AUTO) ────────────────────────────────────────────────
|
||||||
|
const autoModal = reactive({
|
||||||
|
open: false,
|
||||||
|
error: '',
|
||||||
|
loading: false,
|
||||||
|
target: 'empty' as 'empty' | 'full',
|
||||||
|
})
|
||||||
|
|
||||||
|
function openAuto(target: 'empty' | 'full'): void {
|
||||||
|
autoModal.target = target
|
||||||
|
autoModal.error = ''
|
||||||
|
autoModal.open = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Déclenche la pesée bascule puis enregistre le brouillon (ERP-193). */
|
||||||
|
async function confirmAuto(): Promise<void> {
|
||||||
|
if (autoModal.loading) return
|
||||||
|
autoModal.loading = true
|
||||||
|
autoModal.error = ''
|
||||||
|
try {
|
||||||
|
const reading = await weighbridge.triggerAuto()
|
||||||
|
form.applyReading(form[autoModal.target], reading)
|
||||||
|
autoModal.open = false
|
||||||
|
await saveDraft()
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
autoModal.error = weighbridge.extractWeighbridgeError(e)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
autoModal.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modal pesée manuelle (MANUAL) ─────────────────────────────────────────────
|
||||||
|
const manualModal = reactive({
|
||||||
|
open: false,
|
||||||
|
loading: false,
|
||||||
|
target: 'empty' as 'empty' | 'full',
|
||||||
|
weight: null as string | null,
|
||||||
|
dsd: null as string | null,
|
||||||
|
errors: {} as Record<string, string>,
|
||||||
|
})
|
||||||
|
|
||||||
|
function openManual(target: 'empty' | 'full'): void {
|
||||||
|
manualModal.target = target
|
||||||
|
manualModal.weight = null
|
||||||
|
manualModal.dsd = null
|
||||||
|
manualModal.errors = {}
|
||||||
|
manualModal.open = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valide la saisie manuelle (poids + DSD), remplit le bloc puis enregistre le brouillon. */
|
||||||
|
async function confirmManual(): Promise<void> {
|
||||||
|
if (manualModal.loading) return
|
||||||
|
manualModal.errors = {}
|
||||||
|
|
||||||
|
const weight = manualModal.weight === null || manualModal.weight === '' ? null : Number(manualModal.weight)
|
||||||
|
const dsd = manualModal.dsd === null || manualModal.dsd === '' ? null : Number(manualModal.dsd)
|
||||||
|
if (weight === null || Number.isNaN(weight)) {
|
||||||
|
manualModal.errors = { ...manualModal.errors, weight: t('logistique.weighingTickets.form.manual.weightRequired') }
|
||||||
|
}
|
||||||
|
if (dsd === null || Number.isNaN(dsd)) {
|
||||||
|
manualModal.errors = { ...manualModal.errors, dsd: t('logistique.weighingTickets.form.manual.dsdRequired') }
|
||||||
|
}
|
||||||
|
if (Object.keys(manualModal.errors).length > 0) return
|
||||||
|
|
||||||
|
manualModal.loading = true
|
||||||
|
try {
|
||||||
|
const reading = await weighbridge.triggerManual(weight as number, dsd as number)
|
||||||
|
form.applyReading(form[manualModal.target], reading)
|
||||||
|
manualModal.open = false
|
||||||
|
await saveDraft()
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
// 422 de pesée (poids/DSD ≤ 0, Assert\Positive) → erreur sous le BON champ
|
||||||
|
// (le propertyPath back `weight`/`dsd` = nom du champ de la modale). Sinon
|
||||||
|
// (503 pont indispo, réseau) → message générique sous le champ Poids.
|
||||||
|
const violations = mapViolationsToRecord((e as { response?: { _data?: unknown } })?.response?._data)
|
||||||
|
manualModal.errors = Object.keys(violations).length > 0
|
||||||
|
? violations
|
||||||
|
: { weight: weighbridge.extractWeighbridgeError(e) }
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
manualModal.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Persistance / impression ──────────────────────────────────────────────────
|
||||||
|
/** Enregistre l'état courant en BROUILLON (PATCH). False sur erreur (422 inline). */
|
||||||
|
async function saveDraft(): Promise<boolean> {
|
||||||
|
clearErrors()
|
||||||
|
try {
|
||||||
|
await api.patch(`/weighing_tickets/${ticketId}`, form.buildDraftPayload(), { toast: false })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
handleApiError(e, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action principale : persiste l'état courant puis finalise/re-valide via
|
||||||
|
* PATCH /validate (back autoritaire : 3 champs du haut + 2 pesées). Ouvre le bon de
|
||||||
|
* pesée PDF (RG-5.08) — aussi bien à la validation d'un brouillon qu'à
|
||||||
|
* l'enregistrement d'un ticket déjà validé. Retour à la liste au succès.
|
||||||
|
*/
|
||||||
|
async function submitPrimary(): Promise<void> {
|
||||||
|
if (saving.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (!(await saveDraft())) return
|
||||||
|
|
||||||
|
await api.patch(`/weighing_tickets/${ticketId}/validate`, form.buildValidatePayload(), { toast: false })
|
||||||
|
window.open(`/api/weighing_tickets/${ticketId}/print.pdf`, '_blank')
|
||||||
|
router.push('/weighing-tickets')
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
handleApiError(e, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* « Imprimer » : ouvre le bon de pesée PDF servi par le back (Twig, ERP-192).
|
||||||
|
* Le front ne dessine AUCUN gabarit — il ouvre seulement l'URL (RG-5.08).
|
||||||
|
*/
|
||||||
|
function printTicket(): void {
|
||||||
|
window.open(`/api/weighing_tickets/${ticketId}/print.pdf`, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
referentials.load().catch(() => {})
|
||||||
|
try {
|
||||||
|
const detail = await fetchTicket(ticketId)
|
||||||
|
ticketNumber.value = detail.number ?? ''
|
||||||
|
form.hydrate(detail)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
error.value = true
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PageHeader>
|
||||||
|
{{ t('logistique.weighingTickets.title') }}
|
||||||
|
<template #actions>
|
||||||
|
<MalioButton
|
||||||
|
v-if="canManage"
|
||||||
|
variant="secondary"
|
||||||
|
:label="t('logistique.weighingTickets.add')"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
@click="goToCreate"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<!-- Datatable branchee sur usePaginatedList via useWeighingTicketsRepository :
|
||||||
|
pagination serveur (defaut 10), tri number DESC par defaut (cote back),
|
||||||
|
liste cloisonnee par site courant (spec-back § 2.3). Etat 100 % local
|
||||||
|
(regle ABSOLUE n°6). -->
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="rows"
|
||||||
|
:total-items="totalItems"
|
||||||
|
:page="currentPage"
|
||||||
|
:per-page="itemsPerPage"
|
||||||
|
:per-page-options="itemsPerPageOptions"
|
||||||
|
row-clickable
|
||||||
|
:empty-message="t('logistique.weighingTickets.empty')"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
@update:page="goToPage"
|
||||||
|
@update:per-page="setItemsPerPage"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex justify-center mt-4">
|
||||||
|
<MalioButton
|
||||||
|
v-if="canView"
|
||||||
|
variant="primary"
|
||||||
|
:label="t('logistique.weighingTickets.export')"
|
||||||
|
:disabled="exporting"
|
||||||
|
@click="exportXlsx"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { formatDateFr, formatWeightKg } from '~/modules/logistique/utils/weighingTicketFormat'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const api = useApi()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
// Site courant (switcher global) : la liste est cloisonnée par site côté back
|
||||||
|
// (spec-back § 2.3). Le front n'envoie PAS le site (résolu serveur) — il se
|
||||||
|
// contente de recharger quand le site change pour refléter le bon périmètre.
|
||||||
|
const { currentSite } = useCurrentSite()
|
||||||
|
|
||||||
|
useHead({ title: t('logistique.weighingTickets.title') })
|
||||||
|
|
||||||
|
// Bouton « + Ajouter » reserve a `manage` (Admin / Bureau / Usine). « Exporter »
|
||||||
|
// suit `view`. Compta et Commerciale n'ont aucun acces (item sidebar masque cote
|
||||||
|
// back) — spec-front § Acces.
|
||||||
|
const canManage = computed(() => can('logistique.weighing_tickets.manage'))
|
||||||
|
const canView = computed(() => can('logistique.weighing_tickets.view'))
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: tickets,
|
||||||
|
totalItems,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
fetch: loadTickets,
|
||||||
|
goToPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
reset: reloadFromFirstPage,
|
||||||
|
} = useWeighingTicketsRepository()
|
||||||
|
|
||||||
|
// Mappe les tickets en objets « plats » formates pour MalioDataTable (items typees
|
||||||
|
// Record<string, unknown>[]). La contrepartie est mutuellement exclusive (RG-5.03) :
|
||||||
|
// une seule des colonnes client / supplier / otherLabel est renseignee, les autres
|
||||||
|
// restent vides. Date et poids sont formates ici (cf. helpers ci-dessous).
|
||||||
|
const rows = computed(() => tickets.value.map(ticket => ({
|
||||||
|
id: ticket.id,
|
||||||
|
// Numéro vide tant que brouillon (attribué à la validation, ERP-193).
|
||||||
|
number: ticket.number ?? '',
|
||||||
|
client: ticket.client?.companyName ?? '',
|
||||||
|
supplier: ticket.supplier?.companyName ?? '',
|
||||||
|
otherLabel: ticket.otherLabel ?? '',
|
||||||
|
displayDate: formatDateFr(ticket.displayDate),
|
||||||
|
netWeight: formatWeightKg(ticket.netWeight),
|
||||||
|
status: t(ticket.status === 'VALIDATED'
|
||||||
|
? 'logistique.weighingTickets.status.validated'
|
||||||
|
: 'logistique.weighingTickets.status.draft'),
|
||||||
|
})))
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'number', label: t('logistique.weighingTickets.column.number') },
|
||||||
|
{ key: 'client', label: t('logistique.weighingTickets.column.client') },
|
||||||
|
{ key: 'supplier', label: t('logistique.weighingTickets.column.supplier') },
|
||||||
|
{ key: 'otherLabel', label: t('logistique.weighingTickets.column.other') },
|
||||||
|
{ key: 'displayDate', label: t('logistique.weighingTickets.column.date') },
|
||||||
|
{ key: 'netWeight', label: t('logistique.weighingTickets.column.weight') },
|
||||||
|
{ key: 'status', label: t('logistique.weighingTickets.column.status') },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Clic sur une ligne → ecran Modification (pas de consultation separee, spec § Navigation). */
|
||||||
|
function onRowClick(item: Record<string, unknown>): void {
|
||||||
|
router.push(`/weighing-tickets/${item.id}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToCreate(): void {
|
||||||
|
router.push('/weighing-tickets/new')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export XLSX ─────────────────────────────────────────────────────────────
|
||||||
|
// Exporte toute la liste (site courant applique cote back, spec-back § 4.5).
|
||||||
|
const exporting = ref(false)
|
||||||
|
|
||||||
|
async function exportXlsx(): Promise<void> {
|
||||||
|
if (exporting.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
|
||||||
|
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
|
||||||
|
// contenu faute d'overload blob sur le client partage (meme pattern M2/M3/M4).
|
||||||
|
const blob = await api.get<Blob>('/weighing_tickets/export.xlsx', {}, {
|
||||||
|
responseType: 'blob',
|
||||||
|
toast: false,
|
||||||
|
} as unknown as Parameters<typeof api.get>[2])
|
||||||
|
|
||||||
|
triggerDownload(blob, 'tickets-pesee.xlsx')
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
toast.error({
|
||||||
|
title: t('logistique.weighingTickets.toast.error'),
|
||||||
|
message: t('logistique.weighingTickets.toast.exportError'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Declenche le telechargement d'un blob via un lien temporaire. */
|
||||||
|
function triggerDownload(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changement de site courant → recharge la liste en page 1 (nouveau périmètre).
|
||||||
|
// usePaginatedList ne passe pas par useAsyncData : le refreshNuxtData() de
|
||||||
|
// switchSite ne l'atteint pas, d'où ce watcher explicite. On compare l'id pour
|
||||||
|
// ignorer l'hydratation initiale (même site) et les ré-affectations sans réel
|
||||||
|
// changement.
|
||||||
|
watch(() => currentSite.value?.id, (id, previousId) => {
|
||||||
|
if (id !== previousId) {
|
||||||
|
reloadFromFirstPage()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(loadTickets)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- En-tête : retour vers la liste + titre. -->
|
||||||
|
<div class="flex items-center gap-3 pt-11">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
:title="t('logistique.weighingTickets.form.back')"
|
||||||
|
v-bind="{ ariaLabel: t('logistique.weighingTickets.form.back') }"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
|
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('logistique.weighingTickets.form.addTitle') }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form à plat, pleine largeur (sans box-shadow) : un filet noir 1px
|
||||||
|
sépare chacun des 3 blocs (divide-y). -->
|
||||||
|
<div class="mt-[48px] flex flex-col divide-y divide-black">
|
||||||
|
<!-- ── 4 champs du haut : contrepartie (type + champ conditionnel),
|
||||||
|
immatriculation, « Tout format » (ERP-193, hors blocs de pesée).
|
||||||
|
1er bloc : pas de padding-top (marge titre→form = mt-[48px] standard). ── -->
|
||||||
|
<div class="pb-[20px]">
|
||||||
|
<div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="form.counterpartyType.value"
|
||||||
|
:options="counterpartyOptions"
|
||||||
|
:label="t('logistique.weighingTickets.form.counterparty.type')"
|
||||||
|
:required="true"
|
||||||
|
empty-option-label=""
|
||||||
|
:error="errors.counterpartyType"
|
||||||
|
@update:model-value="onCounterpartyTypeChange"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-if="form.counterpartyField.value === 'supplier'"
|
||||||
|
:model-value="form.supplierIri.value"
|
||||||
|
:options="referentials.suppliers.value"
|
||||||
|
:label="t('logistique.weighingTickets.form.counterparty.supplier')"
|
||||||
|
:required="true"
|
||||||
|
empty-option-label=""
|
||||||
|
:error="errors.supplier"
|
||||||
|
@update:model-value="(v: string | number | null) => form.supplierIri.value = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-else-if="form.counterpartyField.value === 'client'"
|
||||||
|
:model-value="form.clientIri.value"
|
||||||
|
:options="referentials.clients.value"
|
||||||
|
:label="t('logistique.weighingTickets.form.counterparty.client')"
|
||||||
|
:required="true"
|
||||||
|
empty-option-label=""
|
||||||
|
:error="errors.client"
|
||||||
|
@update:model-value="(v: string | number | null) => form.clientIri.value = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-else-if="form.counterpartyField.value === 'other'"
|
||||||
|
:model-value="form.otherLabel.value"
|
||||||
|
:label="t('logistique.weighingTickets.form.counterparty.other')"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.otherLabel"
|
||||||
|
@update:model-value="(v: string | null) => form.otherLabel.value = v"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Pas de cellule vide quand aucun type n'est choisi : immat et
|
||||||
|
« Tout format » se collent au type, et le champ conditionnel
|
||||||
|
les décale une fois un type sélectionné. -->
|
||||||
|
<!-- Immatriculation : masque XX-000-XX (plaque FR SIV) ; en « Tout
|
||||||
|
format », masque élargi. Partagée par les 2 pesées (RG-5.01). -->
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="form.immatriculation.value"
|
||||||
|
:mask="form.plateFreeFormat.value ? FREE_PLATE_MASK : PLATE_MASK"
|
||||||
|
:label="t('logistique.weighingTickets.form.immatriculation')"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.immatriculation"
|
||||||
|
@update:model-value="(v: string | null) => form.immatriculation.value = v"
|
||||||
|
/>
|
||||||
|
<MalioCheckbox
|
||||||
|
id="plate-free-format"
|
||||||
|
:model-value="form.plateFreeFormat.value"
|
||||||
|
:label="t('logistique.weighingTickets.form.plateFreeFormat')"
|
||||||
|
group-class="self-center"
|
||||||
|
@update:model-value="(v: boolean) => form.plateFreeFormat.value = v"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Bloc « Poids à vide » ──────────────────────────────────────── -->
|
||||||
|
<WeighingBlock
|
||||||
|
class="py-[20px]"
|
||||||
|
block-id="empty"
|
||||||
|
:title="t('logistique.weighingTickets.form.emptyBlock')"
|
||||||
|
:block="form.empty"
|
||||||
|
:errors="emptyBlockErrors"
|
||||||
|
@update:block="(field, value) => updateBlock('empty', field, value)"
|
||||||
|
@request-auto="openAuto('empty')"
|
||||||
|
@request-manual="openManual('empty')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- ── Bloc « Poids à plein » (dernier bloc : pas de padding-bottom,
|
||||||
|
pour ne pas écarter le bouton « Valider »). ───────────────────── -->
|
||||||
|
<WeighingBlock
|
||||||
|
class="pt-[20px]"
|
||||||
|
block-id="full"
|
||||||
|
:title="t('logistique.weighingTickets.form.fullBlock')"
|
||||||
|
:block="form.full"
|
||||||
|
:errors="fullBlockErrors"
|
||||||
|
@update:block="(field, value) => updateBlock('full', field, value)"
|
||||||
|
@request-auto="openAuto('full')"
|
||||||
|
@request-manual="openManual('full')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- « Valider » : persiste l'état courant (brouillon) puis finalise (3 champs
|
||||||
|
du haut + 2 pesées, validation back autoritaire) et ouvre le bon de
|
||||||
|
pesée PDF (RG-5.08, ERP-193). Toujours actif : les 422 s'affichent inline. -->
|
||||||
|
<div class="mt-12 flex justify-center">
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('logistique.weighingTickets.form.validate')"
|
||||||
|
:disabled="validating"
|
||||||
|
@click="submitValidate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────-->
|
||||||
|
<MalioModal v-model="autoModal.open" modal-class="max-w-md" footer-class="justify-center pb-6">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
|
||||||
|
</template>
|
||||||
|
<p v-if="autoModal.error" class="text-m-danger">{{ autoModal.error }}</p>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('logistique.weighingTickets.form.weighbridge.validate')"
|
||||||
|
:disabled="autoModal.loading"
|
||||||
|
@click="confirmAuto"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
|
||||||
|
<!-- ── Modal « Pesée manuelle » ────────────────────────────────────────-->
|
||||||
|
<MalioModal
|
||||||
|
v-model="manualModal.open"
|
||||||
|
modal-class="max-w-md"
|
||||||
|
header-class="mx-7 px-0 pt-6 pb-3 border-b border-black"
|
||||||
|
body-class="px-7 pt-9"
|
||||||
|
footer-class="px-7 justify-center pb-6"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold uppercase">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
|
||||||
|
</template>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="manualModal.weight"
|
||||||
|
:mask="NUMERIC_MASK"
|
||||||
|
:label="t('logistique.weighingTickets.form.manual.weight')"
|
||||||
|
:required="true"
|
||||||
|
:error="manualModal.errors.weight"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="manualModal.dsd"
|
||||||
|
:mask="NUMERIC_MASK"
|
||||||
|
:label="t('logistique.weighingTickets.form.manual.dsd')"
|
||||||
|
:required="true"
|
||||||
|
:error="manualModal.errors.dsd"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('logistique.weighingTickets.form.manual.save')"
|
||||||
|
:disabled="manualModal.loading"
|
||||||
|
@click="confirmManual"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||||
|
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
|
||||||
|
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
|
||||||
|
import { NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
|
||||||
|
import { mapViolationsToRecord } from '~/shared/utils/api'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const api = useApi()
|
||||||
|
const router = useRouter()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
useHead({ title: t('logistique.weighingTickets.form.addTitle') })
|
||||||
|
|
||||||
|
// Création réservée à `manage` (Admin / Bureau / Usine) — sinon retour à la liste.
|
||||||
|
if (!can('logistique.weighing_tickets.manage')) {
|
||||||
|
await navigateTo('/weighing-tickets')
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
const weighbridge = useWeighbridge()
|
||||||
|
const referentials = useWeighingTicketReferentials()
|
||||||
|
const { errors, clearErrors, handleApiError } = useFormErrors()
|
||||||
|
|
||||||
|
const validating = ref(false)
|
||||||
|
|
||||||
|
/** Retour vers la liste (flèche d'en-tête). */
|
||||||
|
function goBack(): void {
|
||||||
|
router.push('/weighing-tickets')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contrepartie (RG-5.03) — ordre maquette : Fournisseur / Client / Autre. ───
|
||||||
|
const counterpartyOptions = computed<RefOption[]>(() => [
|
||||||
|
{ value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') },
|
||||||
|
{ value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') },
|
||||||
|
{ value: 'AUTRE', label: t('logistique.weighingTickets.form.counterparty.other') },
|
||||||
|
])
|
||||||
|
|
||||||
|
function onCounterpartyTypeChange(value: string | number | null): void {
|
||||||
|
const type = (value === null || value === '') ? null : (String(value) as 'CLIENT' | 'FOURNISSEUR' | 'AUTRE')
|
||||||
|
form.setCounterpartyType(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Erreurs par bloc (mapping propertyPath back → champs du composant) ────────
|
||||||
|
const emptyBlockErrors = computed<Record<string, string>>(() => ({
|
||||||
|
date: errors.emptyDate,
|
||||||
|
weight: errors.emptyWeight,
|
||||||
|
dsd: errors.emptyDsd,
|
||||||
|
}))
|
||||||
|
const fullBlockErrors = computed<Record<string, string>>(() => ({
|
||||||
|
date: errors.fullDate,
|
||||||
|
weight: errors.fullWeight,
|
||||||
|
dsd: errors.fullDsd,
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** Mute un champ d'un bloc de pesée (état centralisé dans le form). */
|
||||||
|
function updateBlock(target: 'empty' | 'full', field: keyof WeighingBlockState, value: unknown): void {
|
||||||
|
(form[target] as Record<string, unknown>)[field as string] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modal pesée bascule (AUTO) ────────────────────────────────────────────────
|
||||||
|
const autoModal = reactive({
|
||||||
|
open: false,
|
||||||
|
error: '',
|
||||||
|
loading: false,
|
||||||
|
target: 'empty' as 'empty' | 'full',
|
||||||
|
})
|
||||||
|
|
||||||
|
function openAuto(target: 'empty' | 'full'): void {
|
||||||
|
autoModal.target = target
|
||||||
|
autoModal.error = ''
|
||||||
|
autoModal.open = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Déclenche la pesée bascule puis enregistre le brouillon (ERP-193). */
|
||||||
|
async function confirmAuto(): Promise<void> {
|
||||||
|
if (autoModal.loading) return
|
||||||
|
autoModal.loading = true
|
||||||
|
autoModal.error = ''
|
||||||
|
try {
|
||||||
|
const reading = await weighbridge.triggerAuto()
|
||||||
|
form.applyReading(form[autoModal.target], reading)
|
||||||
|
autoModal.open = false
|
||||||
|
await saveDraft()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
autoModal.error = weighbridge.extractWeighbridgeError(error)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
autoModal.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modal pesée manuelle (MANUAL) ─────────────────────────────────────────────
|
||||||
|
const manualModal = reactive({
|
||||||
|
open: false,
|
||||||
|
loading: false,
|
||||||
|
target: 'empty' as 'empty' | 'full',
|
||||||
|
weight: null as string | null,
|
||||||
|
dsd: null as string | null,
|
||||||
|
errors: {} as Record<string, string>,
|
||||||
|
})
|
||||||
|
|
||||||
|
function openManual(target: 'empty' | 'full'): void {
|
||||||
|
manualModal.target = target
|
||||||
|
manualModal.weight = null
|
||||||
|
manualModal.dsd = null
|
||||||
|
manualModal.errors = {}
|
||||||
|
manualModal.open = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valide la saisie manuelle (poids + DSD), remplit le bloc puis enregistre le brouillon. */
|
||||||
|
async function confirmManual(): Promise<void> {
|
||||||
|
if (manualModal.loading) return
|
||||||
|
manualModal.errors = {}
|
||||||
|
|
||||||
|
const weight = manualModal.weight === null || manualModal.weight === '' ? null : Number(manualModal.weight)
|
||||||
|
const dsd = manualModal.dsd === null || manualModal.dsd === '' ? null : Number(manualModal.dsd)
|
||||||
|
if (weight === null || Number.isNaN(weight)) {
|
||||||
|
manualModal.errors = { ...manualModal.errors, weight: t('logistique.weighingTickets.form.manual.weightRequired') }
|
||||||
|
}
|
||||||
|
if (dsd === null || Number.isNaN(dsd)) {
|
||||||
|
manualModal.errors = { ...manualModal.errors, dsd: t('logistique.weighingTickets.form.manual.dsdRequired') }
|
||||||
|
}
|
||||||
|
if (Object.keys(manualModal.errors).length > 0) return
|
||||||
|
|
||||||
|
manualModal.loading = true
|
||||||
|
try {
|
||||||
|
const reading = await weighbridge.triggerManual(weight as number, dsd as number)
|
||||||
|
form.applyReading(form[manualModal.target], reading)
|
||||||
|
manualModal.open = false
|
||||||
|
await saveDraft()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
// 422 de pesée (poids/DSD ≤ 0, Assert\Positive) → erreur sous le BON champ
|
||||||
|
// (le propertyPath back `weight`/`dsd` = nom du champ de la modale). Sinon
|
||||||
|
// (503 pont indispo, réseau) → message générique sous le champ Poids.
|
||||||
|
const violations = mapViolationsToRecord((error as { response?: { _data?: unknown } })?.response?._data)
|
||||||
|
manualModal.errors = Object.keys(violations).length > 0
|
||||||
|
? violations
|
||||||
|
: { weight: weighbridge.extractWeighbridgeError(error) }
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
manualModal.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Persistance ──────────────────────────────────────────────────────────────
|
||||||
|
interface TicketResponse { id: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enregistre l'état courant en BROUILLON (ERP-193) : POST si le ticket n'existe pas
|
||||||
|
* encore (1ʳᵉ pesée enregistrée), PATCH ensuite. Renvoie false sur erreur (422
|
||||||
|
* mappée inline, ex. format d'immatriculation).
|
||||||
|
*/
|
||||||
|
async function saveDraft(): Promise<boolean> {
|
||||||
|
clearErrors()
|
||||||
|
try {
|
||||||
|
if (form.ticketId.value === null) {
|
||||||
|
const created = await api.post<TicketResponse>('/weighing_tickets', form.buildDraftPayload(), {
|
||||||
|
headers: { Accept: 'application/ld+json' },
|
||||||
|
toast: false,
|
||||||
|
})
|
||||||
|
form.ticketId.value = created.id
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await api.patch(`/weighing_tickets/${form.ticketId.value}`, form.buildDraftPayload(), { toast: false })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
handleApiError(error, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* « Valider » : persiste l'état courant puis finalise via PATCH /validate. La
|
||||||
|
* validation stricte (3 champs du haut + 2 pesées) est portée par le back ; les 422
|
||||||
|
* remontent inline. Succès → ouverture du bon de pesée PDF + retour à la liste.
|
||||||
|
*/
|
||||||
|
async function submitValidate(): Promise<void> {
|
||||||
|
if (validating.value) return
|
||||||
|
validating.value = true
|
||||||
|
try {
|
||||||
|
if (!(await saveDraft())) return
|
||||||
|
|
||||||
|
await api.patch(`/weighing_tickets/${form.ticketId.value}/validate`, form.buildValidatePayload(), { toast: false })
|
||||||
|
window.open(`/api/weighing_tickets/${form.ticketId.value}/print.pdf`, '_blank')
|
||||||
|
router.push('/weighing-tickets')
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
handleApiError(error, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
validating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
referentials.load().catch(() => {})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { formatDateFr, formatWeightKg, formatPlate } from '../weighingTicketFormat'
|
||||||
|
|
||||||
|
describe('weighingTicketFormat', () => {
|
||||||
|
// ── Date JJ-MM-AAAA ───────────────────────────────────────────────────────
|
||||||
|
describe('formatDateFr', () => {
|
||||||
|
it('formate un datetime ISO en JJ-MM-AAAA', () => {
|
||||||
|
expect(formatDateFr('2026-06-17T09:12:00+02:00')).toBe('17-06-2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zéro-pad le jour et le mois', () => {
|
||||||
|
expect(formatDateFr('2026-01-05T00:00:00Z')).toBe('05-01-2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne une chaîne vide si absente ou invalide', () => {
|
||||||
|
expect(formatDateFr(null)).toBe('')
|
||||||
|
expect(formatDateFr(undefined)).toBe('')
|
||||||
|
expect(formatDateFr('pas-une-date')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Poids « X XXX Kg » ────────────────────────────────────────────────────
|
||||||
|
describe('formatWeightKg', () => {
|
||||||
|
it('ajoute un séparateur de milliers (espace) et le suffixe Kg', () => {
|
||||||
|
expect(formatWeightKg(7150)).toBe('7 150 Kg')
|
||||||
|
expect(formatWeightKg(14300)).toBe('14 300 Kg')
|
||||||
|
expect(formatWeightKg(1000000)).toBe('1 000 000 Kg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gère les petits nombres sans séparateur', () => {
|
||||||
|
expect(formatWeightKg(0)).toBe('0 Kg')
|
||||||
|
expect(formatWeightKg(999)).toBe('999 Kg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne une chaîne vide si le poids est absent', () => {
|
||||||
|
expect(formatWeightKg(null)).toBe('')
|
||||||
|
expect(formatWeightKg(undefined)).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Immatriculation UPPER ─────────────────────────────────────────────────
|
||||||
|
describe('formatPlate', () => {
|
||||||
|
it('met en majuscules et trim', () => {
|
||||||
|
expect(formatPlate(' ab-123-cd ')).toBe('AB-123-CD')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne une chaîne vide si absente', () => {
|
||||||
|
expect(formatPlate(null)).toBe('')
|
||||||
|
expect(formatPlate('')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { MaskInputOptions } from 'maska'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Masques de saisie du module « Tickets de pesée » (M5). Partagés entre le
|
||||||
|
* composant de bloc (`WeighingBlock`) et les modales de pesée (écrans Ajouter /
|
||||||
|
* Modifier). La validation de format reste autoritaire côté serveur (RG-5.01).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Masque « chiffres uniquement » (longueur libre) — Poids et DSD. Verrouille la
|
||||||
|
* saisie sur des entiers.
|
||||||
|
*/
|
||||||
|
export const NUMERIC_MASK: MaskInputOptions = {
|
||||||
|
mask: 'D',
|
||||||
|
tokens: { D: { pattern: /[0-9]/, multiple: true } },
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Masque plaque FR SIV `XX-000-XX` : 2 lettres, 3 chiffres, 2 lettres, majuscules
|
||||||
|
* forcées. Utilisé quand « Tout format » n'est pas coché (RG-5.01).
|
||||||
|
*/
|
||||||
|
export const PLATE_MASK: MaskInputOptions = {
|
||||||
|
mask: 'AA-###-AA',
|
||||||
|
tokens: { A: { pattern: /[A-Za-z]/, transform: (c: string) => c.toUpperCase() } },
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Masque « Tout format » (RG-5.01) : plaques anciennes / étrangères / engins. On
|
||||||
|
* autorise lettres, chiffres, espace et tiret, en MAJUSCULES, longueur libre —
|
||||||
|
* mais on filtre tout le reste (accents, ponctuation, symboles : « &é"'(_ç… »).
|
||||||
|
* Pattern maska charset du projet (cf. shared/utils/textSanitize) : `preProcess`
|
||||||
|
* retire d'abord les caractères hors charset (le token `multiple` glouton
|
||||||
|
* s'arrêterait sinon au 1er invalide), puis le token laisse passer le reste.
|
||||||
|
*/
|
||||||
|
export const FREE_PLATE_MASK: MaskInputOptions = {
|
||||||
|
mask: 'P',
|
||||||
|
tokens: { P: { pattern: /[A-Z0-9 -]/, multiple: true } },
|
||||||
|
preProcess: (value: string) => value.toUpperCase().replace(/[^A-Z0-9 -]/g, ''),
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Filtres d'affichage du module « Tickets de pesée » (M5, ERP-191). Helpers PURS
|
||||||
|
* et testables, partagés par la liste et les écrans. Le serveur reste l'autorité
|
||||||
|
* de normalisation (spec-front § Règles de formatage) : ces helpers ne font que
|
||||||
|
* mettre en forme la valeur déjà normalisée renvoyée par l'API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Date courte française `JJ-MM-AAAA` (spec M5) : helper partagé inter-modules
|
||||||
|
// (mutualisé avec les répertoires M1→M4). Re-exporté ici pour les écrans M5.
|
||||||
|
export { formatDateFr } from '~/shared/utils/date'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poids en kg avec séparateur de milliers (espace) + suffixe « Kg »
|
||||||
|
* (spec-front : « 7 150 Kg »). Chaîne vide si le poids est absent (ticket dont la
|
||||||
|
* pesée à plein n'est pas finalisée). Groupement manuel (espace ASCII) pour un
|
||||||
|
* rendu déterministe, indépendant de l'ICU de l'environnement.
|
||||||
|
*/
|
||||||
|
export function formatWeightKg(value: number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const grouped = String(Math.round(value)).replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
|
||||||
|
return `${grouped} Kg`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immatriculation en MAJUSCULES (cohérent avec la normalisation serveur RG-5.01 :
|
||||||
|
* trim + UPPER). Chaîne vide si absente.
|
||||||
|
*/
|
||||||
|
export function formatPlate(value: string | null | undefined): string {
|
||||||
|
return value ? value.trim().toUpperCase() : ''
|
||||||
|
}
|
||||||
@@ -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}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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).\$_\$");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ use ApiPlatform\Metadata\Post;
|
|||||||
use App\Module\Commercial\Domain\Entity\Client; // relation ORM partagee (§ 2.1)
|
use App\Module\Commercial\Domain\Entity\Client; // relation ORM partagee (§ 2.1)
|
||||||
use App\Module\Commercial\Domain\Entity\Supplier; // relation ORM partagee (§ 2.1)
|
use App\Module\Commercial\Domain\Entity\Supplier; // relation ORM partagee (§ 2.1)
|
||||||
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighingTicketProcessor;
|
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighingTicketProcessor;
|
||||||
|
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider\WeighingTicketPrintProvider;
|
||||||
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider\WeighingTicketProvider;
|
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider\WeighingTicketProvider;
|
||||||
use App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository;
|
use App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository;
|
||||||
use App\Module\Sites\Domain\Entity\Site; // relation ORM partagee (§ 2.1)
|
use App\Module\Sites\Domain\Entity\Site; // relation ORM partagee (§ 2.1)
|
||||||
@@ -84,6 +85,18 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
]],
|
]],
|
||||||
provider: WeighingTicketProvider::class,
|
provider: WeighingTicketProvider::class,
|
||||||
),
|
),
|
||||||
|
// Bon de pesee PDF (RG-5.08, spec § 2.12 / § 4.6) : operation dediee qui
|
||||||
|
// sert un binaire (pas une representation Hydra). Le provider retourne une
|
||||||
|
// Response -> la serialisation est court-circuitee. Pas de controller
|
||||||
|
// (decision spec § 4.6). Pas de format API Platform negocie : `.pdf` est
|
||||||
|
// litteral dans l'URI.
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/weighing_tickets/{id}/print.pdf',
|
||||||
|
security: "is_granted('logistique.weighing_tickets.view')",
|
||||||
|
provider: WeighingTicketPrintProvider::class,
|
||||||
|
output: false,
|
||||||
|
read: true,
|
||||||
|
),
|
||||||
new Post(
|
new Post(
|
||||||
security: "is_granted('logistique.weighing_tickets.manage')",
|
security: "is_granted('logistique.weighing_tickets.manage')",
|
||||||
normalizationContext: ['groups' => [
|
normalizationContext: ['groups' => [
|
||||||
@@ -95,6 +108,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
'default:read',
|
'default:read',
|
||||||
]],
|
]],
|
||||||
denormalizationContext: ['groups' => ['weighing_ticket:write']],
|
denormalizationContext: ['groups' => ['weighing_ticket:write']],
|
||||||
|
// Erreurs de denormalisation (date non parsable, type/IRI invalide)
|
||||||
|
// remontees en 422 avec propertyPath (et non 400 opaque) -> mapping
|
||||||
|
// inline par champ cote front via useFormErrors (miroir M1 Client).
|
||||||
|
collectDenormalizationErrors: true,
|
||||||
processor: WeighingTicketProcessor::class,
|
processor: WeighingTicketProcessor::class,
|
||||||
),
|
),
|
||||||
new Patch(
|
new Patch(
|
||||||
@@ -108,6 +125,30 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
'default:read',
|
'default:read',
|
||||||
]],
|
]],
|
||||||
denormalizationContext: ['groups' => ['weighing_ticket:write']],
|
denormalizationContext: ['groups' => ['weighing_ticket:write']],
|
||||||
|
collectDenormalizationErrors: true,
|
||||||
|
provider: WeighingTicketProvider::class,
|
||||||
|
processor: WeighingTicketProcessor::class,
|
||||||
|
),
|
||||||
|
// Validation (« Valider », ERP-193) : transition brouillon -> valide. Seule
|
||||||
|
// operation qui exige le groupe `finalize` (contrepartie + immatriculation +
|
||||||
|
// les 2 pesees, § 2.14) ; le Processor y attribue le numero et passe status
|
||||||
|
// a VALIDATED. Le POST/PATCH standard restent « brouillon » (validation
|
||||||
|
// Default relachee, on enregistre une pesee sans contrepartie/immat).
|
||||||
|
new Patch(
|
||||||
|
uriTemplate: '/weighing_tickets/{id}/validate',
|
||||||
|
name: 'weighing_ticket_validate',
|
||||||
|
security: "is_granted('logistique.weighing_tickets.manage')",
|
||||||
|
normalizationContext: ['groups' => [
|
||||||
|
'weighing_ticket:read',
|
||||||
|
'weighing_ticket:item:read',
|
||||||
|
'client:read',
|
||||||
|
'supplier:read',
|
||||||
|
'site:read',
|
||||||
|
'default:read',
|
||||||
|
]],
|
||||||
|
denormalizationContext: ['groups' => ['weighing_ticket:write']],
|
||||||
|
validationContext: ['groups' => ['Default', 'finalize']],
|
||||||
|
collectDenormalizationErrors: true,
|
||||||
provider: WeighingTicketProvider::class,
|
provider: WeighingTicketProvider::class,
|
||||||
processor: WeighingTicketProcessor::class,
|
processor: WeighingTicketProcessor::class,
|
||||||
),
|
),
|
||||||
@@ -128,14 +169,20 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
{
|
{
|
||||||
use TimestampableBlamableTrait;
|
use TimestampableBlamableTrait;
|
||||||
|
|
||||||
|
/** Brouillon : pesee(s) enregistree(s), pas encore valide (« En attente »). */
|
||||||
|
public const string STATUS_DRAFT = 'DRAFT';
|
||||||
|
|
||||||
|
/** Valide : contrepartie + immatriculation + 2 pesees OK, numero attribue (« Terminée »). */
|
||||||
|
public const string STATUS_VALIDATED = 'VALIDATED';
|
||||||
|
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['weighing_ticket:read'])]
|
#[Groups(['weighing_ticket:read'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
/** Numero {siteCode}-TP-{NNNN} — attribue serveur, lecture seule, immuable (RG-5.02). */
|
/** Numero {siteCode}-TP-{NNNN} — attribue serveur a la VALIDATION, null tant que brouillon, immuable ensuite (RG-5.02, ERP-193). */
|
||||||
#[ORM\Column(length: 20)]
|
#[ORM\Column(length: 20, nullable: true)]
|
||||||
#[Groups(['weighing_ticket:read'])]
|
#[Groups(['weighing_ticket:read'])]
|
||||||
private ?string $number = null;
|
private ?string $number = null;
|
||||||
|
|
||||||
@@ -145,9 +192,9 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
#[Groups(['weighing_ticket:item:read'])]
|
#[Groups(['weighing_ticket:item:read'])]
|
||||||
private ?Site $site = null;
|
private ?Site $site = null;
|
||||||
|
|
||||||
/** CLIENT | FOURNISSEUR | AUTRE (RG-5.03) — pilote le champ associe obligatoire. */
|
/** CLIENT | FOURNISSEUR | AUTRE (RG-5.03) — null tant que brouillon, requis a la validation. Pilote le champ associe obligatoire. */
|
||||||
#[ORM\Column(name: 'counterparty_type', length: 12)]
|
#[ORM\Column(name: 'counterparty_type', length: 12, nullable: true)]
|
||||||
#[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.')]
|
#[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.', groups: ['finalize'])]
|
||||||
#[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR', 'AUTRE'], message: 'Type de contrepartie invalide.')]
|
#[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR', 'AUTRE'], message: 'Type de contrepartie invalide.')]
|
||||||
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||||
private ?string $counterpartyType = null;
|
private ?string $counterpartyType = null;
|
||||||
@@ -170,9 +217,9 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||||
private ?string $otherLabel = null;
|
private ?string $otherLabel = null;
|
||||||
|
|
||||||
/** Plaque du vehicule, partagee entre les 2 formulaires (RG-5.01). Masque XX-000-XX sauf plateFreeFormat. */
|
/** Plaque du vehicule, partagee entre les 2 formulaires (RG-5.01). Null tant que brouillon, requise a la validation. Masque XX-000-XX sauf plateFreeFormat. */
|
||||||
#[ORM\Column(length: 20)]
|
#[ORM\Column(length: 20, nullable: true)]
|
||||||
#[Assert\NotBlank(message: 'L\'immatriculation est obligatoire.', normalizer: 'trim')]
|
#[Assert\NotBlank(message: 'L\'immatriculation est obligatoire.', normalizer: 'trim', groups: ['finalize'])]
|
||||||
#[Assert\Length(max: 20, maxMessage: 'L\'immatriculation ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
#[Assert\Length(max: 20, maxMessage: 'L\'immatriculation ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||||
private ?string $immatriculation = null;
|
private ?string $immatriculation = null;
|
||||||
@@ -190,7 +237,12 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||||
private ?DateTimeImmutable $emptyDate = null;
|
private ?DateTimeImmutable $emptyDate = null;
|
||||||
|
|
||||||
/** Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07). */
|
/**
|
||||||
|
* Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07).
|
||||||
|
* Nullable au brouillon (on peut enregistrer la seule pesee a plein d'abord,
|
||||||
|
* ERP-193). L'obligation des DEUX pesees est portee par validateFinalization
|
||||||
|
* (groupe `finalize`), jouee uniquement a la validation.
|
||||||
|
*/
|
||||||
#[ORM\Column(name: 'empty_weight', nullable: true)]
|
#[ORM\Column(name: 'empty_weight', nullable: true)]
|
||||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||||
private ?int $emptyWeight = null;
|
private ?int $emptyWeight = null;
|
||||||
@@ -205,12 +257,6 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||||
private ?string $emptyMode = null;
|
private ?string $emptyMode = null;
|
||||||
|
|
||||||
/** Numero de pesee saisi en manuelle (distinct du DSD) — RG-5.04. */
|
|
||||||
#[ORM\Column(name: 'empty_manual_number', length: 50, nullable: true)]
|
|
||||||
#[Assert\Length(max: 50, maxMessage: 'Le numéro de pesée ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
|
||||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
|
||||||
private ?string $emptyManualNumber = null;
|
|
||||||
|
|
||||||
// === Pesee a plein (§ 2.4) ===
|
// === Pesee a plein (§ 2.4) ===
|
||||||
|
|
||||||
#[ORM\Column(name: 'full_date', type: 'datetime_immutable', nullable: true)]
|
#[ORM\Column(name: 'full_date', type: 'datetime_immutable', nullable: true)]
|
||||||
@@ -232,17 +278,21 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||||
private ?string $fullMode = null;
|
private ?string $fullMode = null;
|
||||||
|
|
||||||
/** Numero de pesee saisi en manuelle (distinct du DSD) — RG-5.04. */
|
|
||||||
#[ORM\Column(name: 'full_manual_number', length: 50, nullable: true)]
|
|
||||||
#[Assert\Length(max: 50, maxMessage: 'Le numéro de pesée ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
|
||||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
|
||||||
private ?string $fullManualNumber = null;
|
|
||||||
|
|
||||||
/** Poids net derive plein - vide (kg) — calcule serveur (RG-5.05). Colonne Poids de la liste. */
|
/** Poids net derive plein - vide (kg) — calcule serveur (RG-5.05). Colonne Poids de la liste. */
|
||||||
#[ORM\Column(name: 'net_weight', nullable: true)]
|
#[ORM\Column(name: 'net_weight', nullable: true)]
|
||||||
#[Groups(['weighing_ticket:read'])]
|
#[Groups(['weighing_ticket:read'])]
|
||||||
private ?int $netWeight = null;
|
private ?int $netWeight = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cycle de vie (ERP-193) : DRAFT (« En attente » — pesee enregistree sans
|
||||||
|
* contrepartie/immat) -> VALIDATED (« Terminée » — valide avec numero). Pose
|
||||||
|
* serveur (DRAFT a la creation, VALIDATED par l'operation `validate`) ; pas de
|
||||||
|
* groupe d'ecriture (jamais pilote par le client).
|
||||||
|
*/
|
||||||
|
#[ORM\Column(length: 12, options: ['default' => self::STATUS_DRAFT])]
|
||||||
|
#[Groups(['weighing_ticket:read'])]
|
||||||
|
private string $status = self::STATUS_DRAFT;
|
||||||
|
|
||||||
/** Soft-delete technique prepare mais non expose au M5 (§ 2.13) — pas de groupe. */
|
/** Soft-delete technique prepare mais non expose au M5 (§ 2.13) — pas de groupe. */
|
||||||
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
|
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
|
||||||
private ?DateTimeImmutable $deletedAt = null;
|
private ?DateTimeImmutable $deletedAt = null;
|
||||||
@@ -259,7 +309,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
* (chk_wt_*_branch) et la normalisation du Processor (qui null-ifie les
|
* (chk_wt_*_branch) et la normalisation du Processor (qui null-ifie les
|
||||||
* champs hors-branche — ERP-185).
|
* champs hors-branche — ERP-185).
|
||||||
*/
|
*/
|
||||||
#[Assert\Callback]
|
#[Assert\Callback(groups: ['finalize'])]
|
||||||
public function validateCounterpartyConsistency(ExecutionContextInterface $context): void
|
public function validateCounterpartyConsistency(ExecutionContextInterface $context): void
|
||||||
{
|
{
|
||||||
switch ($this->counterpartyType) {
|
switch ($this->counterpartyType) {
|
||||||
@@ -295,6 +345,31 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation finale (ERP-193, § 2.14) : un ticket ne peut etre VALIDE qu'avec
|
||||||
|
* ses DEUX pesees renseignees (le poids net plein - vide n'a de sens que
|
||||||
|
* complet). Jouee uniquement dans le groupe `finalize` (operation `validate`) ;
|
||||||
|
* un brouillon peut ne porter qu'une seule pesee. Violations posees sur les
|
||||||
|
* champs poids -> mapping inline front (useFormErrors, ERP-101).
|
||||||
|
*/
|
||||||
|
#[Assert\Callback(groups: ['finalize'])]
|
||||||
|
public function validateFinalization(ExecutionContextInterface $context): void
|
||||||
|
{
|
||||||
|
if (null === $this->emptyWeight) {
|
||||||
|
$context->buildViolation('La pesée à vide est obligatoire pour valider le ticket.')
|
||||||
|
->atPath('emptyWeight')
|
||||||
|
->addViolation()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $this->fullWeight) {
|
||||||
|
$context->buildViolation('La pesée à plein est obligatoire pour valider le ticket.')
|
||||||
|
->atPath('fullWeight')
|
||||||
|
->addViolation()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Date du ticket affichee en LISTE (§ 4.0) : date de la pesee a plein si
|
* Date du ticket affichee en LISTE (§ 4.0) : date de la pesee a plein si
|
||||||
* disponible, sinon date de la pesee a vide. Getter calcule (jamais
|
* disponible, sinon date de la pesee a vide. Getter calcule (jamais
|
||||||
@@ -459,18 +534,6 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getEmptyManualNumber(): ?string
|
|
||||||
{
|
|
||||||
return $this->emptyManualNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setEmptyManualNumber(?string $emptyManualNumber): static
|
|
||||||
{
|
|
||||||
$this->emptyManualNumber = $emptyManualNumber;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getFullDate(): ?DateTimeImmutable
|
public function getFullDate(): ?DateTimeImmutable
|
||||||
{
|
{
|
||||||
return $this->fullDate;
|
return $this->fullDate;
|
||||||
@@ -519,18 +582,6 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getFullManualNumber(): ?string
|
|
||||||
{
|
|
||||||
return $this->fullManualNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setFullManualNumber(?string $fullManualNumber): static
|
|
||||||
{
|
|
||||||
$this->fullManualNumber = $fullManualNumber;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getNetWeight(): ?int
|
public function getNetWeight(): ?int
|
||||||
{
|
{
|
||||||
return $this->netWeight;
|
return $this->netWeight;
|
||||||
@@ -543,6 +594,23 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getStatus(): string
|
||||||
|
{
|
||||||
|
return $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStatus(string $status): static
|
||||||
|
{
|
||||||
|
$this->status = $status;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isValidated(): bool
|
||||||
|
{
|
||||||
|
return self::STATUS_VALIDATED === $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
public function getDeletedAt(): ?DateTimeImmutable
|
public function getDeletedAt(): ?DateTimeImmutable
|
||||||
{
|
{
|
||||||
return $this->deletedAt;
|
return $this->deletedAt;
|
||||||
|
|||||||
+29
-19
@@ -20,17 +20,16 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
*
|
*
|
||||||
* - AUTO (`{ "mode": "AUTO" }`) → `{ weight, dsd, mode }` (stub : poids
|
* - AUTO (`{ "mode": "AUTO" }`) → `{ weight, dsd, mode }` (stub : poids
|
||||||
* aleatoire ∈ [10000,50000] kg + DSD du site, RG-5.04 / RG-5.06).
|
* aleatoire ∈ [10000,50000] kg + DSD du site, RG-5.04 / RG-5.06).
|
||||||
* - MANUAL (`{ "mode": "MANUAL", "weight": <int>, "manualNumber": "<str>" }`)
|
* - MANUAL (`{ "mode": "MANUAL", "weight": <int>, "dsd": <int> }`)
|
||||||
* → `{ weight, dsd, manualNumber, mode }` (DSD = dernier DSD du site + 1).
|
* → `{ weight, dsd, mode }`. Le DSD est SAISI par l'operateur (numero du pont
|
||||||
|
* qu'il a reellement utilise) et conserve tel quel — plus d'auto-increment
|
||||||
|
* (ERP-193). Pas d'unicite : un DSD peut se repeter.
|
||||||
*
|
*
|
||||||
* `read: false` : pas de chargement d'entite existante — le payload est
|
* `read: false` : pas de chargement d'entite existante — le payload est
|
||||||
* denormalise directement dans cette ressource, puis le Processor prend le relais.
|
* denormalise directement dans cette ressource, puis le Processor prend le relais.
|
||||||
*
|
*
|
||||||
* ⚠ Le `dsd` renvoye ici est PREVISIONNEL : l'attribution AUTORITAIRE du DSD
|
* ⚠ En AUTO, le `dsd` renvoye est fourni par le pont. En MANUAL, c'est la valeur
|
||||||
* (et du numero de ticket) est refaite/verrouillee a la creation du ticket
|
* saisie. Le ticket persiste fait foi.
|
||||||
* (`POST /api/weighing_tickets`, ERP-185) pour eviter les collisions si deux
|
|
||||||
* postes pesent en parallele. Le front affiche cette valeur, mais c'est le
|
|
||||||
* ticket persiste qui fait foi.
|
|
||||||
*/
|
*/
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
shortName: 'WeighbridgeReading',
|
shortName: 'WeighbridgeReading',
|
||||||
@@ -63,29 +62,40 @@ final class WeighbridgeReadingResource
|
|||||||
#[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])]
|
#[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])]
|
||||||
public ?int $weight = null;
|
public ?int $weight = null;
|
||||||
|
|
||||||
/** Numero de pesee papier saisi en MANUAL (distinct du DSD, RG-5.04). */
|
/**
|
||||||
#[Assert\Length(max: 50, maxMessage: 'Le numéro de pesée ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
* DSD de la pesee. En AUTO : fourni par le pont (lecture seule). En MANUAL :
|
||||||
|
* SAISI par l'operateur et conserve tel quel (ERP-193). Positif s'il est present
|
||||||
|
* (l'obligation en MANUAL est portee par le Callback ci-dessous).
|
||||||
|
*/
|
||||||
|
#[Assert\Positive(message: 'Le DSD doit être un entier positif.')]
|
||||||
#[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])]
|
#[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])]
|
||||||
public ?string $manualNumber = null;
|
|
||||||
|
|
||||||
/** DSD attribue par le serveur (lecture seule) — previsionnel (cf. docbloc classe). */
|
|
||||||
#[Groups(['weighbridge_reading:read'])]
|
|
||||||
public ?int $dsd = null;
|
public ?int $dsd = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RG metier : en pesee MANUAL, le poids est saisi par l'operateur (le pont
|
* RG metier MANUAL : le pont n'est pas lu, l'operateur saisit le poids ET le DSD
|
||||||
* n'est pas lu) → il est obligatoire. Porte par un Callback pour que le 422
|
* → les deux sont obligatoires. Porte par un Callback pour que chaque 422 cible
|
||||||
* cible le propertyPath `weight` (mapping inline front, ERP-101). En AUTO,
|
* son propertyPath (`weight` / `dsd`) et soit mappee inline (ERP-101). En AUTO,
|
||||||
* le poids fourni par le client est ignore (renseigne par le pont).
|
* poids et DSD sont fournis par le pont (saisie client ignoree).
|
||||||
*/
|
*/
|
||||||
#[Assert\Callback]
|
#[Assert\Callback]
|
||||||
public function validateManualWeight(ExecutionContextInterface $context): void
|
public function validateManualFields(ExecutionContextInterface $context): void
|
||||||
{
|
{
|
||||||
if ('MANUAL' === $this->mode && null === $this->weight) {
|
if ('MANUAL' !== $this->mode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $this->weight) {
|
||||||
$context->buildViolation('Le poids est obligatoire en pesée manuelle.')
|
$context->buildViolation('Le poids est obligatoire en pesée manuelle.')
|
||||||
->atPath('weight')
|
->atPath('weight')
|
||||||
->addViolation()
|
->addViolation()
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (null === $this->dsd) {
|
||||||
|
$context->buildViolation('Le DSD est obligatoire en pesée manuelle.')
|
||||||
|
->atPath('dsd')
|
||||||
|
->addViolation()
|
||||||
|
;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-10
@@ -6,7 +6,6 @@ namespace App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
|
|
||||||
use App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface;
|
use App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface;
|
||||||
use App\Module\Logistique\Domain\Exception\WeighbridgeUnavailableException;
|
use App\Module\Logistique\Domain\Exception\WeighbridgeUnavailableException;
|
||||||
use App\Module\Logistique\Infrastructure\ApiPlatform\Resource\WeighbridgeReadingResource;
|
use App\Module\Logistique\Infrastructure\ApiPlatform\Resource\WeighbridgeReadingResource;
|
||||||
@@ -23,7 +22,9 @@ use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
|
|||||||
* - AUTO : lit le pont (WeighbridgeReaderInterface) → poids + DSD. Si la
|
* - AUTO : lit le pont (WeighbridgeReaderInterface) → poids + DSD. Si la
|
||||||
* bascule est indisponible (WeighbridgeUnavailableException) → HTTP 503
|
* bascule est indisponible (WeighbridgeUnavailableException) → HTTP 503
|
||||||
* « Pont bascule indisponible — passez en pesee manuelle » (RG-5.06).
|
* « Pont bascule indisponible — passez en pesee manuelle » (RG-5.06).
|
||||||
* - MANUAL : conserve le poids saisi et alloue le DSD (dernier + 1, RG-5.04).
|
* - MANUAL : conserve le poids ET le DSD saisis par l'operateur tels quels — plus
|
||||||
|
* d'auto-increment (ERP-193 : le DSD saisi est la valeur du pont reellement
|
||||||
|
* utilisee, on ne la remplace pas).
|
||||||
*
|
*
|
||||||
* @implements ProcessorInterface<WeighbridgeReadingResource, WeighbridgeReadingResource>
|
* @implements ProcessorInterface<WeighbridgeReadingResource, WeighbridgeReadingResource>
|
||||||
*/
|
*/
|
||||||
@@ -32,7 +33,6 @@ final class WeighbridgeReadingProcessor implements ProcessorInterface
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||||
private readonly WeighbridgeReaderInterface $weighbridgeReader,
|
private readonly WeighbridgeReaderInterface $weighbridgeReader,
|
||||||
private readonly DsdAllocatorInterface $dsdAllocator,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): WeighbridgeReadingResource
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): WeighbridgeReadingResource
|
||||||
@@ -65,17 +65,15 @@ final class WeighbridgeReadingProcessor implements ProcessorInterface
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$data->weight = $reading->weight;
|
$data->weight = $reading->weight;
|
||||||
$data->dsd = $reading->dsd;
|
$data->dsd = $reading->dsd;
|
||||||
$data->manualNumber = null; // pas de numero papier en mode bascule
|
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MANUAL : le poids est saisi (validateManualWeight garantit sa presence),
|
// MANUAL : poids ET DSD sont saisis par l'operateur (validateManualFields
|
||||||
// seul le DSD est attribue serveur (dernier DSD du site + 1, RG-5.04).
|
// garantit leur presence) et conserves tels quels — aucun auto-increment
|
||||||
$data->dsd = $this->dsdAllocator->next($site);
|
// (ERP-193). Rien a recalculer cote serveur.
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+75
-22
@@ -67,14 +67,14 @@ final class WeighingTicketProcessor implements ProcessorInterface
|
|||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Une entite non geree par l'ORM = creation (POST) : site + numero ne sont
|
// Une entite non geree par l'ORM = creation (POST). On rattache le site
|
||||||
// attribues qu'a ce moment et restent immuables ensuite (RG-5.09).
|
// courant (cloisonnement + base de la numerotation), immuable ensuite
|
||||||
|
// (RG-5.09). Le NUMERO n'est PLUS attribue ici : un ticket nait « brouillon »
|
||||||
|
// (status DRAFT par defaut) et n'est numerote qu'a la validation (ERP-193).
|
||||||
$isNew = !$this->em->contains($data);
|
$isNew = !$this->em->contains($data);
|
||||||
|
|
||||||
if ($isNew) {
|
if ($isNew) {
|
||||||
$site = $this->resolveCurrentSite();
|
$data->setSite($this->resolveCurrentSite());
|
||||||
$data->setSite($site);
|
|
||||||
$data->setNumber($this->numberAllocator->allocate($site));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->applyCounterpartyExclusivity($data);
|
$this->applyCounterpartyExclusivity($data);
|
||||||
@@ -84,11 +84,23 @@ final class WeighingTicketProcessor implements ProcessorInterface
|
|||||||
// depuis la base. Garde defensive si jamais il manque (ne devrait pas).
|
// depuis la base. Garde defensive si jamais il manque (ne devrait pas).
|
||||||
$site = $data->getSite();
|
$site = $data->getSite();
|
||||||
if ($site instanceof Site) {
|
if ($site instanceof Site) {
|
||||||
$this->allocateAutoDsd($data, $site, $isNew);
|
$this->allocateAutoDsd($data, $site);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->computeNetWeight($data);
|
$this->computeNetWeight($data);
|
||||||
|
|
||||||
|
// Operation `validate` (« Valider », ERP-193) : transition brouillon -> valide.
|
||||||
|
// La validation stricte (groupe finalize : contrepartie + immat + 2 pesees) a
|
||||||
|
// deja joue en amont. On attribue le numero {siteCode}-TP-{NNNN} (compteur
|
||||||
|
// verrouille, RG-5.02 ; uniquement s'il n'existe pas encore, immuable) puis on
|
||||||
|
// passe le statut a VALIDATED.
|
||||||
|
if ('weighing_ticket_validate' === $operation->getName()) {
|
||||||
|
if (null === $data->getNumber() && $site instanceof Site) {
|
||||||
|
$data->setNumber($this->numberAllocator->allocate($site));
|
||||||
|
}
|
||||||
|
$data->setStatus(WeighingTicket::STATUS_VALIDATED);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,36 +121,73 @@ final class WeighingTicketProcessor implements ProcessorInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* RG-5.03 : garantit l'exclusivite de la contrepartie en forcant a null les
|
* RG-5.03 : garantit l'exclusivite de la contrepartie en forcant a null les
|
||||||
* champs hors-branche selon counterpartyType. La PRESENCE du champ requis est
|
* champs hors-branche selon counterpartyType. La PRESENCE du champ requis n'est
|
||||||
* deja validee en amont (Assert\Callback de l'entite) ; ici on evite qu'un
|
* validee qu'a la VALIDATION (Assert\Callback groupe finalize, ERP-193) : un
|
||||||
* payload portant a la fois client_id ET supplier_id ne fasse echouer les CHECK
|
* BROUILLON peut donc arriver ici avec un type choisi mais SANS son champ associe
|
||||||
* Postgres (500 generique au lieu d'une donnee coherente). otherLabel est
|
* (l'operateur a ouvert le menu avant de selectionner). On retire alors la
|
||||||
* normalise (trim) dans la branche AUTRE.
|
* contrepartie entiere (clearCounterparty) au lieu de persister un etat
|
||||||
|
* incoherent qui violerait les CHECK Postgres chk_wt_*_branch (500 generique).
|
||||||
|
* Ne concerne que le brouillon : a la validation, le Callback finalize a deja
|
||||||
|
* leve une 422 AVANT ce Processor. otherLabel est normalise (trim) en branche
|
||||||
|
* AUTRE ; un libelle vide vaut « champ associe absent » -> contrepartie retiree.
|
||||||
*/
|
*/
|
||||||
private function applyCounterpartyExclusivity(WeighingTicket $data): void
|
private function applyCounterpartyExclusivity(WeighingTicket $data): void
|
||||||
{
|
{
|
||||||
switch ($data->getCounterpartyType()) {
|
switch ($data->getCounterpartyType()) {
|
||||||
case 'CLIENT':
|
case 'CLIENT':
|
||||||
|
if (null === $data->getClient()) {
|
||||||
|
$this->clearCounterparty($data);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
$data->setSupplier(null);
|
$data->setSupplier(null);
|
||||||
$data->setOtherLabel(null);
|
$data->setOtherLabel(null);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'FOURNISSEUR':
|
case 'FOURNISSEUR':
|
||||||
|
if (null === $data->getSupplier()) {
|
||||||
|
$this->clearCounterparty($data);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
$data->setClient(null);
|
$data->setClient(null);
|
||||||
$data->setOtherLabel(null);
|
$data->setOtherLabel(null);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'AUTRE':
|
case 'AUTRE':
|
||||||
|
$label = $this->normalizer->normalizeOtherLabel($data->getOtherLabel());
|
||||||
|
if (null === $label) {
|
||||||
|
$this->clearCounterparty($data);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
$data->setClient(null);
|
$data->setClient(null);
|
||||||
$data->setSupplier(null);
|
$data->setSupplier(null);
|
||||||
$data->setOtherLabel($this->normalizer->normalizeOtherLabel($data->getOtherLabel()));
|
$data->setOtherLabel($label);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retire toute la contrepartie d'un brouillon a la selection incomplete (type
|
||||||
|
* sans champ associe) : on ne persiste pas une contrepartie a moitie (qui
|
||||||
|
* violerait chk_wt_*_branch). Le brouillon reste enregistrable sans contrepartie
|
||||||
|
* (ERP-193) ; la coherence est exigee a la validation.
|
||||||
|
*/
|
||||||
|
private function clearCounterparty(WeighingTicket $data): void
|
||||||
|
{
|
||||||
|
$data->setCounterpartyType(null);
|
||||||
|
$data->setClient(null);
|
||||||
|
$data->setSupplier(null);
|
||||||
|
$data->setOtherLabel(null);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RG-5.01 / RG-5.10 : normalisation serveur de l'immatriculation (trim + UPPER
|
* RG-5.01 / RG-5.10 : normalisation serveur de l'immatriculation (trim + UPPER
|
||||||
* + masque XX-000-XX hors « Tout format »). Un format invalide est traduit en
|
* + masque XX-000-XX hors « Tout format »). Un format invalide est traduit en
|
||||||
@@ -162,21 +211,25 @@ final class WeighingTicketProcessor implements ProcessorInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RG-5.04 : (re)attribution AUTORITAIRE du DSD pour chaque pesee AUTO via
|
* RG-5.04 : le DSD d'une pesee est attribue A LA PESEE (POST /api/weighbridge_readings)
|
||||||
* DsdAllocator (verrou FOR UPDATE). A la creation, le DSD prévisionnel envoye
|
* et CONSERVE tel quel sur le ticket — on ne le reattribue PAS au save. Raison :
|
||||||
* par le client (issu de POST /api/weighbridge_readings) est ecrase. Sur PATCH,
|
* le DSD est l'index de pesee du pont, deja verrouille (FOR UPDATE) a l'emission ;
|
||||||
* on n'alloue que pour une pesee AUTO encore depourvue de DSD (ex. la pesee a
|
* demain il proviendra directement du materiel (driver reel derriere
|
||||||
* plein realisee apres coup) — sinon on churne le compteur a chaque edition.
|
* WeighbridgeReaderInterface) et devra etre persiste a l'identique. Reallouer ici
|
||||||
* Les pesees MANUELLES conservent leur DSD (deja alloue par l'endpoint de
|
* ecraserait cet index (double comptage aujourd'hui, perte de l'index reel demain)
|
||||||
* pesee, « dernier + 1 »).
|
* et ferait diverger le DSD previsionnel affiche du DSD enregistre.
|
||||||
|
*
|
||||||
|
* On n'alloue donc qu'en FILET DE SECURITE : pesee AUTO sans DSD (ex. ticket cree
|
||||||
|
* sans passer par l'endpoint de pesee). Les pesees MANUELLES conservent egalement
|
||||||
|
* leur DSD (alloue « dernier + 1 » par l'endpoint de pesee).
|
||||||
*/
|
*/
|
||||||
private function allocateAutoDsd(WeighingTicket $data, Site $site, bool $isNew): void
|
private function allocateAutoDsd(WeighingTicket $data, Site $site): void
|
||||||
{
|
{
|
||||||
if ('AUTO' === $data->getEmptyMode() && ($isNew || null === $data->getEmptyDsd())) {
|
if ('AUTO' === $data->getEmptyMode() && null === $data->getEmptyDsd()) {
|
||||||
$data->setEmptyDsd($this->dsdAllocator->next($site));
|
$data->setEmptyDsd($this->dsdAllocator->next($site));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('AUTO' === $data->getFullMode() && ($isNew || null === $data->getFullDsd())) {
|
if ('AUTO' === $data->getFullMode() && null === $data->getFullDsd()) {
|
||||||
$data->setFullDsd($this->dsdAllocator->next($site));
|
$data->setFullDsd($this->dsdAllocator->next($site));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||||
|
use App\Module\Logistique\Domain\Repository\WeighingTicketRepositoryInterface;
|
||||||
|
use App\Module\Logistique\Infrastructure\Pdf\WeighingTicketPdfRenderer;
|
||||||
|
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||||
|
use App\Module\Sites\Domain\Entity\Site;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider de l'operation `GET /api/weighing_tickets/{id}/print.pdf` : sert le bon
|
||||||
|
* de pesee en PDF (M5, spec-back § 2.12 / § 4.6 — RG-5.08). Operation API Platform
|
||||||
|
* dediee (pas de controller, decision spec § 4.6) ; le binaire est genere par
|
||||||
|
* {@see WeighingTicketPdfRenderer} (template Twig -> Dompdf).
|
||||||
|
*
|
||||||
|
* Le provider retourne directement une {@see Response} : API Platform court-circuite
|
||||||
|
* alors la serialisation Hydra (le SerializeListener/RespondListener detectent une
|
||||||
|
* Response et la renvoient telle quelle). `Content-Type: application/pdf`,
|
||||||
|
* disposition `inline` (le front ouvre l'apercu — RG-5.08).
|
||||||
|
*
|
||||||
|
* Securite & visibilite — miroir de {@see WeighingTicketProvider::provideItem()} :
|
||||||
|
* - permission `logistique.weighing_tickets.view` portee par l'operation (403) ;
|
||||||
|
* - 404 si ticket introuvable, soft-delete (non expose au M5 — § 2.13), ou hors
|
||||||
|
* perimetre du site courant (anti-enumeration, § 2.3 / RG-5.09).
|
||||||
|
*
|
||||||
|
* @implements ProviderInterface<WeighingTicket>
|
||||||
|
*/
|
||||||
|
final class WeighingTicketPrintProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(service: 'App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository')]
|
||||||
|
private readonly WeighingTicketRepositoryInterface $repository,
|
||||||
|
private readonly WeighingTicketPdfRenderer $renderer,
|
||||||
|
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||||
|
{
|
||||||
|
$ticket = $this->findVisibleTicket($uriVariables['id'] ?? null);
|
||||||
|
if (null === $ticket) {
|
||||||
|
throw new NotFoundHttpException('Ticket de pesée introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdf = $this->renderer->render($ticket);
|
||||||
|
|
||||||
|
$response = new Response($pdf);
|
||||||
|
$response->headers->set('Content-Type', 'application/pdf');
|
||||||
|
$response->headers->set(
|
||||||
|
'Content-Disposition',
|
||||||
|
sprintf('inline; filename="bon-pesee-%s.pdf"', $ticket->getNumber() ?? (string) $ticket->getId()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge le ticket visible par l'utilisateur courant, ou null (-> 404) :
|
||||||
|
* introuvable, soft-delete, ou hors perimetre du site courant. Logique
|
||||||
|
* identique a WeighingTicketProvider::provideItem() (cloisonnement § 2.3).
|
||||||
|
*/
|
||||||
|
private function findVisibleTicket(mixed $id): ?WeighingTicket
|
||||||
|
{
|
||||||
|
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticket = $this->repository->findById((int) $id);
|
||||||
|
if (null === $ticket || null !== $ticket->getDeletedAt()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scopeSite = $this->currentScopeSite();
|
||||||
|
if (null !== $scopeSite && $ticket->getSite()?->getId() !== $scopeSite->getId()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site servant a cloisonner, ou null si aucun cloisonnement ne s'applique
|
||||||
|
* (user `sites.bypass_scope`, ou pas de site courant). Miroir de
|
||||||
|
* WeighingTicketProvider::currentScopeSite().
|
||||||
|
*/
|
||||||
|
private function currentScopeSite(): ?Site
|
||||||
|
{
|
||||||
|
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->currentSiteProvider->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-28
@@ -146,8 +146,11 @@ final class WeighingTicketExportController
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'Numéro',
|
'Numéro',
|
||||||
'Type contrepartie',
|
// Contrepartie eclatee en 3 colonnes mutuellement exclusives (miroir de
|
||||||
'Contrepartie',
|
// la liste / repertoire, ERP-193) plutot que « type + nom ».
|
||||||
|
'Fournisseur',
|
||||||
|
'Client',
|
||||||
|
'Autre',
|
||||||
'Date',
|
'Date',
|
||||||
'Immatriculation',
|
'Immatriculation',
|
||||||
'Poids vide (kg)',
|
'Poids vide (kg)',
|
||||||
@@ -155,6 +158,7 @@ final class WeighingTicketExportController
|
|||||||
'Poids net (kg)',
|
'Poids net (kg)',
|
||||||
'DSD vide',
|
'DSD vide',
|
||||||
'DSD plein',
|
'DSD plein',
|
||||||
|
'Statut',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,10 +170,14 @@ final class WeighingTicketExportController
|
|||||||
private function buildRows(array $tickets): iterable
|
private function buildRows(array $tickets): iterable
|
||||||
{
|
{
|
||||||
foreach ($tickets as $ticket) {
|
foreach ($tickets as $ticket) {
|
||||||
|
$type = $ticket->getCounterpartyType();
|
||||||
|
|
||||||
yield [
|
yield [
|
||||||
$ticket->getNumber(),
|
$ticket->getNumber() ?? '',
|
||||||
$this->counterpartyTypeLabel($ticket->getCounterpartyType()),
|
// Une seule des 3 colonnes est renseignee selon le type (RG-5.03).
|
||||||
$this->counterpartyName($ticket),
|
'FOURNISSEUR' === $type ? ($ticket->getSupplier()?->getCompanyName() ?? '') : '',
|
||||||
|
'CLIENT' === $type ? ($ticket->getClient()?->getCompanyName() ?? '') : '',
|
||||||
|
'AUTRE' === $type ? ($ticket->getOtherLabel() ?? '') : '',
|
||||||
$ticket->getDisplayDate()?->format('d/m/Y H:i') ?? '',
|
$ticket->getDisplayDate()?->format('d/m/Y H:i') ?? '',
|
||||||
$ticket->getImmatriculation() ?? '',
|
$ticket->getImmatriculation() ?? '',
|
||||||
$ticket->getEmptyWeight() ?? '',
|
$ticket->getEmptyWeight() ?? '',
|
||||||
@@ -177,36 +185,22 @@ final class WeighingTicketExportController
|
|||||||
$ticket->getNetWeight() ?? '',
|
$ticket->getNetWeight() ?? '',
|
||||||
$ticket->getEmptyDsd() ?? '',
|
$ticket->getEmptyDsd() ?? '',
|
||||||
$ticket->getFullDsd() ?? '',
|
$ticket->getFullDsd() ?? '',
|
||||||
|
$this->statusLabel($ticket->getStatus()),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Libelle FR du type de contrepartie (RG-5.03). Renvoie la valeur brute pour
|
* Libelle FR du statut du cycle de vie (ERP-193) : « En attente » (DRAFT) ou
|
||||||
* une valeur inattendue (garde-fou : ne masque pas une donnee corrompue).
|
* « Terminée » (VALIDATED). Renvoie la valeur brute pour une valeur inattendue
|
||||||
|
* (garde-fou : ne masque pas une donnee corrompue).
|
||||||
*/
|
*/
|
||||||
private function counterpartyTypeLabel(?string $type): string
|
private function statusLabel(string $status): string
|
||||||
{
|
{
|
||||||
return match ($type) {
|
return match ($status) {
|
||||||
'CLIENT' => 'Client',
|
WeighingTicket::STATUS_DRAFT => 'En attente',
|
||||||
'FOURNISSEUR' => 'Fournisseur',
|
WeighingTicket::STATUS_VALIDATED => 'Terminée',
|
||||||
'AUTRE' => 'Autre',
|
default => $status,
|
||||||
default => $type ?? '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Nom de la contrepartie selon le type (RG-5.03) : raison sociale du client,
|
|
||||||
* du fournisseur, ou libelle libre « Autre ». Client / Supplier sont
|
|
||||||
* fetch-joines par le repository (anti N+1, § 4.0).
|
|
||||||
*/
|
|
||||||
private function counterpartyName(WeighingTicket $ticket): string
|
|
||||||
{
|
|
||||||
return match ($ticket->getCounterpartyType()) {
|
|
||||||
'CLIENT' => $ticket->getClient()?->getCompanyName() ?? '',
|
|
||||||
'FOURNISSEUR' => $ticket->getSupplier()?->getCompanyName() ?? '',
|
|
||||||
'AUTRE' => $ticket->getOtherLabel() ?? '',
|
|
||||||
default => '',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Logistique\Infrastructure\Pdf;
|
||||||
|
|
||||||
|
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||||
|
use Dompdf\Dompdf;
|
||||||
|
use Dompdf\Options;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rend le ticket de pesee (M5, spec-back § 2.12 / § 4.6 — RG-5.08) : hydrate le
|
||||||
|
* template Twig `logistique/weighing_ticket_print.html.twig` avec le ticket, puis
|
||||||
|
* convertit le HTML en PDF via Dompdf (pur PHP, aucune dependance systeme — choix
|
||||||
|
* valide avec Matthieu, ERP-192).
|
||||||
|
*
|
||||||
|
* Le gabarit reproduit le modele fourni (ticket_pesee.pdf) : en-tete FIXE (logo +
|
||||||
|
* identite societe), titre, les deux pesees (poids / N° pesee / DSD + date) et le
|
||||||
|
* poids net. Le rendu ne depend PAS du site (decision Tristan, ERP-192) : le logo
|
||||||
|
* et l'identite societe sont constants.
|
||||||
|
*
|
||||||
|
* Service technique d'infrastructure (pas de logique metier) : le contenu/affiche
|
||||||
|
* est decide par le template ; ICI on ne fait que charger le logo et generer le
|
||||||
|
* binaire.
|
||||||
|
*/
|
||||||
|
final class WeighingTicketPdfRenderer
|
||||||
|
{
|
||||||
|
/** Logo societe embarque dans l'en-tete (fixe, hors versioning par site). */
|
||||||
|
private const string LOGO_PATH = __DIR__.'/assets/logo-lpc-liot.png';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Environment $twig,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genere le binaire PDF du ticket de pesee pour un ticket donne.
|
||||||
|
*
|
||||||
|
* Dompdf : remote desactive (aucune ressource externe chargee — securite ; le
|
||||||
|
* logo passe en data-URI), A4 portrait, police par defaut DejaVu Sans (UTF-8
|
||||||
|
* -> accents FR et « ° » corrects).
|
||||||
|
*/
|
||||||
|
public function render(WeighingTicket $ticket): string
|
||||||
|
{
|
||||||
|
$html = $this->twig->render('logistique/weighing_ticket_print.html.twig', [
|
||||||
|
'ticket' => $ticket,
|
||||||
|
'logoSrc' => $this->logoDataUri(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$options = new Options();
|
||||||
|
$options->set('isRemoteEnabled', false);
|
||||||
|
$options->set('defaultFont', 'DejaVu Sans');
|
||||||
|
|
||||||
|
$dompdf = new Dompdf($options);
|
||||||
|
$dompdf->loadHtml($html, 'UTF-8');
|
||||||
|
$dompdf->setPaper('A4', 'portrait');
|
||||||
|
$dompdf->render();
|
||||||
|
|
||||||
|
return (string) $dompdf->output();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logo societe encode en data-URI base64, ou null s'il est introuvable (le
|
||||||
|
* template degrade alors sans bloquer la generation du PDF).
|
||||||
|
*/
|
||||||
|
private function logoDataUri(): ?string
|
||||||
|
{
|
||||||
|
$binary = @file_get_contents(self::LOGO_PATH);
|
||||||
|
if (false === $binary) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'data:image/png;base64,'.base64_encode($binary);
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
@@ -553,28 +553,27 @@ final class ColumnCommentsCatalog
|
|||||||
// -> app:apply-column-comments les rejoue depuis ce catalogue. Strings
|
// -> app:apply-column-comments les rejoue depuis ce catalogue. Strings
|
||||||
// identiques aux COMMENT de la migration Version20260617150000.
|
// identiques aux COMMENT de la migration Version20260617150000.
|
||||||
'weighing_ticket' => [
|
'weighing_ticket' => [
|
||||||
'_table' => 'Tickets de pesee (M5 Logistique) — pesee a vide + a plein au pont bascule, contrepartie Client/Fournisseur/Autre. Cloisonne par site courant.',
|
'_table' => 'Tickets de pesee (M5 Logistique) — pesee a vide + a plein au pont bascule, contrepartie Client/Fournisseur/Autre. Cloisonne par site courant.',
|
||||||
'id' => 'Identifiant interne auto-incremente.',
|
'id' => 'Identifiant interne auto-incremente.',
|
||||||
'site_id' => 'Site du pont bascule (cloisonnement § 2.3). FK -> site.id, ON DELETE RESTRICT. Renseigne serveur depuis le site courant, immuable (RG-5.09).',
|
'site_id' => 'Site du pont bascule (cloisonnement § 2.3). FK -> site.id, ON DELETE RESTRICT. Renseigne serveur depuis le site courant, immuable (RG-5.09).',
|
||||||
'number' => 'Numero {siteCode}-TP-{NNNN}, unique par site (uq_weighing_ticket_number), immuable. Sequence weighing_ticket_counter (RG-5.02).',
|
'number' => 'Numero {siteCode}-TP-{NNNN}, unique par site (uq_weighing_ticket_number), immuable. NULL tant que brouillon : attribue a la validation (RG-5.02, ERP-193).',
|
||||||
'counterparty_type' => 'Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (chk_wt_counterparty_type, RG-5.03). Pilote l obligation client_id / supplier_id / other_label.',
|
'counterparty_type' => 'Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (chk_wt_counterparty_type, RG-5.03). NULL tant que brouillon, requise a la validation. Pilote l obligation client_id / supplier_id / other_label.',
|
||||||
'client_id' => 'Branche CLIENT (RG-5.03) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi counterparty_type = CLIENT, nul sinon (chk_wt_client_branch).',
|
'client_id' => 'Branche CLIENT (RG-5.03) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi counterparty_type = CLIENT, nul sinon (chk_wt_client_branch).',
|
||||||
'supplier_id' => 'Branche FOURNISSEUR (RG-5.03) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi counterparty_type = FOURNISSEUR (chk_wt_supplier_branch).',
|
'supplier_id' => 'Branche FOURNISSEUR (RG-5.03) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi counterparty_type = FOURNISSEUR (chk_wt_supplier_branch).',
|
||||||
'other_label' => 'Branche AUTRE (RG-5.03) : libelle libre de la contrepartie. Requis ssi counterparty_type = AUTRE, nul sinon (chk_wt_other_branch).',
|
'other_label' => 'Branche AUTRE (RG-5.03) : libelle libre de la contrepartie. Requis ssi counterparty_type = AUTRE, nul sinon (chk_wt_other_branch).',
|
||||||
'immatriculation' => 'Plaque du vehicule, partagee entre pesee vide et plein. Masque XX-000-XX sauf si plate_free_format (RG-5.01). Normalisee serveur (trim/UPPER).',
|
'immatriculation' => 'Plaque du vehicule, partagee entre pesee vide et plein. NULL tant que brouillon, requise a la validation. Masque XX-000-XX sauf si plate_free_format (RG-5.01). Normalisee serveur (trim/UPPER).',
|
||||||
'plate_free_format' => '« Tout format » : desactive le masque XX-000-XX de l immatriculation (RG-5.01). Partage entre les 2 formulaires. Faux par defaut.',
|
'plate_free_format' => '« Tout format » : desactive le masque XX-000-XX de l immatriculation (RG-5.01). Partage entre les 2 formulaires. Faux par defaut.',
|
||||||
'empty_date' => 'Date/heure de la pesee a vide (tare). Defaut jour courant cote front (RG-5.07). Null tant que la pesee vide n est pas faite.',
|
'empty_date' => 'Date/heure de la pesee a vide (tare). Defaut jour courant cote front (RG-5.07). Null tant que la pesee vide n est pas faite.',
|
||||||
'empty_weight' => 'Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07).',
|
'empty_weight' => 'Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07).',
|
||||||
'empty_dsd' => 'Compteur DSD du pont a la pesee a vide. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).',
|
'empty_dsd' => 'Compteur DSD du pont a la pesee a vide. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).',
|
||||||
'empty_mode' => 'Mode de la pesee a vide : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_empty_mode (RG-5.06).',
|
'empty_mode' => 'Mode de la pesee a vide : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_empty_mode (RG-5.06).',
|
||||||
'empty_manual_number' => 'Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a vide (RG-5.04).',
|
'full_date' => 'Date/heure de la pesee a plein (brut). Null tant que la pesee plein n est pas faite.',
|
||||||
'full_date' => 'Date/heure de la pesee a plein (brut). Null tant que la pesee plein n est pas faite.',
|
'full_weight' => 'Poids a plein (brut) en kg — readonly UI, rempli par la pesee (RG-5.07).',
|
||||||
'full_weight' => 'Poids a plein (brut) en kg — readonly UI, rempli par la pesee (RG-5.07).',
|
'full_dsd' => 'Compteur DSD du pont a la pesee a plein. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).',
|
||||||
'full_dsd' => 'Compteur DSD du pont a la pesee a plein. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).',
|
'full_mode' => 'Mode de la pesee a plein : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_full_mode (RG-5.06).',
|
||||||
'full_mode' => 'Mode de la pesee a plein : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_full_mode (RG-5.06).',
|
'net_weight' => 'Poids net = full_weight - empty_weight (kg), calcule serveur (RG-5.05). Null si une pesee manque. Colonne Poids de la liste.',
|
||||||
'full_manual_number' => 'Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a plein (RG-5.04).',
|
'status' => 'Cycle de vie (ERP-193) : DRAFT (« En attente », pesee enregistree sans contrepartie/immat) ou VALIDATED (« Terminée », valide avec numero). chk_wt_status. Defaut DRAFT.',
|
||||||
'net_weight' => 'Poids net = full_weight - empty_weight (kg), calcule serveur (RG-5.05). Null si une pesee manque. Colonne Poids de la liste.',
|
'deleted_at' => 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.',
|
||||||
'deleted_at' => 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.',
|
|
||||||
] + self::timestampableBlamableComments(),
|
] + self::timestampableBlamableComments(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
{#
|
||||||
|
Ticket de pesée (M5 Logistique) — gabarit imprimable hydraté côté serveur puis
|
||||||
|
converti en PDF par WeighingTicketPdfRenderer (Dompdf). Cf. spec-back M5 § 2.12
|
||||||
|
/ § 4.6 (RG-5.08). Reproduit fidèlement le modèle fourni (ticket_pesee.pdf).
|
||||||
|
|
||||||
|
En-tête FIXE (logo + identité société) : le ticket ne change pas en fonction du
|
||||||
|
site (décision Tristan, ERP-192). Le logo est injecté en data-URI par le renderer
|
||||||
|
(logoSrc) ; l'identité société est en dur ci-dessous.
|
||||||
|
|
||||||
|
Contraintes Dompdf : CSS2.1 (pas de flexbox/grid), mise en page par tableaux.
|
||||||
|
Police DejaVu Sans (UTF-8 — accents FR et « ° » rendus correctement).
|
||||||
|
#}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
@page { margin: 18mm 16mm; }
|
||||||
|
|
||||||
|
* { font-family: "DejaVu Sans", sans-serif; }
|
||||||
|
|
||||||
|
body { color: #000; font-size: 10px; margin: 0; }
|
||||||
|
|
||||||
|
.logo { margin-bottom: 16px; }
|
||||||
|
.logo img { height: 100px; }
|
||||||
|
|
||||||
|
.company-name { font-weight: bold; font-size: 12px; }
|
||||||
|
.company-line { font-size: 12px; }
|
||||||
|
|
||||||
|
.title { font-size: 22px; font-weight: bold; margin: 22px 0 18px; }
|
||||||
|
|
||||||
|
/* Lignes des deux pesées : tableau sans bordure, colonnes alignées. */
|
||||||
|
.weighings { border-collapse: collapse; font-size: 12px; }
|
||||||
|
.weighings td { vertical-align: top; white-space: nowrap; }
|
||||||
|
.weighings .c-label { width: 130px; }
|
||||||
|
.weighings .c-weight { width: 95px; }
|
||||||
|
.weighings .c-num { width: 175px; }
|
||||||
|
.weighings .c-dsd { width: auto; }
|
||||||
|
|
||||||
|
.net { font-size: 18px; font-weight: bold; margin-top: 26px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% 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>
|
||||||
|
|
||||||
|
<div class="title">Ticket de pesée</div>
|
||||||
|
|
||||||
|
{#
|
||||||
|
DSD de la pesée : valeur du pont en AUTO, valeur saisie par l'opérateur en
|
||||||
|
MANUAL (ERP-193). Un seul champ `dsd` dans les deux cas.
|
||||||
|
#}
|
||||||
|
{% set emptyRef = ticket.emptyDsd %}
|
||||||
|
{% set fullRef = ticket.fullDsd %}
|
||||||
|
|
||||||
|
<table class="weighings">
|
||||||
|
<tr>
|
||||||
|
<td class="c-label">Poids à vide</td>
|
||||||
|
<td class="c-weight">{{ ticket.emptyWeight is not null ? ticket.emptyWeight ~ ' kg' : '' }}</td>
|
||||||
|
<td class="c-num">N° pesée à vide</td>
|
||||||
|
<td class="c-dsd">{% if emptyRef is not null %}DSD : {{ emptyRef }}{% endif %}{% if ticket.emptyDate %} {{ ticket.emptyDate|date('d/m/Y H:i:s') }}{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="c-label">Poids à plein</td>
|
||||||
|
<td class="c-weight">{{ ticket.fullWeight is not null ? ticket.fullWeight ~ ' kg' : '' }}</td>
|
||||||
|
<td class="c-num">N° pesée à plein</td>
|
||||||
|
<td class="c-dsd">{% if fullRef is not null %}DSD : {{ fullRef }}{% endif %}{% if ticket.fullDate %} {{ ticket.fullDate|date('d/m/Y H:i:s') }}{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="net">Poids : {{ ticket.netWeight is not null ? ticket.netWeight ~ ' kg' : '—' }}</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -220,7 +220,8 @@ abstract class AbstractWeighingTicketApiTestCase extends AbstractApiTestCase
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST un ticket et renvoie la reponse (assertions de statut a la charge de
|
* POST un ticket et renvoie la reponse (assertions de statut a la charge de
|
||||||
* l'appelant).
|
* l'appelant). Cree un BROUILLON (status DRAFT, sans numero, ERP-193) — la
|
||||||
|
* validation est portee par validateTicket().
|
||||||
*/
|
*/
|
||||||
protected function postTicket(Client $http, array $payload): ResponseInterface
|
protected function postTicket(Client $http, array $payload): ResponseInterface
|
||||||
{
|
{
|
||||||
@@ -230,6 +231,32 @@ abstract class AbstractWeighingTicketApiTestCase extends AbstractApiTestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* « Valider » un ticket : PATCH /weighing_tickets/{id}/validate (ERP-193).
|
||||||
|
* Declenche la validation stricte (groupe finalize) + attribution du numero +
|
||||||
|
* passage en VALIDATED. Body vide par defaut = on valide l'etat deja persiste.
|
||||||
|
*/
|
||||||
|
protected function validateTicket(Client $http, int $id, array $payload = []): ResponseInterface
|
||||||
|
{
|
||||||
|
return $http->request('PATCH', '/api/weighing_tickets/'.$id.'/validate', [
|
||||||
|
'headers' => ['Content-Type' => self::MERGE],
|
||||||
|
'json' => [] === $payload ? new \stdClass() : $payload,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST un brouillon complet puis le valide ; renvoie le ticket VALIDE (numero
|
||||||
|
* attribue). Le payload doit porter contrepartie + immatriculation + 2 pesees.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function createValidatedTicket(Client $http, array $payload): array
|
||||||
|
{
|
||||||
|
$id = (int) $this->postTicket($http, $payload)->toArray()['id'];
|
||||||
|
|
||||||
|
return $this->validateTicket($http, $id)->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrouve un membre d'une collection Hydra par son id.
|
* Retrouve un membre d'une collection Hydra par son id.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -56,18 +56,16 @@ final class WeighbridgeReadingApiTest extends AbstractApiTestCase
|
|||||||
self::assertLessThanOrEqual(50000, $data['weight']);
|
self::assertLessThanOrEqual(50000, $data['weight']);
|
||||||
self::assertIsInt($data['dsd']);
|
self::assertIsInt($data['dsd']);
|
||||||
self::assertGreaterThanOrEqual(1, $data['dsd']);
|
self::assertGreaterThanOrEqual(1, $data['dsd']);
|
||||||
// manualNumber est null en mode bascule (cle potentiellement omise si
|
|
||||||
// skip_null_values est actif — tolerant aux deux cas).
|
|
||||||
self::assertNull($data['manualNumber'] ?? null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testManualWeighingKeepsWeightAndAllocatesDsd(): void
|
public function testManualWeighingKeepsWeightAndEnteredDsd(): void
|
||||||
{
|
{
|
||||||
$client = $this->manageClientWithCurrentSite();
|
$client = $this->manageClientWithCurrentSite();
|
||||||
|
|
||||||
$response = $client->request('POST', '/api/weighbridge_readings', [
|
$response = $client->request('POST', '/api/weighbridge_readings', [
|
||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => ['mode' => 'MANUAL', 'weight' => 23187, 'manualNumber' => 'PAP-555'],
|
// Le DSD est SAISI par l'operateur et conserve tel quel (ERP-193).
|
||||||
|
'json' => ['mode' => 'MANUAL', 'weight' => 23187, 'dsd' => 16619],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(200);
|
self::assertResponseStatusCodeSame(200);
|
||||||
@@ -75,8 +73,7 @@ final class WeighbridgeReadingApiTest extends AbstractApiTestCase
|
|||||||
|
|
||||||
self::assertSame('MANUAL', $data['mode']);
|
self::assertSame('MANUAL', $data['mode']);
|
||||||
self::assertSame(23187, $data['weight']);
|
self::assertSame(23187, $data['weight']);
|
||||||
self::assertSame('PAP-555', $data['manualNumber']);
|
self::assertSame(16619, $data['dsd'], 'Le DSD saisi est conserve, pas d\'auto-increment.');
|
||||||
self::assertGreaterThanOrEqual(1, $data['dsd']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testManagePermissionIsRequired(): void
|
public function testManagePermissionIsRequired(): void
|
||||||
@@ -117,11 +114,25 @@ final class WeighbridgeReadingApiTest extends AbstractApiTestCase
|
|||||||
'json' => ['mode' => 'MANUAL'],
|
'json' => ['mode' => 'MANUAL'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Garde-fou ERP-101 : la 422 doit cibler `weight` (Callback validateManualWeight).
|
// Garde-fou ERP-101 : la 422 doit cibler `weight` (Callback validateManualFields).
|
||||||
self::assertResponseStatusCodeSame(422);
|
self::assertResponseStatusCodeSame(422);
|
||||||
self::assertViolationOnPath($response, 'weight');
|
self::assertViolationOnPath($response, 'weight');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testManualWeighingRequiresDsd(): void
|
||||||
|
{
|
||||||
|
$client = $this->manageClientWithCurrentSite();
|
||||||
|
|
||||||
|
$response = $client->request('POST', '/api/weighbridge_readings', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => ['mode' => 'MANUAL', 'weight' => 23187],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// En manuel, le DSD est saisi → obligatoire (Callback validateManualFields).
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
self::assertViolationOnPath($response, 'dsd');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Garde-fou ERP-101 (miroir AbstractWeighingTicketApiTestCase) : une 422 doit
|
* Garde-fou ERP-101 (miroir AbstractWeighingTicketApiTestCase) : une 422 doit
|
||||||
* porter une violation sur le `propertyPath` attendu, consommable inline par
|
* porter une violation sur le `propertyPath` attendu, consommable inline par
|
||||||
|
|||||||
@@ -72,8 +72,10 @@ final class WeighingTicketExportControllerTest extends AbstractApiTestCase
|
|||||||
// 1re ligne = en-tetes attendus (ordre des colonnes § 4.5).
|
// 1re ligne = en-tetes attendus (ordre des colonnes § 4.5).
|
||||||
$header = $this->gridFromResponse($response->getContent())[0];
|
$header = $this->gridFromResponse($response->getContent())[0];
|
||||||
self::assertSame('Numéro', $header[0]);
|
self::assertSame('Numéro', $header[0]);
|
||||||
self::assertContains('Type contrepartie', $header);
|
// Contrepartie eclatee en 3 colonnes (miroir liste, ERP-193).
|
||||||
self::assertContains('Contrepartie', $header);
|
self::assertContains('Fournisseur', $header);
|
||||||
|
self::assertContains('Client', $header);
|
||||||
|
self::assertContains('Autre', $header);
|
||||||
self::assertContains('Date', $header);
|
self::assertContains('Date', $header);
|
||||||
self::assertContains('Immatriculation', $header);
|
self::assertContains('Immatriculation', $header);
|
||||||
self::assertContains('Poids vide (kg)', $header);
|
self::assertContains('Poids vide (kg)', $header);
|
||||||
@@ -81,6 +83,7 @@ final class WeighingTicketExportControllerTest extends AbstractApiTestCase
|
|||||||
self::assertContains('Poids net (kg)', $header);
|
self::assertContains('Poids net (kg)', $header);
|
||||||
self::assertContains('DSD vide', $header);
|
self::assertContains('DSD vide', $header);
|
||||||
self::assertContains('DSD plein', $header);
|
self::assertContains('DSD plein', $header);
|
||||||
|
self::assertContains('Statut', $header);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,8 +102,11 @@ final class WeighingTicketExportControllerTest extends AbstractApiTestCase
|
|||||||
|
|
||||||
$cell = static fn (string $label) => $row[array_search($label, $header, true)] ?? null;
|
$cell = static fn (string $label) => $row[array_search($label, $header, true)] ?? null;
|
||||||
|
|
||||||
self::assertSame('Client', $cell('Type contrepartie'));
|
// Contrepartie Client → colonne « Client » renseignée, « Fournisseur » / « Autre » vides.
|
||||||
self::assertStringContainsString('BÉTON SA', (string) $cell('Contrepartie'));
|
self::assertStringContainsString('BÉTON SA', (string) $cell('Client'));
|
||||||
|
self::assertSame('', (string) $cell('Fournisseur'));
|
||||||
|
self::assertSame('', (string) $cell('Autre'));
|
||||||
|
self::assertSame('Terminée', $cell('Statut'));
|
||||||
self::assertSame('AB-123-CD', $cell('Immatriculation'));
|
self::assertSame('AB-123-CD', $cell('Immatriculation'));
|
||||||
self::assertSame(7150, (int) $cell('Poids vide (kg)'));
|
self::assertSame(7150, (int) $cell('Poids vide (kg)'));
|
||||||
self::assertSame(14300, (int) $cell('Poids plein (kg)'));
|
self::assertSame(14300, (int) $cell('Poids plein (kg)'));
|
||||||
@@ -184,6 +190,7 @@ final class WeighingTicketExportControllerTest extends AbstractApiTestCase
|
|||||||
$ticket->setFullDsd(42);
|
$ticket->setFullDsd(42);
|
||||||
$ticket->setFullMode('AUTO');
|
$ticket->setFullMode('AUTO');
|
||||||
$ticket->setNetWeight(7150);
|
$ticket->setNetWeight(7150);
|
||||||
|
$ticket->setStatus(WeighingTicket::STATUS_VALIDATED);
|
||||||
|
|
||||||
$em->persist($ticket);
|
$em->persist($ticket);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Logistique\Api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cycle de vie brouillon -> valide du ticket de pesee (ERP-193, spec-back § 2.14).
|
||||||
|
*
|
||||||
|
* Couvre :
|
||||||
|
* - une pesee peut etre enregistree SANS contrepartie ni immatriculation : le POST
|
||||||
|
* cree un BROUILLON (status DRAFT, pas de numero) ;
|
||||||
|
* - la validation (PATCH /validate) exige les 3 champs du haut (type + champ
|
||||||
|
* contrepartie + immatriculation) ET les 2 pesees (groupe `finalize`) ;
|
||||||
|
* - une validation complete attribue le numero {siteCode}-TP-{NNNN} et passe le
|
||||||
|
* ticket en VALIDATED.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class WeighingTicketLifecycleTest extends AbstractWeighingTicketApiTestCase
|
||||||
|
{
|
||||||
|
public function testWeighingOnlyCreatesDraftWithoutNumber(): void
|
||||||
|
{
|
||||||
|
$http = $this->authManageOnSite($this->siteByCode('86'));
|
||||||
|
|
||||||
|
// Pesee a vide seule : ni contrepartie, ni immatriculation.
|
||||||
|
$body = $this->postTicket($http, [
|
||||||
|
'emptyDate' => '2026-06-17T09:00:00+02:00',
|
||||||
|
'emptyWeight' => 7150,
|
||||||
|
'emptyMode' => 'AUTO',
|
||||||
|
])->toArray();
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
self::assertSame('DRAFT', $body['status']);
|
||||||
|
self::assertArrayNotHasKey('number', $body, 'Un brouillon n\'a pas encore de numero (skip_null_values).');
|
||||||
|
self::assertSame(7150, $body['emptyWeight']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDraftWithIncompleteCounterpartyIsPersistedWithoutBranch(): void
|
||||||
|
{
|
||||||
|
$http = $this->authManageOnSite($this->siteByCode('86'));
|
||||||
|
|
||||||
|
// Brouillon « contrepartie incomplete » : type CLIENT choisi mais client pas
|
||||||
|
// encore selectionne (cas reel : l'operateur ouvre le menu puis pese). Le
|
||||||
|
// Callback de coherence ne joue qu'a la validation (groupe finalize) ->
|
||||||
|
// SANS normalisation cote Processor, le persist violerait chk_wt_client_branch
|
||||||
|
// (counterparty_type='CLIENT' + client_id NULL) et leverait une 500.
|
||||||
|
$body = $this->postTicket($http, [
|
||||||
|
'counterpartyType' => 'CLIENT',
|
||||||
|
'emptyDate' => '2026-06-17T09:00:00+02:00',
|
||||||
|
'emptyWeight' => 7150,
|
||||||
|
'emptyMode' => 'AUTO',
|
||||||
|
])->toArray();
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
self::assertSame('DRAFT', $body['status']);
|
||||||
|
// La contrepartie incoherente est retiree (pas persistee a moitie) : le
|
||||||
|
// brouillon reste enregistrable, la coherence est exigee a la validation.
|
||||||
|
self::assertNull($body['counterpartyType'] ?? null);
|
||||||
|
self::assertSame(7150, $body['emptyWeight']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDraftWithEmptyOtherLabelIsPersistedWithoutBranch(): void
|
||||||
|
{
|
||||||
|
$http = $this->authManageOnSite($this->siteByCode('86'));
|
||||||
|
|
||||||
|
// Meme piege en branche AUTRE : type AUTRE mais libelle vide -> le normalizer
|
||||||
|
// ramene otherLabel a NULL, ce qui violait chk_wt_other_branch (500).
|
||||||
|
$body = $this->postTicket($http, [
|
||||||
|
'counterpartyType' => 'AUTRE',
|
||||||
|
'otherLabel' => ' ',
|
||||||
|
'emptyDate' => '2026-06-17T09:00:00+02:00',
|
||||||
|
'emptyWeight' => 7150,
|
||||||
|
'emptyMode' => 'AUTO',
|
||||||
|
])->toArray();
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
self::assertSame('DRAFT', $body['status']);
|
||||||
|
self::assertNull($body['counterpartyType'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateRequiresCounterparty(): void
|
||||||
|
{
|
||||||
|
$http = $this->authManageOnSite($this->siteByCode('86'));
|
||||||
|
|
||||||
|
// Brouillon complet cote pesees + immatriculation, mais SANS contrepartie.
|
||||||
|
$id = (int) $this->postTicket($http, [
|
||||||
|
'immatriculation' => 'AB-123-CD',
|
||||||
|
'emptyDate' => '2026-06-17T09:00:00+02:00',
|
||||||
|
'emptyWeight' => 7150,
|
||||||
|
'emptyMode' => 'AUTO',
|
||||||
|
'fullDate' => '2026-06-17T09:12:00+02:00',
|
||||||
|
'fullWeight' => 14300,
|
||||||
|
'fullMode' => 'AUTO',
|
||||||
|
])->toArray()['id'];
|
||||||
|
|
||||||
|
$response = $this->validateTicket($http, $id);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
self::assertViolationOnPath($response, 'counterpartyType');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateRequiresBothWeighings(): void
|
||||||
|
{
|
||||||
|
$http = $this->authManageOnSite($this->siteByCode('86'));
|
||||||
|
$client = $this->seedTestClient('Lifecycle');
|
||||||
|
|
||||||
|
// Brouillon avec contrepartie + immat + UNE seule pesee (a vide).
|
||||||
|
$id = (int) $this->postTicket($http, [
|
||||||
|
'counterpartyType' => 'CLIENT',
|
||||||
|
'client' => $this->clientIri($client),
|
||||||
|
'immatriculation' => 'AB-123-CD',
|
||||||
|
'emptyDate' => '2026-06-17T09:00:00+02:00',
|
||||||
|
'emptyWeight' => 7150,
|
||||||
|
'emptyMode' => 'AUTO',
|
||||||
|
])->toArray()['id'];
|
||||||
|
|
||||||
|
$response = $this->validateTicket($http, $id);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
self::assertViolationOnPath($response, 'fullWeight');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateAssignsNumberAndStatus(): void
|
||||||
|
{
|
||||||
|
$http = $this->authManageOnSite($this->siteByCode('86'));
|
||||||
|
$client = $this->seedTestClient('LifecycleOk');
|
||||||
|
|
||||||
|
$validated = $this->createValidatedTicket($http, $this->validClientTicketPayload($client));
|
||||||
|
|
||||||
|
self::assertSame('VALIDATED', $validated['status']);
|
||||||
|
self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', (string) $validated['number']);
|
||||||
|
self::assertSame(7150, $validated['netWeight']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,13 +26,12 @@ final class WeighingTicketNumberingTest extends AbstractWeighingTicketApiTestCas
|
|||||||
$http = $this->authManageOnSite($site);
|
$http = $this->authManageOnSite($site);
|
||||||
$client = $this->seedTestClient('Num');
|
$client = $this->seedTestClient('Num');
|
||||||
|
|
||||||
$first = $this->postTicket($http, $this->validClientTicketPayload($client));
|
// Le numero est attribue a la VALIDATION (brouillon -> valide, ERP-193).
|
||||||
self::assertResponseStatusCodeSame(201);
|
$first = $this->createValidatedTicket($http, $this->validClientTicketPayload($client));
|
||||||
$second = $this->postTicket($http, $this->validClientTicketPayload($client));
|
$second = $this->createValidatedTicket($http, $this->validClientTicketPayload($client));
|
||||||
self::assertResponseStatusCodeSame(201);
|
|
||||||
|
|
||||||
$n1 = (string) $first->toArray()['number'];
|
$n1 = (string) $first['number'];
|
||||||
$n2 = (string) $second->toArray()['number'];
|
$n2 = (string) $second['number'];
|
||||||
|
|
||||||
self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n1);
|
self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n1);
|
||||||
self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n2);
|
self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n2);
|
||||||
@@ -49,8 +48,8 @@ final class WeighingTicketNumberingTest extends AbstractWeighingTicketApiTestCas
|
|||||||
$http86 = $this->authManageOnSite($this->siteByCode('86'));
|
$http86 = $this->authManageOnSite($this->siteByCode('86'));
|
||||||
$http17 = $this->authManageOnSite($this->siteByCode('17'));
|
$http17 = $this->authManageOnSite($this->siteByCode('17'));
|
||||||
|
|
||||||
$n86 = (string) $this->postTicket($http86, $this->validClientTicketPayload($client))->toArray()['number'];
|
$n86 = (string) $this->createValidatedTicket($http86, $this->validClientTicketPayload($client))['number'];
|
||||||
$n17 = (string) $this->postTicket($http17, $this->validClientTicketPayload($client))->toArray()['number'];
|
$n17 = (string) $this->createValidatedTicket($http17, $this->validClientTicketPayload($client))['number'];
|
||||||
|
|
||||||
// Chaque site encode son propre code dans le numero ; sequences disjointes.
|
// Chaque site encode son propre code dans le numero ; sequences disjointes.
|
||||||
self::assertStringStartsWith('86-TP-', $n86);
|
self::assertStringStartsWith('86-TP-', $n86);
|
||||||
@@ -63,7 +62,8 @@ final class WeighingTicketNumberingTest extends AbstractWeighingTicketApiTestCas
|
|||||||
$http = $this->authManageOnSite($site);
|
$http = $this->authManageOnSite($site);
|
||||||
$client = $this->seedTestClient('Immutable');
|
$client = $this->seedTestClient('Immutable');
|
||||||
|
|
||||||
$created = $this->postTicket($http, $this->validClientTicketPayload($client))->toArray();
|
// Ticket valide (numero attribue) puis tentative de re-ecriture.
|
||||||
|
$created = $this->createValidatedTicket($http, $this->validClientTicketPayload($client));
|
||||||
$id = (int) $created['id'];
|
$id = (int) $created['id'];
|
||||||
$number = (string) $created['number'];
|
$number = (string) $created['number'];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Logistique\Api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests fonctionnels de l'impression du bon de pesee PDF (M5, spec-back § 2.12 /
|
||||||
|
* § 4.6 — RG-5.08, ERP-192) : operation `GET /api/weighing_tickets/{id}/print.pdf`.
|
||||||
|
*
|
||||||
|
* Couvre la verification du ticket :
|
||||||
|
* - 200 + PDF non vide (Content-Type application/pdf, disposition inline,
|
||||||
|
* signature %PDF) pour un ticket existant et visible ;
|
||||||
|
* - 403 sans la permission `logistique.weighing_tickets.view` ;
|
||||||
|
* - 404 pour un ticket inexistant.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class WeighingTicketPrintApiTest extends AbstractWeighingTicketApiTestCase
|
||||||
|
{
|
||||||
|
public function testPrintReturnsNonEmptyPdfForExistingTicket(): void
|
||||||
|
{
|
||||||
|
$site = $this->firstSite();
|
||||||
|
$http = $this->authManageOnSite($site);
|
||||||
|
$client = $this->seedTestClient('Print');
|
||||||
|
|
||||||
|
$created = $this->postTicket($http, $this->validClientTicketPayload($client));
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
$ticketId = $created->toArray()['id'];
|
||||||
|
|
||||||
|
$response = $http->request('GET', sprintf('/api/weighing_tickets/%d/print.pdf', $ticketId));
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$headers = $response->getHeaders(false);
|
||||||
|
self::assertStringContainsString('application/pdf', $headers['content-type'][0] ?? '');
|
||||||
|
self::assertStringContainsString('inline', $headers['content-disposition'][0] ?? '');
|
||||||
|
|
||||||
|
// PDF non vide + signature de fichier PDF (« %PDF-1.x »).
|
||||||
|
$binary = $response->getContent(false);
|
||||||
|
self::assertNotSame('', $binary, 'Le PDF du bon de pesée ne doit pas être vide.');
|
||||||
|
self::assertStringStartsWith('%PDF', $binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testForbiddenWithoutViewPermission(): void
|
||||||
|
{
|
||||||
|
// On seede un ticket reel via un user habilite, puis on tente l'impression
|
||||||
|
// avec un user depourvu de `logistique.weighing_tickets.view`.
|
||||||
|
$site = $this->firstSite();
|
||||||
|
$manager = $this->authManageOnSite($site);
|
||||||
|
$client = $this->seedTestClient('Forbidden');
|
||||||
|
|
||||||
|
$created = $this->postTicket($manager, $this->validClientTicketPayload($client));
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
$ticketId = $created->toArray()['id'];
|
||||||
|
|
||||||
|
$creds = $this->createUserWithPermission('core.users.view');
|
||||||
|
$intrus = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||||
|
|
||||||
|
$intrus->request('GET', sprintf('/api/weighing_tickets/%d/print.pdf', $ticketId));
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNotFoundForUnknownTicket(): void
|
||||||
|
{
|
||||||
|
$http = $this->authManageOnSite($this->firstSite());
|
||||||
|
|
||||||
|
$http->request('GET', '/api/weighing_tickets/99999999/print.pdf');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,12 +31,12 @@ final class WeighingTicketSerializationContractTest extends AbstractWeighingTick
|
|||||||
$http = $this->authManageOnSite($site);
|
$http = $this->authManageOnSite($site);
|
||||||
$clientEntity = $this->seedTestClient('Negoce');
|
$clientEntity = $this->seedTestClient('Negoce');
|
||||||
|
|
||||||
$created = $this->postTicket($http, $this->validClientTicketPayload($clientEntity));
|
// Brouillon cree puis valide (numero attribue a la validation, ERP-193).
|
||||||
self::assertResponseStatusCodeSame(201);
|
$createdBody = $this->createValidatedTicket($http, $this->validClientTicketPayload($clientEntity));
|
||||||
$createdBody = $created->toArray();
|
|
||||||
|
|
||||||
$id = (int) $createdBody['id'];
|
$id = (int) $createdBody['id'];
|
||||||
$number = (string) $createdBody['number'];
|
$number = (string) $createdBody['number'];
|
||||||
|
self::assertSame('VALIDATED', $createdBody['status']);
|
||||||
|
|
||||||
$detail = $http->request('GET', '/api/weighing_tickets/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
$detail = $http->request('GET', '/api/weighing_tickets/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||||
$list = $http->request('GET', '/api/weighing_tickets?search='.$number, ['headers' => ['Accept' => self::LD]])->toArray();
|
$list = $http->request('GET', '/api/weighing_tickets?search='.$number, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||||
@@ -69,6 +69,9 @@ final class WeighingTicketSerializationContractTest extends AbstractWeighingTick
|
|||||||
// displayDate (date du ticket = fullDate ?? emptyDate) expose en liste.
|
// displayDate (date du ticket = fullDate ?? emptyDate) expose en liste.
|
||||||
self::assertArrayHasKey('displayDate', $row);
|
self::assertArrayHasKey('displayDate', $row);
|
||||||
|
|
||||||
|
// Statut du cycle de vie expose en liste (colonne « En attente / Terminée »).
|
||||||
|
self::assertSame('VALIDATED', $row['status']);
|
||||||
|
|
||||||
// === DETAIL : site embarque (avec code), immatriculation, les 2 pesees ===
|
// === DETAIL : site embarque (avec code), immatriculation, les 2 pesees ===
|
||||||
self::assertIsArray($detail['site']);
|
self::assertIsArray($detail['site']);
|
||||||
self::assertSame('86', $detail['site']['code']);
|
self::assertSame('86', $detail['site']['code']);
|
||||||
@@ -95,9 +98,7 @@ final class WeighingTicketSerializationContractTest extends AbstractWeighingTick
|
|||||||
$http = $this->authManageOnSite($site);
|
$http = $this->authManageOnSite($site);
|
||||||
$supplierEntity = $this->seedTestSupplier('Ferraille');
|
$supplierEntity = $this->seedTestSupplier('Ferraille');
|
||||||
|
|
||||||
$created = $this->postTicket($http, $this->validSupplierTicketPayload($supplierEntity));
|
$createdBody = $this->createValidatedTicket($http, $this->validSupplierTicketPayload($supplierEntity));
|
||||||
self::assertResponseStatusCodeSame(201);
|
|
||||||
$createdBody = $created->toArray();
|
|
||||||
|
|
||||||
$id = (int) $createdBody['id'];
|
$id = (int) $createdBody['id'];
|
||||||
$number = (string) $createdBody['number'];
|
$number = (string) $createdBody['number'];
|
||||||
|
|||||||
+5
-2
@@ -145,14 +145,17 @@ final class CounterpartyValidationTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liste des propertyPath des violations de l'entite.
|
* Liste des propertyPath des violations de l'entite, validee dans le groupe
|
||||||
|
* `finalize` (la coherence contrepartie ne joue qu'a la validation depuis
|
||||||
|
* ERP-193 ; un brouillon peut ne pas porter de contrepartie). Miroir du
|
||||||
|
* validationContext de l'operation `validate` (['Default', 'finalize']).
|
||||||
*
|
*
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
private function violationPaths(WeighingTicket $ticket): array
|
private function violationPaths(WeighingTicket $ticket): array
|
||||||
{
|
{
|
||||||
$paths = [];
|
$paths = [];
|
||||||
foreach ($this->validator->validate($ticket) as $violation) {
|
foreach ($this->validator->validate($ticket, null, ['Default', 'finalize']) as $violation) {
|
||||||
$paths[] = $violation->getPropertyPath();
|
$paths[] = $violation->getPropertyPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-28
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
|
namespace App\Tests\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
|
||||||
|
|
||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
|
|
||||||
use App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface;
|
use App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface;
|
||||||
use App\Module\Logistique\Domain\Exception\WeighbridgeUnavailableException;
|
use App\Module\Logistique\Domain\Exception\WeighbridgeUnavailableException;
|
||||||
use App\Module\Logistique\Domain\Weighbridge\WeighbridgeReading;
|
use App\Module\Logistique\Domain\Weighbridge\WeighbridgeReading;
|
||||||
@@ -21,8 +20,8 @@ use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
|
|||||||
* Processor de l'action `POST /api/weighbridge_readings` (§ 4.2).
|
* Processor de l'action `POST /api/weighbridge_readings` (§ 4.2).
|
||||||
*
|
*
|
||||||
* Couvre les 4 chemins sans BDD ni HTTP (stubs purs) : AUTO (lecture pont),
|
* Couvre les 4 chemins sans BDD ni HTTP (stubs purs) : AUTO (lecture pont),
|
||||||
* MANUAL (allocation DSD seule), indisponibilite → 503 (RG-5.06) et absence de
|
* MANUAL (poids ET DSD saisis conserves tels quels, ERP-193), indisponibilite →
|
||||||
* site courant → 400.
|
* 503 (RG-5.06) et absence de site courant → 400.
|
||||||
*
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
@@ -30,8 +29,7 @@ final class WeighbridgeReadingProcessorTest extends TestCase
|
|||||||
{
|
{
|
||||||
private function site(): Site
|
private function site(): Site
|
||||||
{
|
{
|
||||||
// getId() reste null (non persiste) — sans incidence : reader et allocator
|
// getId() reste null (non persiste) — sans incidence : reader stubbe.
|
||||||
// sont stubbes dans ces tests unitaires.
|
|
||||||
return new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233');
|
return new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,11 +41,7 @@ final class WeighbridgeReadingProcessorTest extends TestCase
|
|||||||
$reader = $this->createStub(WeighbridgeReaderInterface::class);
|
$reader = $this->createStub(WeighbridgeReaderInterface::class);
|
||||||
$reader->method('read')->willReturn(new WeighbridgeReading(23000, 42));
|
$reader->method('read')->willReturn(new WeighbridgeReading(23000, 42));
|
||||||
|
|
||||||
$processor = new WeighbridgeReadingProcessor(
|
$processor = new WeighbridgeReadingProcessor($siteProvider, $reader);
|
||||||
$siteProvider,
|
|
||||||
$reader,
|
|
||||||
$this->createStub(DsdAllocatorInterface::class),
|
|
||||||
);
|
|
||||||
|
|
||||||
$resource = new WeighbridgeReadingResource();
|
$resource = new WeighbridgeReadingResource();
|
||||||
$resource->mode = 'AUTO';
|
$resource->mode = 'AUTO';
|
||||||
@@ -56,34 +50,28 @@ final class WeighbridgeReadingProcessorTest extends TestCase
|
|||||||
|
|
||||||
self::assertSame(23000, $result->weight);
|
self::assertSame(23000, $result->weight);
|
||||||
self::assertSame(42, $result->dsd);
|
self::assertSame(42, $result->dsd);
|
||||||
self::assertNull($result->manualNumber);
|
|
||||||
self::assertSame('AUTO', $result->mode);
|
self::assertSame('AUTO', $result->mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testManualModeKeepsWeightAndAllocatesDsd(): void
|
public function testManualModeKeepsWeightAndDsdAsEntered(): void
|
||||||
{
|
{
|
||||||
$siteProvider = $this->createStub(CurrentSiteProviderInterface::class);
|
$siteProvider = $this->createStub(CurrentSiteProviderInterface::class);
|
||||||
$siteProvider->method('get')->willReturn($this->site());
|
$siteProvider->method('get')->willReturn($this->site());
|
||||||
|
|
||||||
$allocator = $this->createStub(DsdAllocatorInterface::class);
|
|
||||||
$allocator->method('next')->willReturn(43);
|
|
||||||
|
|
||||||
$processor = new WeighbridgeReadingProcessor(
|
$processor = new WeighbridgeReadingProcessor(
|
||||||
$siteProvider,
|
$siteProvider,
|
||||||
$this->createStub(WeighbridgeReaderInterface::class),
|
$this->createStub(WeighbridgeReaderInterface::class),
|
||||||
$allocator,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$resource = new WeighbridgeReadingResource();
|
$resource = new WeighbridgeReadingResource();
|
||||||
$resource->mode = 'MANUAL';
|
$resource->mode = 'MANUAL';
|
||||||
$resource->weight = 23187;
|
$resource->weight = 23187;
|
||||||
$resource->manualNumber = 'PAP-555';
|
$resource->dsd = 16619; // DSD saisi par l'operateur
|
||||||
|
|
||||||
$result = $processor->process($resource, new Post());
|
$result = $processor->process($resource, new Post());
|
||||||
|
|
||||||
self::assertSame(23187, $result->weight, 'Le poids saisi est conserve en manuel.');
|
self::assertSame(23187, $result->weight, 'Le poids saisi est conserve en manuel.');
|
||||||
self::assertSame(43, $result->dsd);
|
self::assertSame(16619, $result->dsd, 'Le DSD saisi est conserve tel quel — pas d\'auto-increment (ERP-193).');
|
||||||
self::assertSame('PAP-555', $result->manualNumber);
|
|
||||||
self::assertSame('MANUAL', $result->mode);
|
self::assertSame('MANUAL', $result->mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,11 +83,7 @@ final class WeighbridgeReadingProcessorTest extends TestCase
|
|||||||
$reader = $this->createStub(WeighbridgeReaderInterface::class);
|
$reader = $this->createStub(WeighbridgeReaderInterface::class);
|
||||||
$reader->method('read')->willThrowException(new WeighbridgeUnavailableException());
|
$reader->method('read')->willThrowException(new WeighbridgeUnavailableException());
|
||||||
|
|
||||||
$processor = new WeighbridgeReadingProcessor(
|
$processor = new WeighbridgeReadingProcessor($siteProvider, $reader);
|
||||||
$siteProvider,
|
|
||||||
$reader,
|
|
||||||
$this->createStub(DsdAllocatorInterface::class),
|
|
||||||
);
|
|
||||||
|
|
||||||
$resource = new WeighbridgeReadingResource();
|
$resource = new WeighbridgeReadingResource();
|
||||||
$resource->mode = 'AUTO';
|
$resource->mode = 'AUTO';
|
||||||
@@ -121,7 +105,6 @@ final class WeighbridgeReadingProcessorTest extends TestCase
|
|||||||
$processor = new WeighbridgeReadingProcessor(
|
$processor = new WeighbridgeReadingProcessor(
|
||||||
$siteProvider,
|
$siteProvider,
|
||||||
$this->createStub(WeighbridgeReaderInterface::class),
|
$this->createStub(WeighbridgeReaderInterface::class),
|
||||||
$this->createStub(DsdAllocatorInterface::class),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$resource = new WeighbridgeReadingResource();
|
$resource = new WeighbridgeReadingResource();
|
||||||
|
|||||||
Reference in New Issue
Block a user