From faafd99ef88805ec41d1ac6541e735013e58a2c3 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 14:38:01 +0000 Subject: [PATCH] =?UTF-8?q?feat=20:=20M5=20=E2=80=94=20Tickets=20de=20pes?= =?UTF-8?q?=C3=A9e=20(ERP-188=20=E2=86=92=20ERP-193)=20(#144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MR unique regroupant tout le module M5 « Tickets de pesée » (remplace les MR empilées #140/#141/#142/#143). ## Périmètre - **ERP-188** — Page liste des tickets de pesée + export XLSX (colonnes Fournisseur/Client/Autre + Statut). - **ERP-189** — Écran « Ajouter » (4 champs en haut, 2 blocs de pesée, pesée bascule/manuelle, date+heure horodatée à la validation). - **ERP-190** — Écran « Modifier » + bouton Imprimer. - **ERP-191** — i18n + libellés + branchement site courant. - **ERP-192** — Bon de pesée PDF généré côté back (template Twig → Dompdf), endpoint `GET /api/weighing_tickets/{id}/print.pdf`. - **ERP-193** — Cycle de vie brouillon/validé (status DRAFT/VALIDATED, numéro attribué à la validation), DSD saisi conservé en pesée manuelle, retours métier design. ## Vérifications - Back : tests Logistique + architecture verts, php-cs-fixer propre, migrations appliquées (dev + test). - Front : suite Vitest complète verte, ESLint propre. Base : `develop` — contient les 16 commits du M5 (rien d'autre). Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/144 Co-authored-by: tristan Co-committed-by: tristan --- composer.json | 1 + composer.lock | 447 +++++++++++++++++- config/sidebar.php | 41 +- frontend/i18n/locales/fr.json | 68 +++ .../logistique/components/WeighingBlock.vue | 109 +++++ .../__tests__/useWeighbridge.spec.ts | 61 +++ .../__tests__/useWeighingTicketForm.spec.ts | 228 +++++++++ .../useWeighingTicketsRepository.spec.ts | 58 +++ .../logistique/composables/useWeighbridge.ts | 72 +++ .../composables/useWeighingTicket.ts | 53 +++ .../composables/useWeighingTicketForm.ts | 309 ++++++++++++ .../useWeighingTicketReferentials.ts | 62 +++ .../useWeighingTicketsRepository.ts | 72 +++ .../__tests__/weighingTicketEdit.spec.ts | 144 ++++++ .../pages/__tests__/weighingTicketNew.spec.ts | 102 ++++ .../__tests__/weighingTicketsIndex.spec.ts | 200 ++++++++ .../pages/weighing-tickets/[id]/edit.vue | 421 +++++++++++++++++ .../pages/weighing-tickets/index.vue | 173 +++++++ .../logistique/pages/weighing-tickets/new.vue | 381 +++++++++++++++ .../__tests__/weighingTicketFormat.spec.ts | 52 ++ .../modules/logistique/utils/weighingMasks.ts | 39 ++ .../logistique/utils/weighingTicketFormat.ts | 32 ++ .../components/CarrierQualimatTab.vue | 14 +- .../transport/pages/carriers/index.vue | 15 +- frontend/shared/utils/__tests__/date.test.ts | 27 +- frontend/shared/utils/date.ts | 34 ++ migrations/Version20260624100000.php | 91 ++++ migrations/Version20260624110000.php | 41 ++ .../Domain/Entity/WeighingTicket.php | 160 +++++-- .../Resource/WeighbridgeReadingResource.php | 48 +- .../Processor/WeighbridgeReadingProcessor.php | 18 +- .../Processor/WeighingTicketProcessor.php | 97 +++- .../Provider/WeighingTicketPrintProvider.php | 103 ++++ .../WeighingTicketExportController.php | 50 +- .../Pdf/WeighingTicketPdfRenderer.php | 75 +++ .../Pdf/assets/logo-lpc-liot.png | Bin 0 -> 7169 bytes .../Database/ColumnCommentsCatalog.php | 43 +- .../weighing_ticket_print.html.twig | 78 +++ .../Api/AbstractWeighingTicketApiTestCase.php | 29 +- .../Api/WeighbridgeReadingApiTest.php | 27 +- .../WeighingTicketExportControllerTest.php | 15 +- .../Api/WeighingTicketLifecycleTest.php | 135 ++++++ .../Api/WeighingTicketNumberingTest.php | 18 +- .../Api/WeighingTicketPrintApiTest.php | 73 +++ ...eighingTicketSerializationContractTest.php | 13 +- .../Processor/CounterpartyValidationTest.php | 7 +- .../WeighbridgeReadingProcessorTest.php | 39 +- 47 files changed, 4121 insertions(+), 254 deletions(-) create mode 100644 frontend/modules/logistique/components/WeighingBlock.vue create mode 100644 frontend/modules/logistique/composables/__tests__/useWeighbridge.spec.ts create mode 100644 frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts create mode 100644 frontend/modules/logistique/composables/__tests__/useWeighingTicketsRepository.spec.ts create mode 100644 frontend/modules/logistique/composables/useWeighbridge.ts create mode 100644 frontend/modules/logistique/composables/useWeighingTicket.ts create mode 100644 frontend/modules/logistique/composables/useWeighingTicketForm.ts create mode 100644 frontend/modules/logistique/composables/useWeighingTicketReferentials.ts create mode 100644 frontend/modules/logistique/composables/useWeighingTicketsRepository.ts create mode 100644 frontend/modules/logistique/pages/__tests__/weighingTicketEdit.spec.ts create mode 100644 frontend/modules/logistique/pages/__tests__/weighingTicketNew.spec.ts create mode 100644 frontend/modules/logistique/pages/__tests__/weighingTicketsIndex.spec.ts create mode 100644 frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue create mode 100644 frontend/modules/logistique/pages/weighing-tickets/index.vue create mode 100644 frontend/modules/logistique/pages/weighing-tickets/new.vue create mode 100644 frontend/modules/logistique/utils/__tests__/weighingTicketFormat.spec.ts create mode 100644 frontend/modules/logistique/utils/weighingMasks.ts create mode 100644 frontend/modules/logistique/utils/weighingTicketFormat.ts create mode 100644 migrations/Version20260624100000.php create mode 100644 migrations/Version20260624110000.php create mode 100644 src/Module/Logistique/Infrastructure/ApiPlatform/State/Provider/WeighingTicketPrintProvider.php create mode 100644 src/Module/Logistique/Infrastructure/Pdf/WeighingTicketPdfRenderer.php create mode 100644 src/Module/Logistique/Infrastructure/Pdf/assets/logo-lpc-liot.png create mode 100644 templates/logistique/weighing_ticket_print.html.twig create mode 100644 tests/Module/Logistique/Api/WeighingTicketLifecycleTest.php create mode 100644 tests/Module/Logistique/Api/WeighingTicketPrintApiTest.php diff --git a/composer.json b/composer.json index 0e5eb29..4ce5354 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "doctrine/doctrine-bundle": "^3.2", "doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/orm": "^3.6", + "dompdf/dompdf": "^3.0", "lexik/jwt-authentication-bundle": "^3.2", "nelmio/cors-bundle": "^2.6", "nyholm/psr7": "^1.8", diff --git a/composer.lock b/composer.lock index f5fba04..ba44978 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b029c1484227c926d39dfd3ae5cb0699", + "content-hash": "224bae08ec63f217eabf5b2b611deaa0", "packages": [ { "name": "api-platform/doctrine-common", @@ -2520,6 +2520,161 @@ }, "time": "2026-02-08T16:21:46+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.1.5", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" + }, + "time": "2026-03-03T13:54:37+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, { "name": "lcobucci/jwt", "version": "5.6.0", @@ -2894,6 +3049,73 @@ }, "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", "version": "3.10.0", @@ -3937,6 +4159,86 @@ }, "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", "version": "v8.0.8", @@ -8779,6 +9081,149 @@ ], "time": "2026-03-30T15:14:47+00:00" }, + { + "name": "thecodingmachine/safe", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2026-02-04T18:08:13+00:00" + }, { "name": "twig/twig", "version": "v3.24.0", diff --git a/config/sidebar.php b/config/sidebar.php index 91387d6..5f5d3a0 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -38,7 +38,27 @@ declare(strict_types=1); */ 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 // 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 // applicative (RBAC, users, sites, audit log). // diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index cc62b73..56e766d 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -691,6 +691,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": { "login": "Connexion", "logout": "Deconnexion", diff --git a/frontend/modules/logistique/components/WeighingBlock.vue b/frontend/modules/logistique/components/WeighingBlock.vue new file mode 100644 index 0000000..0dafbc3 --- /dev/null +++ b/frontend/modules/logistique/components/WeighingBlock.vue @@ -0,0 +1,109 @@ + + + diff --git a/frontend/modules/logistique/composables/__tests__/useWeighbridge.spec.ts b/frontend/modules/logistique/composables/__tests__/useWeighbridge.spec.ts new file mode 100644 index 0000000..2004b50 --- /dev/null +++ b/frontend/modules/logistique/composables/__tests__/useWeighbridge.spec.ts @@ -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() + }) +}) diff --git a/frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts b/frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts new file mode 100644 index 0000000..f62bb3f --- /dev/null +++ b/frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts @@ -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') + }) +}) diff --git a/frontend/modules/logistique/composables/__tests__/useWeighingTicketsRepository.spec.ts b/frontend/modules/logistique/composables/__tests__/useWeighingTicketsRepository.spec.ts new file mode 100644 index 0000000..88ec0e5 --- /dev/null +++ b/frontend/modules/logistique/composables/__tests__/useWeighingTicketsRepository.spec.ts @@ -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` 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) + }) +}) diff --git a/frontend/modules/logistique/composables/useWeighbridge.ts b/frontend/modules/logistique/composables/useWeighbridge.ts new file mode 100644 index 0000000..a582f76 --- /dev/null +++ b/frontend/modules/logistique/composables/useWeighbridge.ts @@ -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 { + return await api.post( + '/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 { + return await api.post( + '/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 } +} diff --git a/frontend/modules/logistique/composables/useWeighingTicket.ts b/frontend/modules/logistique/composables/useWeighingTicket.ts new file mode 100644 index 0000000..52cdea9 --- /dev/null +++ b/frontend/modules/logistique/composables/useWeighingTicket.ts @@ -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 { + return await api.get( + `/weighing_tickets/${id}`, + {}, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + } + + return { fetchTicket } +} diff --git a/frontend/modules/logistique/composables/useWeighingTicketForm.ts b/frontend/modules/logistique/composables/useWeighingTicketForm.ts new file mode 100644 index 0000000..ba80747 --- /dev/null +++ b/frontend/modules/logistique/composables/useWeighingTicketForm.ts @@ -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): Record { + 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(null) + const clientIri = ref(null) + const supplierIri = ref(null) + const otherLabel = ref(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(null) + const plateFreeFormat = ref(false) + + // ── Les deux pesées ─────────────────────────────────────────────────────── + const empty = reactive(emptyBlock(now)) + const full = reactive(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(null) + + // Cycle de vie courant (DRAFT tant que non validé, ERP-193). + const status = ref('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 { + 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 { + 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 { + 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 { + 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 { + 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, + } +} diff --git a/frontend/modules/logistique/composables/useWeighingTicketReferentials.ts b/frontend/modules/logistique/composables/useWeighingTicketReferentials.ts new file mode 100644 index 0000000..15c592e --- /dev/null +++ b/frontend/modules/logistique/composables/useWeighingTicketReferentials.ts @@ -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([]) + const suppliers = ref([]) + + /** Récupère une collection complète (pagination désactivée) en Hydra. */ + async function fetchAll(url: string): Promise { + 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 { + 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 } +} diff --git a/frontend/modules/logistique/composables/useWeighingTicketsRepository.ts b/frontend/modules/logistique/composables/useWeighingTicketsRepository.ts new file mode 100644 index 0000000..0e059e5 --- /dev/null +++ b/frontend/modules/logistique/composables/useWeighingTicketsRepository.ts @@ -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` 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({ + url: '/weighing_tickets', + defaultItemsPerPage: 25, + }) +} diff --git a/frontend/modules/logistique/pages/__tests__/weighingTicketEdit.spec.ts b/frontend/modules/logistique/pages/__tests__/weighingTicketEdit.spec.ts new file mode 100644 index 0000000..62e10d5 --- /dev/null +++ b/frontend/modules/logistique/pages/__tests__/weighingTicketEdit.spec.ts @@ -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') + }) +}) diff --git a/frontend/modules/logistique/pages/__tests__/weighingTicketNew.spec.ts b/frontend/modules/logistique/pages/__tests__/weighingTicketNew.spec.ts new file mode 100644 index 0000000..ca5bccb --- /dev/null +++ b/frontend/modules/logistique/pages/__tests__/weighingTicketNew.spec.ts @@ -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') + }) +}) diff --git a/frontend/modules/logistique/pages/__tests__/weighingTicketsIndex.spec.ts b/frontend/modules/logistique/pages/__tests__/weighingTicketsIndex.spec.ts new file mode 100644 index 0000000..708a940 --- /dev/null +++ b/frontend/modules/logistique/pages/__tests__/weighingTicketsIndex.spec.ts @@ -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>>([]) +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> + return h('div', { 'data-testid': 'datatable' }, + (props.items as Array>).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 }), + ) + }) +}) diff --git a/frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue b/frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue new file mode 100644 index 0000000..4686a8d --- /dev/null +++ b/frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue @@ -0,0 +1,421 @@ + + + diff --git a/frontend/modules/logistique/pages/weighing-tickets/index.vue b/frontend/modules/logistique/pages/weighing-tickets/index.vue new file mode 100644 index 0000000..ccd625c --- /dev/null +++ b/frontend/modules/logistique/pages/weighing-tickets/index.vue @@ -0,0 +1,173 @@ + + + diff --git a/frontend/modules/logistique/pages/weighing-tickets/new.vue b/frontend/modules/logistique/pages/weighing-tickets/new.vue new file mode 100644 index 0000000..73bb45c --- /dev/null +++ b/frontend/modules/logistique/pages/weighing-tickets/new.vue @@ -0,0 +1,381 @@ + + + diff --git a/frontend/modules/logistique/utils/__tests__/weighingTicketFormat.spec.ts b/frontend/modules/logistique/utils/__tests__/weighingTicketFormat.spec.ts new file mode 100644 index 0000000..028994a --- /dev/null +++ b/frontend/modules/logistique/utils/__tests__/weighingTicketFormat.spec.ts @@ -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('') + }) + }) +}) diff --git a/frontend/modules/logistique/utils/weighingMasks.ts b/frontend/modules/logistique/utils/weighingMasks.ts new file mode 100644 index 0000000..cf52adf --- /dev/null +++ b/frontend/modules/logistique/utils/weighingMasks.ts @@ -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, ''), +} diff --git a/frontend/modules/logistique/utils/weighingTicketFormat.ts b/frontend/modules/logistique/utils/weighingTicketFormat.ts new file mode 100644 index 0000000..f536fe9 --- /dev/null +++ b/frontend/modules/logistique/utils/weighingTicketFormat.ts @@ -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() : '' +} diff --git a/frontend/modules/transport/components/CarrierQualimatTab.vue b/frontend/modules/transport/components/CarrierQualimatTab.vue index a57436b..7cebfff 100644 --- a/frontend/modules/transport/components/CarrierQualimatTab.vue +++ b/frontend/modules/transport/components/CarrierQualimatTab.vue @@ -1,6 +1,7 @@