Compare commits

..

4 Commits

Author SHA1 Message Date
tristan b438838465 feat(front) : écran modification d'un ticket de pesée + bouton imprimer (ERP-190) 2026-06-22 15:29:15 +02:00
tristan 9f3fe4da4e feat(front) : écran ajouter un ticket de pesée (blocs vide/plein, pesée, masque immat) (ERP-189) 2026-06-22 15:11:54 +02:00
tristan ef7bf69980 style(front) : section Logistique en tête de sidebar + icône camion (ERP-188)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m55s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m30s
2026-06-22 15:03:02 +02:00
tristan 117dcdbdcc feat(front) : page liste des tickets de pesée + export (ERP-188) 2026-06-22 15:03:02 +02:00
69 changed files with 1638 additions and 3545 deletions
-1
View File
@@ -12,7 +12,6 @@
"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",
Generated
+1 -446
View File
@@ -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": "224bae08ec63f217eabf5b2b611deaa0",
"content-hash": "b029c1484227c926d39dfd3ae5cb0699",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -2520,161 +2520,6 @@
},
"time": "2026-02-08T16:21:46+00:00"
},
{
"name": "dompdf/dompdf",
"version": "v3.1.5",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"shasum": ""
},
"require": {
"dompdf/php-font-lib": "^1.0.0",
"dompdf/php-svg-lib": "^1.0.0",
"ext-dom": "*",
"ext-mbstring": "*",
"masterminds/html5": "^2.0",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"ext-gd": "*",
"ext-json": "*",
"ext-zip": "*",
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "^3.5",
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
},
"suggest": {
"ext-gd": "Needed to process images",
"ext-gmagick": "Improves image processing performance",
"ext-imagick": "Improves image processing performance",
"ext-zlib": "Needed for pdf stream compression"
},
"type": "library",
"autoload": {
"psr-4": {
"Dompdf\\": "src/"
},
"classmap": [
"lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1"
],
"authors": [
{
"name": "The Dompdf Community",
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
}
],
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
"source": "https://github.com/dompdf/dompdf/tree/v3.1.5"
},
"time": "2026-03-03T13:54:37+00:00"
},
{
"name": "dompdf/php-font-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-font-lib.git",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
},
"type": "library",
"autoload": {
"psr-4": {
"FontLib\\": "src/FontLib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "The FontLib Community",
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse, export and make subsets of different types of font files.",
"homepage": "https://github.com/dompdf/php-font-lib",
"support": {
"issues": "https://github.com/dompdf/php-font-lib/issues",
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
},
"time": "2026-01-20T14:10:26+00:00"
},
{
"name": "dompdf/php-svg-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabberworm/php-css-parser": "^8.4 || ^9.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
},
"type": "library",
"autoload": {
"psr-4": {
"Svg\\": "src/Svg"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "The SvgLib Community",
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse and export to PDF SVG files.",
"homepage": "https://github.com/dompdf/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
},
"time": "2026-01-02T16:01:13+00:00"
},
{
"name": "lcobucci/jwt",
"version": "5.6.0",
@@ -3049,73 +2894,6 @@
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "masterminds/html5",
"version": "2.10.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",
@@ -4159,86 +3937,6 @@
},
"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",
@@ -9081,149 +8779,6 @@
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "thecodingmachine/safe",
"version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/safe.git",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10",
"squizlabs/php_codesniffer": "^3.2"
},
"type": "library",
"autoload": {
"files": [
"lib/special_cases.php",
"generated/apache.php",
"generated/apcu.php",
"generated/array.php",
"generated/bzip2.php",
"generated/calendar.php",
"generated/classobj.php",
"generated/com.php",
"generated/cubrid.php",
"generated/curl.php",
"generated/datetime.php",
"generated/dir.php",
"generated/eio.php",
"generated/errorfunc.php",
"generated/exec.php",
"generated/fileinfo.php",
"generated/filesystem.php",
"generated/filter.php",
"generated/fpm.php",
"generated/ftp.php",
"generated/funchand.php",
"generated/gettext.php",
"generated/gmp.php",
"generated/gnupg.php",
"generated/hash.php",
"generated/ibase.php",
"generated/ibmDb2.php",
"generated/iconv.php",
"generated/image.php",
"generated/imap.php",
"generated/info.php",
"generated/inotify.php",
"generated/json.php",
"generated/ldap.php",
"generated/libxml.php",
"generated/lzf.php",
"generated/mailparse.php",
"generated/mbstring.php",
"generated/misc.php",
"generated/mysql.php",
"generated/mysqli.php",
"generated/network.php",
"generated/oci8.php",
"generated/opcache.php",
"generated/openssl.php",
"generated/outcontrol.php",
"generated/pcntl.php",
"generated/pcre.php",
"generated/pgsql.php",
"generated/posix.php",
"generated/ps.php",
"generated/pspell.php",
"generated/readline.php",
"generated/rnp.php",
"generated/rpminfo.php",
"generated/rrd.php",
"generated/sem.php",
"generated/session.php",
"generated/shmop.php",
"generated/sockets.php",
"generated/sodium.php",
"generated/solr.php",
"generated/spl.php",
"generated/sqlsrv.php",
"generated/ssdeep.php",
"generated/ssh2.php",
"generated/stream.php",
"generated/strings.php",
"generated/swoole.php",
"generated/uodbc.php",
"generated/uopz.php",
"generated/url.php",
"generated/var.php",
"generated/xdiff.php",
"generated/xml.php",
"generated/xmlrpc.php",
"generated/yaml.php",
"generated/yaz.php",
"generated/zip.php",
"generated/zlib.php"
],
"classmap": [
"lib/DateTime.php",
"lib/DateTimeImmutable.php",
"lib/Exceptions/",
"generated/Exceptions/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": {
"issues": "https://github.com/thecodingmachine/safe/issues",
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
},
"funding": [
{
"url": "https://github.com/OskarStark",
"type": "github"
},
{
"url": "https://github.com/shish",
"type": "github"
},
{
"url": "https://github.com/silasjoisten",
"type": "github"
},
{
"url": "https://github.com/staabm",
"type": "github"
}
],
"time": "2026-02-04T18:08:13+00:00"
},
{
"name": "twig/twig",
"version": "v3.24.0",
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.149'
app.version: '0.1.147'
+9 -20
View File
@@ -183,7 +183,6 @@
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
},
"accounting": {
"infoTitle": "Informations",
"siren": "SIREN",
"accountNumber": "Numéro de compte",
"tvaMode": "Mode de TVA",
@@ -191,7 +190,6 @@
"paymentDelay": "Délai de règlement",
"paymentType": "Type de règlement",
"bank": "Banque",
"ribTitle": "RIB {n}",
"ribLabel": "Libellé",
"ribBic": "BIC",
"ribIban": "IBAN",
@@ -352,7 +350,6 @@
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
},
"accounting": {
"infoTitle": "Informations",
"siren": "SIREN",
"accountNumber": "Numéro de compte",
"tvaMode": "Mode de TVA",
@@ -444,7 +441,6 @@
"categoryRequired": "Sélectionnez au moins une catégorie."
},
"contact": {
"title": "Contact {n}",
"lastName": "Nom",
"firstName": "Prénom",
"jobTitle": "Fonction",
@@ -456,7 +452,6 @@
"add": "Nouveau contact"
},
"address": {
"title": "Adresse {n}",
"sites": "Sites",
"contacts": "Contact(s) rattaché(s)",
"country": "Pays",
@@ -470,7 +465,6 @@
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
},
"accounting": {
"infoTitle": "Informations",
"siren": "SIREN",
"accountNumber": "Numéro de compte",
"tvaMode": "Mode de TVA",
@@ -478,7 +472,6 @@
"paymentDelay": "Délai de règlement",
"paymentType": "Type de règlement",
"bank": "Banque",
"ribTitle": "RIB {n}",
"ribLabel": "Libellé",
"ribBic": "BIC",
"ribIban": "IBAN",
@@ -635,7 +628,6 @@
"uploadFailed": "Le téléversement de la décharge a échoué."
},
"address": {
"title": "Adresse",
"country": "Pays",
"postalCode": "Code postal",
"city": "Ville",
@@ -645,7 +637,6 @@
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
},
"contact": {
"title": "Contact {n}",
"lastName": "Nom",
"firstName": "Prénom",
"jobTitle": "Fonction",
@@ -712,18 +703,15 @@
"supplier": "Fournisseur",
"other": "Autre",
"date": "Date",
"weight": "Poids",
"status": "Statut"
},
"status": {
"draft": "En attente",
"validated": "Terminée"
"weight": "Poids"
},
"form": {
"back": "Retour à la liste",
"addTitle": "Ajouter un ticket de pesée",
"emptyBlock": "Poids à vide",
"fullBlock": "Poids à plein",
"number": "Numéro",
"site": "Site",
"date": "Date",
"weight": "Poids (Kg)",
"dsd": "DSD",
@@ -732,8 +720,6 @@
"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",
@@ -743,17 +729,20 @@
"weighbridge": {
"auto": "Pesée bascule",
"manual": "Pesée manuelle",
"confirmTitle": "Êtes-vous sûr de vouloir déclencher une pesée ?",
"confirmTitle": "Pesée bascule",
"confirmMessage": "Êtes-vous sûr de vouloir déclencher une pesée ?",
"cancel": "Annuler",
"validate": "Valider",
"unavailable": "Pont bascule indisponible — passez en pesée manuelle."
},
"manual": {
"title": "Pesée manuelle",
"weight": "Poids (Kg)",
"dsd": "DSD",
"number": "Numéro de pesée",
"save": "Enregistrer",
"cancel": "Annuler",
"weightRequired": "Le poids est obligatoire.",
"dsdRequired": "Le DSD est obligatoire."
"numberRequired": "Le numéro de pesée est obligatoire."
}
},
"edit": {
@@ -1,211 +1,203 @@
<template>
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon
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>
<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)]">
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.clients.form.address.remove') }"
@click="$emit('remove')"
/>
<!-- Grille 4 colonnes des champs de l'adresse. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Usage de l'adresse : Select unique (plus simple pour l'utilisateur)
remplacant les 3 cases. Les options encodent les combinaisons valides
(exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les
drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). -->
<!-- Erreur portee sur `isProspect` cote back (Callback type obligatoire +
exclusivite prospect) -> affichee sous le select Type d'adresse. -->
<MalioSelect
:model-value="addressType"
:options="addressTypeOptions"
:label="t('commercial.clients.form.address.addressType')"
<!-- Usage de l'adresse : Select unique (plus simple pour l'utilisateur)
remplacant les 3 cases. Les options encodent les combinaisons valides
(exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les
drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). -->
<!-- Erreur portee sur `isProspect` cote back (Callback type obligatoire +
exclusivite prospect) -> affichee sous le select Type d'adresse. -->
<MalioSelect
:model-value="addressType"
:options="addressTypeOptions"
:label="t('commercial.clients.form.address.addressType')"
:readonly="readonly"
:disabled="disabled"
: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"
:disabled="disabled"
: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"
: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.city"
:label="t('commercial.clients.form.address.city')"
:mask="ADDRESS_MASK"
:model-value="model.street"
:label="t('commercial.clients.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
:error="errors?.street"
@update:model-value="(v: string) => update('street', 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"
: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 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>
</template>
@@ -238,8 +230,6 @@ const props = defineProps<{
/** Pays disponibles (France par defaut). */
countryOptions: RefOption[]
removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
@@ -1,93 +1,84 @@
<template>
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule.
ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('commercial.clients.form.contact.remove') }"
@click="$emit('remove')"
/>
</div>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule.
ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.clients.form.contact.remove') }"
@click="$emit('remove')"
/>
<!-- Grille 4 colonnes des champs du contact. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName"
:label="t('commercial.clients.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName"
:label="t('commercial.clients.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
<MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName"
:label="t('commercial.clients.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:model-value="model.jobTitle"
:label="t('commercial.clients.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName"
:label="t('commercial.clients.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
<MalioInputText
:model-value="model.jobTitle"
:label="t('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)"
: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>
</template>
@@ -107,8 +98,6 @@ const props = defineProps<{
title: string
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-1.14). */
removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
/** Bloc en lecture seule (onglet valide). */
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
@@ -1,198 +1,189 @@
<template>
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<!-- Suppression : modal de confirmation cote parent. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('commercial.suppliers.form.address.remove') }"
@click="$emit('remove')"
/>
</div>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : modal de confirmation cote parent. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.suppliers.form.address.remove') }"
@click="$emit('remove')"
/>
<!-- Grille 4 colonnes des champs de l'adresse. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Type d'adresse : Prospect / Depart / Rendu (RG-2.09). Select en attendant
l'arbitrage metier (radio vs select) ; l'erreur 422 (propertyPath
`addressType`) s'affiche via la prop native :error de MalioSelect. -->
<MalioSelect
:model-value="model.addressType"
:options="addressTypeOptions"
:label="t('commercial.suppliers.form.address.addressType')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="!readonly && !disabled"
:error="errors?.addressType"
@update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))"
/>
<!-- Type d'adresse : Prospect / Depart / Rendu (RG-2.09). Select en attendant
l'arbitrage metier (radio vs select) ; l'erreur 422 (propertyPath
`addressType`) s'affiche via la prop native :error de MalioSelect. -->
<MalioSelect
:model-value="model.addressType"
:options="addressTypeOptions"
:label="t('commercial.suppliers.form.address.addressType')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
: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). -->
<MalioSelectCheckbox
:model-value="model.siteIris"
:options="siteOptions"
:label="t('commercial.suppliers.form.address.sites')"
:display-tag="true"
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-2.06). -->
<MalioSelectCheckbox
:model-value="model.siteIris"
:options="siteOptions"
:label="t('commercial.suppliers.form.address.sites')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/>
<!-- Contacts rattaches (M2M, facultatif). -->
<MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris"
:options="contactOptions"
:label="t('commercial.suppliers.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<!-- Filler : aligne le debut de ligne suivant sur la grille (le bloc client
porte ici l'email de facturation, absent cote fournisseur). Inutile en
consultation masquee (la grille se recompose sans les champs vides). -->
<div v-if="!hideEmpty" aria-hidden="true" />
<!-- Categories de type FOURNISSEUR (>= 1 obligatoire, RG-2.10). -->
<MalioSelectCheckbox
:model-value="model.categoryIris"
:options="categoryOptions"
:label="t('commercial.suppliers.form.address.categories')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
<MalioSelect
:model-value="model.country"
:options="countryOptions"
:label="t('commercial.suppliers.form.address.country')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<MalioInputText
:model-value="model.postalCode"
:label="t('commercial.suppliers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect
v-if="!degraded"
:model-value="model.city"
:options="cityOptions"
:label="t('commercial.suppliers.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="onCityChange"
/>
<MalioInputText
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"
: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"
: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.city"
:label="t('commercial.suppliers.form.address.city')"
:model-value="model.street"
:label="t('commercial.suppliers.form.address.street')"
: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"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('commercial.suppliers.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('commercial.suppliers.form.address.street')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
<MalioInputText
:model-value="model.streetComplement"
:label="t('commercial.suppliers.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
<!-- Bennes : stepper (specifique fournisseur, defaut 0). En consultation, 0
reste affiche (valeur saisie) ; seul un champ vide serait masque. -->
<MalioInputNumber
v-if="!hideEmpty || isFilled(model.bennes)"
:model-value="model.bennes"
:label="t('commercial.suppliers.form.address.bennes')"
:min="0"
:readonly="readonly"
:disabled="disabled"
:error="errors?.bennes"
@update:model-value="(v: string) => update('bennes', v)"
/>
<!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur).
Consultation : masquee si non cochee (ERP-193). -->
<MalioCheckbox
v-if="!hideEmpty || isFilled(model.triageProvider)"
id="address-triage-provider"
:label="t('commercial.suppliers.form.address.triageProvider')"
:model-value="model.triageProvider"
group-class="self-center"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: boolean) => update('triageProvider', v)"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
<MalioInputText
:model-value="model.streetComplement"
:label="t('commercial.suppliers.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
<!-- Bennes : stepper (specifique fournisseur, defaut 0). En consultation, 0
reste affiche (valeur saisie) ; seul un champ vide serait masque. -->
<MalioInputNumber
v-if="!hideEmpty || isFilled(model.bennes)"
:model-value="model.bennes"
:label="t('commercial.suppliers.form.address.bennes')"
:min="0"
:readonly="readonly"
:disabled="disabled"
:error="errors?.bennes"
@update:model-value="(v: string) => update('bennes', v)"
/>
<!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur).
Consultation : masquee si non cochee (ERP-193). -->
<MalioCheckbox
v-if="!hideEmpty || isFilled(model.triageProvider)"
id="address-triage-provider"
:label="t('commercial.suppliers.form.address.triageProvider')"
:model-value="model.triageProvider"
group-class="self-center"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: boolean) => update('triageProvider', v)"
/>
</div>
</template>
@@ -219,8 +210,6 @@ const props = defineProps<{
/** Pays disponibles (France par defaut). */
countryOptions: RefOption[]
removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
@@ -1,92 +1,83 @@
<template>
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc, RG-2.13) ou en lecture seule. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('commercial.suppliers.form.contact.remove') }"
@click="$emit('remove')"
/>
</div>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc, RG-2.13) ou en lecture seule. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.suppliers.form.contact.remove') }"
@click="$emit('remove')"
/>
<!-- Grille 4 colonnes des champs du contact. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName"
:label="t('commercial.suppliers.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName"
:label="t('commercial.suppliers.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
<MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName"
:label="t('commercial.suppliers.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:model-value="model.jobTitle"
:label="t('commercial.suppliers.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName"
:label="t('commercial.suppliers.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
<MalioInputText
:model-value="model.jobTitle"
:label="t('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)"
: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>
</template>
@@ -105,8 +96,6 @@ const props = defineProps<{
title: string
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-2.13). */
removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
/** Bloc en lecture seule (onglet valide). */
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
@@ -178,7 +178,6 @@
:model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -211,7 +210,6 @@
:key="address.id ?? `new-${index}`"
:model-value="address"
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:category-options="addressCategoryOptions"
:site-options="siteOptions"
:contact-options="contactOptions"
@@ -246,10 +244,8 @@
editable uniquement si accounting.manage). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')"
@@ -318,27 +314,21 @@
</div>
</div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13).
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). -->
<div
v-for="(rib, index) in visibleRibs"
:key="rib.id ?? `new-${index}`"
class="pb-[20px]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
</div>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
@@ -156,7 +156,6 @@
:key="contact.id ?? index"
:model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:last="index === contacts.length - 1"
disabled
hide-empty
/>
@@ -171,7 +170,6 @@
:key="view.draft.id ?? index"
:model-value="view.draft"
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
:last="index === addressViews.length - 1"
:category-options="view.categoryOptions"
:site-options="allSiteOptions"
:contact-options="contactOptions"
@@ -185,10 +183,8 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': 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">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="isFilled(accounting.siren)"
:model-value="accounting.siren"
@@ -243,16 +239,13 @@
</div>
</div>
<!-- Blocs RIB (0..n), lecture seule.
Titre « RIB N », filet de separation sauf sous le dernier. -->
<!-- Blocs RIB (0..n), lecture seule. -->
<div
v-for="(rib, index) in ribs"
:key="rib.id ?? index"
class="pb-[20px]"
:class="{ 'border-b border-black': index !== ribs.length - 1 }"
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<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">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="isFilled(rib.label)"
:model-value="rib.label"
@@ -177,7 +177,6 @@
:model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="isValidated('contact')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -210,7 +209,6 @@
:key="index"
:model-value="address"
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:category-options="addressCategoryOptions"
:site-options="referentials.sites.value"
:contact-options="contactOptions"
@@ -244,10 +242,8 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view) -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')"
@@ -316,28 +312,22 @@
</div>
</div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13).
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). -->
<div
v-for="(rib, index) in visibleRibs"
:key="index"
class="pb-[20px]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
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">
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
@@ -147,7 +147,6 @@
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -180,7 +179,6 @@
:key="address.id ?? `new-${index}`"
:model-value="address"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:category-options="mainCategoryOptions"
:site-options="siteOptions"
:contact-options="contactOptions"
@@ -215,10 +213,8 @@
editable uniquement si accounting.manage). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')"
@@ -287,27 +283,21 @@
</div>
</div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08).
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08). -->
<div
v-for="(rib, index) in visibleRibs"
:key="rib.id ?? `new-${index}`"
class="pb-[20px]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
</div>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')"
@@ -137,7 +137,6 @@
:key="contact.id ?? index"
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:last="index === contacts.length - 1"
disabled
hide-empty
/>
@@ -152,7 +151,6 @@
:key="view.draft.id ?? index"
:model-value="view.draft"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:last="index === addressViews.length - 1"
:category-options="view.categoryOptions"
:site-options="allSiteOptions"
:contact-options="contactOptions"
@@ -166,10 +164,8 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': 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">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="isFilled(accounting.siren)"
:model-value="accounting.siren"
@@ -224,16 +220,13 @@
</div>
</div>
<!-- Blocs RIB (0..n), lecture seule.
Titre « RIB N », filet de separation sauf sous le dernier. -->
<!-- Blocs RIB (0..n), lecture seule. -->
<div
v-for="(rib, index) in ribs"
:key="rib.id ?? index"
class="pb-[20px]"
:class="{ 'border-b border-black': index !== ribs.length - 1 }"
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<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">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="isFilled(rib.label)"
:model-value="rib.label"
@@ -145,7 +145,6 @@
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="isValidated('contacts')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -178,7 +177,6 @@
:key="index"
:model-value="address"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:category-options="referentials.categories.value"
:site-options="referentials.sites.value"
:contact-options="contactOptions"
@@ -212,10 +210,8 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view) -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')"
@@ -284,27 +280,21 @@
</div>
</div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08).
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08). -->
<div
v-for="(rib, index) in visibleRibs"
:key="index"
class="pb-[20px]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
</div>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')"
@@ -1,22 +1,17 @@
<template>
<!-- Padding vertical piloté par la page (1er bloc sans pt, dernier sans pb). -->
<div>
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- 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">
<div class="flex items-center gap-4">
<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')"
@@ -24,12 +19,13 @@
</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
<!-- Contrepartie : rendue par le parent (bloc vide uniquement) via le slot. -->
<slot name="counterparty" />
<!-- Date de la pesée jour par défaut (RG-5.07). MalioDate (composant
projet pour le type date, exception tolérée @.claude/rules/frontend.md). -->
<MalioDate
:model-value="block.date"
:label="t('logistique.weighingTickets.form.date')"
:required="true"
@@ -39,68 +35,95 @@
@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"
<!-- Poids : readonly, rempli par la pesée (RG-5.07). Unité Kg dans le label. -->
<MalioInputNumber
:model-value="block.weight"
:label="t('logistique.weighingTickets.form.weight')"
:required="true"
:disabled="true"
:readonly="true"
:disabled="disabled"
: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"
<!-- DSD : readonly, rempli par la pesée (RG-5.04 / RG-5.07). -->
<MalioInputNumber
:model-value="block.dsd"
:label="t('logistique.weighingTickets.form.dsd')"
:required="true"
:disabled="true"
:readonly="true"
:disabled="disabled"
:error="errors.dsd"
/>
<!-- Immatriculation : masque XX-000-XX (plaque FR SIV) sauf « Tout format ».
PARTAGÉE entre les 2 blocs (RG-5.01) — v-model remonté au form parent.
TODO migrer le masque plaque quand @malio/layer-ui couvrira le format. -->
<MalioInputText
:model-value="immatriculation"
:mask="plateFreeFormat ? undefined : PLATE_MASK"
:label="t('logistique.weighingTickets.form.immatriculation')"
:required="true"
:disabled="disabled"
:error="errors.immatriculation"
@update:model-value="(v: string | null) => $emit('update:immatriculation', v)"
/>
<!-- « Tout format » : désactive le masque plaque. Partagé entre blocs (RG-5.01). -->
<MalioCheckbox
:id="`${blockId}-plate-free-format`"
:model-value="plateFreeFormat"
:label="t('logistique.weighingTickets.form.plateFreeFormat')"
group-class="self-center"
:disabled="disabled"
@update:model-value="(v: boolean) => $emit('update:plateFreeFormat', v)"
/>
</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`.
* Champs Date / Poids / DSD / Immatriculation / « Tout format » + boutons de pesée.
* L'immatriculation et « Tout format » sont PARTAGÉS entre les 2 blocs (RG-5.01) :
* portés par le form parent et remontés en `update:*`. Le slot `counterparty`
* permet au parent d'injecter la contrepartie sur le seul bloc vide (RG-5.03).
*/
const props = withDefaults(defineProps<{
// Masque plaque FR SIV `XX-000-XX` (maska) : 2 lettres, 3 chiffres, 2 lettres,
// majuscules forcées. Désactivé quand « Tout format » est coché (RG-5.01).
const PLATE_MASK = {
mask: 'AA-###-AA',
tokens: { A: { pattern: /[A-Za-z]/, transform: (c: string) => c.toUpperCase() } },
}
const props = defineProps<{
/** Identifiant technique du bloc (pour les `id` de champs uniques). */
blockId: string
title: string
block: WeighingBlockState
/** Immatriculation partagée (RG-5.01) — portée par le form parent. */
immatriculation: string | null
/** « Tout format » partagé (RG-5.01) — porté par le form parent. */
plateFreeFormat: boolean
/** 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]
'update:immatriculation': [value: string | null]
'update:plateFreeFormat': [value: boolean]
'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)))
const errors = computed(() => props.errors ?? {})
/** Remonte la mutation d'un champ du bloc au parent (état des pesées centralisé). */
function emitBlock(field: keyof WeighingBlockState, value: unknown): void {
@@ -26,19 +26,18 @@ describe('useWeighbridge', () => {
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' })
it('MANUAL : POST { mode: MANUAL, weight, manualNumber } et renvoie la lecture', async () => {
mockPost.mockResolvedValue({ weight: 5000, dsd: 43, manualNumber: 'PAP-555', mode: 'MANUAL' })
const { triggerManual } = useWeighbridge()
const reading = await triggerManual(5000, 16619)
const reading = await triggerManual(5000, 'PAP-555')
expect(mockPost).toHaveBeenCalledWith(
'/weighbridge_readings',
{ mode: 'MANUAL', weight: 5000, dsd: 16619 },
{ mode: 'MANUAL', weight: 5000, manualNumber: 'PAP-555' },
expect.objectContaining({ toast: false }),
)
expect(reading.dsd).toBe(16619)
expect(reading.dsd).toBe(43)
})
it('erreur (RG-5.06) : extractWeighbridgeError privilégie le detail du 503', () => {
@@ -1,45 +1,20 @@
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' }))
// `todayIso` est importé par le composable : on le stubbe pour une date déterministe.
vi.mock('~/shared/utils/date', () => ({ todayIso: () => '2026-06-22' }))
const { useWeighingTicketForm } = await import('../useWeighingTicketForm')
describe('useWeighingTicketForm', () => {
it('initialise les 2 blocs à la date/heure courante (RG-5.07), sans poids ni DSD', () => {
it('initialise les 2 blocs à la date du jour (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.date).toBe('2026-06-22')
expect(form.full.date).toBe('2026-06-22')
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()
@@ -53,7 +28,7 @@ describe('useWeighingTicketForm', () => {
expect(form.supplierIri.value).toBeNull()
expect(form.otherLabel.value).toBeNull()
const payload = form.buildDraftPayload()
const payload = form.buildCreatePayload()
expect(payload.counterpartyType).toBe('CLIENT')
expect(payload.client).toBe('/api/clients/629')
expect(payload).not.toHaveProperty('supplier')
@@ -68,7 +43,7 @@ describe('useWeighingTicketForm', () => {
expect(form.counterpartyField.value).toBe('supplier')
expect(form.clientIri.value).toBeNull()
expect(form.buildDraftPayload().supplier).toBe('/api/suppliers/7')
expect(form.buildCreatePayload().supplier).toBe('/api/suppliers/7')
})
it('AUTRE : ne conserve que le libellé libre', () => {
@@ -79,31 +54,7 @@ describe('useWeighingTicketForm', () => {
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')
expect(form.buildCreatePayload().otherLabel).toBe('Reprise interne')
})
// ── Immatriculation / « Tout format » partagés entre blocs (RG-5.01) ──────
@@ -112,58 +63,48 @@ describe('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)
// Les 2 payloads (création + finalisation) reflètent la même valeur.
expect(form.buildCreatePayload().immatriculation).toBe('AB-123-CD')
expect(form.buildCreatePayload().plateFreeFormat).toBe(true)
expect(form.buildFullPayload().immatriculation).toBe('AB-123-CD')
expect(form.buildFullPayload().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 pee', () => {
it('applyReading remplit poids / DSD / mode du bloc visé', () => {
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')
expect(form.empty.manualNumber).toBeNull()
// Pesée manuelle : le DSD saisi (16619) est conservé tel quel (ERP-193).
form.applyReading(form.full, { weight: 14300, dsd: 16619, mode: 'MANUAL' })
form.applyReading(form.full, { weight: 14300, dsd: 2, mode: 'MANUAL', manualNumber: 'PAP-555' })
expect(form.full.weight).toBe(14300)
expect(form.full.dsd).toBe(16619)
expect(form.full.mode).toBe('MANUAL')
expect(form.full.manualNumber).toBe('PAP-555')
})
it('buildDraftPayload porte les pesées effectuées ; buildValidatePayload les 4 champs du haut', () => {
it('buildCreatePayload porte la pesée à vide, buildFullPayload la pesée à plein', () => {
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')
const create = form.buildCreatePayload()
expect(create.emptyWeight).toBe(7150)
expect(create.emptyDsd).toBe(1)
expect(create.emptyMode).toBe('AUTO')
expect(create).not.toHaveProperty('fullWeight')
// 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')
const full = form.buildFullPayload()
expect(full.fullWeight).toBe(14300)
expect(full.fullDsd).toBe(2)
expect(full.fullMode).toBe('AUTO')
})
// ── Pré-remplissage (écran Modification, ERP-190) ─────────────────────────
it('hydrate pré-remplit l\'état depuis le détail (datetime ISO ramené en local, heure conservée)', () => {
it('hydrate pré-remplit l\'état depuis le détail (dates ISO ramenées à YYYY-MM-DD)', () => {
const form = useWeighingTicketForm()
form.hydrate({
id: 9,
@@ -186,9 +127,9 @@ describe('useWeighingTicketForm', () => {
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')
// Date datetime back -> date seule pour MalioDate.
expect(form.empty.date).toBe('2026-06-17')
expect(form.full.date).toBe('2026-06-17')
expect(form.empty.weight).toBe(7150)
expect(form.full.weight).toBe(14300)
})
@@ -199,16 +140,15 @@ describe('useWeighingTicketForm', () => {
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')
// Pas de date back -> repli sur le jour (stub 2026-06-22).
expect(form.empty.date).toBe('2026-06-22')
expect(form.empty.weight).toBeNull()
})
it('buildDraftPayload après hydrate porte contrepartie + véhicule + les 2 pesées', () => {
it('buildUpdatePayload fusionne 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',
@@ -216,9 +156,7 @@ describe('useWeighingTicketForm', () => {
fullWeight: 14300, fullDsd: 2, fullMode: 'AUTO',
})
expect(form.status.value).toBe('VALIDATED')
const payload = form.buildDraftPayload()
const payload = form.buildUpdatePayload()
expect(payload.counterpartyType).toBe('CLIENT')
expect(payload.client).toBe('/api/clients/629')
expect(payload.emptyWeight).toBe(7150)
@@ -1,58 +0,0 @@
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)
})
})
@@ -7,8 +7,8 @@
* - 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).
* - MANUAL (« Pesée manuelle ») : poids + numéro de pesée saisis ; le serveur
* calcule le DSD = dernier + 1 (RG-5.04).
*
* 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
@@ -24,6 +24,8 @@ export interface WeighbridgeReading {
weight: number
dsd: number
mode: WeighbridgeMode
/** Numéro de pesée saisi en mode MANUAL (absent en AUTO). */
manualNumber?: string
}
export function useWeighbridge() {
@@ -44,13 +46,13 @@ export function useWeighbridge() {
}
/**
* 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).
* Pesée manuelle (MANUAL). Le DSD est calculé serveur (dernier + 1, RG-5.04) ;
* le `manualNumber` est la référence du ticket papier / autre bascule.
*/
async function triggerManual(weight: number, dsd: number): Promise<WeighbridgeReading> {
async function triggerManual(weight: number, manualNumber: string): Promise<WeighbridgeReading> {
return await api.post<WeighbridgeReading>(
'/weighbridge_readings',
{ mode: 'MANUAL', weight, dsd },
{ mode: 'MANUAL', weight, manualNumber },
{ toast: false },
)
}
@@ -1,5 +1,5 @@
import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge'
import type { CounterpartyType, WeighingTicketStatus } from '~/modules/logistique/composables/useWeighingTicketForm'
import type { CounterpartyType } from '~/modules/logistique/composables/useWeighingTicketForm'
/**
* Détail d'un ticket de pesée (`GET /api/weighing_tickets/{id}`, spec-back
@@ -8,13 +8,11 @@ import type { CounterpartyType, WeighingTicketStatus } from '~/modules/logistiqu
*/
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
/** Numéro `{siteCode}-TP-{NNNN}` — immuable (RG-5.09). */
number: string
/** Site rattaché (embarqué) — immuable (RG-5.09). */
site?: { id: number, name: string, code: string } | null
counterpartyType?: CounterpartyType | null
counterpartyType: CounterpartyType
client?: { '@id': string, companyName: string } | null
supplier?: { '@id': string, companyName: string } | null
otherLabel?: string | null
@@ -25,11 +23,13 @@ export interface WeighingTicketDetail {
emptyWeight?: number | null
emptyDsd?: number | null
emptyMode?: WeighbridgeMode | null
emptyManualNumber?: string | null
// Pesée à plein
fullDate?: string | null
fullWeight?: number | null
fullDsd?: number | null
fullMode?: WeighbridgeMode | null
fullManualNumber?: string | null
netWeight?: number | null
}
@@ -1,5 +1,5 @@
import { computed, reactive, ref } from 'vue'
import { nowIsoDateTime } from '~/shared/utils/date'
import { todayIso } from '~/shared/utils/date'
import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge'
/**
@@ -11,13 +11,12 @@ import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighb
* - **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).
* - **Immatriculation + « Tout format » partagés entre les 2 blocs (RG-5.01)** :
* une seule valeur (refs uniques) — modifier l'un met à jour l'autre puisque
* les 2 blocs bindent la même ref.
* - **Workflow 2 temps** : `buildCreatePayload()` (POST à l'« Enregistrer » du
* bloc vide) crée le ticket avec la pesée à vide ; `buildFullPayload()` (PATCH
* au « Valider ») ajoute la pesée à plein (net recalculé serveur, RG-5.05).
*
* Composable UI-agnostique et testable : aucune dépendance API ici (les appels
* vivent dans l'écran via `useApi`). Instancié PAR écran (refs locales).
@@ -28,24 +27,22 @@ 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 de la pesée (ISO `YYYY-MM-DD`) — jour 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 — readonly, rempli par la pesée (RG-5.04). */
dsd: number | null
/** Mode de la dernière pesée appliquée au bloc. */
mode: WeighbridgeMode | null
/** Numéro de pesée (rempli uniquement en pesée manuelle). */
manualNumber: string | 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
counterpartyType: CounterpartyType
client?: { '@id': string } | null
supplier?: { '@id': string } | null
otherLabel?: string | null
@@ -55,47 +52,32 @@ export interface WeighingTicketHydration {
emptyWeight?: number | null
emptyDsd?: number | null
emptyMode?: WeighbridgeMode | null
emptyManualNumber?: string | null
fullDate?: string | null
fullWeight?: number | null
fullDsd?: number | null
fullMode?: WeighbridgeMode | null
fullManualNumber?: string | 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
/** Extrait la partie date `YYYY-MM-DD` d'une chaîne ISO (datetime back) — null si absente. */
function isoDateOnly(value: string | null | undefined): string | null {
return value ? value.slice(0, 10) : 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 {
/** Crée l'état initial d'un bloc de pesée (date = aujourd'hui, RG-5.07). */
function emptyBlock(today: string): WeighingBlockState {
return {
date: now,
date: today,
weight: null,
dsd: null,
mode: null,
manualNumber: null,
}
}
export function useWeighingTicketForm() {
const now = nowIsoDateTime()
const today = todayIso()
// ── Contrepartie (RG-5.03) ───────────────────────────────────────────────
const counterpartyType = ref<CounterpartyType | null>(null)
@@ -121,16 +103,12 @@ export function useWeighingTicketForm() {
const plateFreeFormat = ref<boolean>(false)
// ── Les deux pesées ───────────────────────────────────────────────────────
const empty = reactive<WeighingBlockState>(emptyBlock(now))
const full = reactive<WeighingBlockState>(emptyBlock(now))
const empty = reactive<WeighingBlockState>(emptyBlock(today))
const full = reactive<WeighingBlockState>(emptyBlock(today))
// 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é.
// Id du ticket créé (POST du bloc vide) — pilote le PATCH du bloc plein.
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).
@@ -144,35 +122,15 @@ export function useWeighingTicketForm() {
}
})
/**
* 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).
*/
/** Applique une lecture de pesée (bascule/manuelle) à un bloc. */
function applyReading(
block: WeighingBlockState,
reading: { weight: number, dsd: number, mode: WeighbridgeMode },
reading: { weight: number, dsd: number, mode: WeighbridgeMode, manualNumber?: string },
): void {
block.date = nowIsoDateTime()
block.weight = reading.weight
block.dsd = reading.dsd
block.mode = reading.mode
block.manualNumber = reading.manualNumber ?? null
}
/** Partie « contrepartie » du payload (FK en IRI ou libellé libre). */
@@ -186,69 +144,33 @@ export function useWeighingTicketForm() {
}
/**
* 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.
* Payload de CRÉATION (POST /weighing_tickets, spec-back § 4.3) : contrepartie
* + véhicule + pesée à VIDE. Le numéro, le site et le net sont attribués
* serveur (rien à envoyer). Les noms de champs miroir des `propertyPath` back
* pour que `useFormErrors` mappe les 422 inline.
*/
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 {}
function buildCreatePayload(): Record<string, unknown> {
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(),
counterpartyType: counterpartyType.value,
...counterpartyPayload(),
immatriculation: immatriculation.value || null,
plateFreeFormat: plateFreeFormat.value,
...blockPayload('empty', empty),
...blockPayload('full', full),
})
emptyDate: empty.date || null,
emptyWeight: empty.weight,
emptyDsd: empty.dsd,
emptyMode: empty.mode,
emptyManualNumber: empty.manualNumber || null,
}
}
/**
* 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).
* Les dates ISO du back (datetime) sont ramenées à `YYYY-MM-DD` pour MalioDate.
*/
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
@@ -256,31 +178,45 @@ export function useWeighingTicketForm() {
immatriculation.value = detail.immatriculation ?? null
plateFreeFormat.value = detail.plateFreeFormat ?? false
empty.date = toLocalIsoDateTime(detail.emptyDate) ?? now
empty.date = isoDateOnly(detail.emptyDate) ?? today
empty.weight = detail.emptyWeight ?? null
empty.dsd = detail.emptyDsd ?? null
empty.mode = detail.emptyMode ?? null
empty.manualNumber = detail.emptyManualNumber ?? null
full.date = toLocalIsoDateTime(detail.fullDate) ?? now
full.date = isoDateOnly(detail.fullDate) ?? today
full.weight = detail.fullWeight ?? null
full.dsd = detail.fullDsd ?? null
full.mode = detail.fullMode ?? null
full.manualNumber = detail.fullManualNumber ?? 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.
* Payload de MODIFICATION (PATCH /weighing_tickets/{id}, ERP-190) : tous les
* champs éditables (contrepartie + véhicule + les 2 pesées). Le numéro et le
* site sont immuables (RG-5.09, ignorés par le back même si envoyés). Le net
* est recalculé serveur (RG-5.05).
*/
function buildValidatePayload(): Record<string, unknown> {
return compact({
counterpartyType: counterpartyType.value,
...counterpartyPayload(),
function buildUpdatePayload(): Record<string, unknown> {
return { ...buildCreatePayload(), ...buildFullPayload() }
}
/**
* Payload de FINALISATION (PATCH /weighing_tickets/{id}, spec-back § 4.4) :
* pesée à PLEIN. Le véhicule (immat / tout format) peut avoir été ajusté entre
* les 2 blocs → on le repousse aussi (valeur partagée, RG-5.01). Le net est
* recalculé serveur (RG-5.05).
*/
function buildFullPayload(): Record<string, unknown> {
return {
immatriculation: immatriculation.value || null,
plateFreeFormat: plateFreeFormat.value,
})
fullDate: full.date || null,
fullWeight: full.weight,
fullDsd: full.dsd,
fullMode: full.mode,
fullManualNumber: full.manualNumber || null,
}
}
return {
@@ -298,12 +234,11 @@ export function useWeighingTicketForm() {
empty,
full,
applyReading,
missingWeighingFields,
// workflow
ticketId,
status,
hydrate,
buildDraftPayload,
buildValidatePayload,
buildCreatePayload,
buildFullPayload,
buildUpdatePayload,
}
}
@@ -1,5 +1,4 @@
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
@@ -26,10 +25,8 @@ export interface WeighingTicketParty {
*/
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
/** Numero metier `{siteCode}-TP-{NNNN}` attribue par site (RG-5.02). */
number: string
/** Embarque uniquement si contrepartie = Client (RG-5.03), sinon absent. */
client: WeighingTicketParty | null
/** Embarque uniquement si contrepartie = Fournisseur (RG-5.03), sinon absent. */
@@ -62,11 +59,5 @@ export interface WeighingTicketFilters {
* 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,
})
return usePaginatedList<WeighingTicket, WeighingTicketFilters>({ url: '/weighing_tickets' })
}
@@ -27,7 +27,7 @@ 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() }))
vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), clearErrors: vi.fn(), handleApiError: vi.fn() }))
globalThis.open = mockOpen
const EditPage = (await import('../weighing-tickets/[id]/edit.vue')).default
@@ -48,10 +48,9 @@ const InputStub = defineComponent({
},
})
// WeighingBlock stubbé (Date/Poids/DSD + boutons) — la contrepartie vit désormais
// dans les 4 champs du haut, hors bloc (ERP-193).
// WeighingBlock stubbe : rend le slot counterparty (présent sur le bloc vide).
const BlockStub = defineComponent({
setup() { return () => h('div', { 'data-testid': 'block' }) },
setup(_, { slots }) { return () => h('div', { 'data-testid': 'block' }, slots.counterparty?.()) },
})
const ModalStub = defineComponent({
@@ -65,7 +64,7 @@ const stubs = {
MalioInputText: InputStub,
MalioInputNumber: InputStub,
MalioSelect: InputStub,
MalioDateTime: InputStub,
MalioDate: InputStub,
MalioCheckbox: InputStub,
MalioModal: ModalStub,
WeighingBlock: BlockStub,
@@ -83,7 +82,6 @@ async function mountPage() {
const DETAIL = {
id: 9,
status: 'VALIDATED',
number: '86-TP-0001',
site: { id: 1, name: 'Chatellerault', code: '86' },
counterpartyType: 'CLIENT',
@@ -102,16 +100,18 @@ describe('Écran Modification ticket de pesée (page /weighing-tickets/{id}/edit
mockOpen.mockReset()
})
it('charge le ticket au montage (pré-remplissage via hydrate)', async () => {
await mountPage()
it('pré-remplit le numéro et le site en lecture seule (RG-5.09)', async () => {
const wrapper = await mountPage()
expect(mockFetchTicket).toHaveBeenCalledWith('9')
expect(wrapper.find('[data-label="logistique.weighingTickets.form.number"]').attributes('value')).toBe('86-TP-0001')
expect(wrapper.find('[data-label="logistique.weighingTickets.form.site"]').attributes('value')).toBe('Chatellerault')
})
it('ticket validé : action principale « Enregistrer » + « Imprimer » (pas « Valider »)', async () => {
it('bascule des boutons : « Enregistrer » + « Imprimer » présents, pas de « 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)
// « Valider » est le bouton de l'écran d'AJOUT — absent en modification (RG-5.08).
expect(wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').exists()).toBe(false)
})
@@ -121,24 +121,15 @@ describe('Écran Modification ticket de pesée (page /weighing-tickets/{id}/edit
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/9/print.pdf', '_blank')
})
it('« Enregistrer » : PATCH brouillon puis PATCH /validate, retour à la liste', async () => {
it('« Enregistrer » PATCH le ticket puis revient à 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')
})
})
@@ -1,102 +0,0 @@
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')
})
})
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, ref } from 'vue'
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
@@ -9,7 +9,6 @@ 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 }))
@@ -18,9 +17,6 @@ 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).
@@ -44,7 +40,6 @@ vi.stubGlobal('useWeighingTicketsRepository', () => ({
goToPage: vi.fn(),
setItemsPerPage: vi.fn(),
setFilters: vi.fn(),
reset: mockReset,
}))
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
@@ -91,13 +86,8 @@ 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, {
return mount(WeighingTicketsIndex, {
global: {
stubs: {
PageHeader: PageHeaderStub,
@@ -106,8 +96,6 @@ function mountPage() {
},
},
})
mountedWrappers.push(wrapper)
return wrapper
}
describe('Liste des tickets de pesée (page /weighing-tickets)', () => {
@@ -116,17 +104,8 @@ describe('Liste des tickets de pesée (page /weighing-tickets)', () => {
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 () => {
@@ -135,17 +114,6 @@ describe('Liste des tickets de pesée (page /weighing-tickets)', () => {
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()
@@ -18,14 +18,38 @@
<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 titreform = mt-[48px] standard). -->
<div class="pb-[20px]">
<div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Numéro + site : lecture seule, immuables (RG-5.09). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
:model-value="ticketNumber"
:label="t('logistique.weighingTickets.form.number')"
:readonly="true"
:disabled="true"
/>
<MalioInputText
:model-value="siteName"
:label="t('logistique.weighingTickets.form.site')"
:readonly="true"
:disabled="true"
/>
</div>
<div class="mt-8 flex flex-col gap-8">
<!-- Bloc « Poids à vide » (porte la contrepartie, RG-5.03) -->
<WeighingBlock
block-id="empty"
:title="t('logistique.weighingTickets.form.emptyBlock')"
:block="form.empty"
:immatriculation="form.immatriculation.value"
:plate-free-format="form.plateFreeFormat.value"
:errors="emptyBlockErrors"
@update:block="(field, value) => updateBlock('empty', field, value)"
@update:immatriculation="(v) => form.immatriculation.value = v"
@update:plate-free-format="(v) => form.plateFreeFormat.value = v"
@request-auto="openAuto('empty')"
@request-manual="openManual('empty')"
>
<template #counterparty>
<MalioSelect
:model-value="form.counterpartyType.value"
:options="counterpartyOptions"
@@ -63,56 +87,28 @@
:error="errors.otherLabel"
@update:model-value="(v: string | null) => form.otherLabel.value = v"
/>
</template>
</WeighingBlock>
<!-- 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 » ──────────────────────────────────── -->
<!-- Bloc « Poids à plein » : le bouton « Enregistrer » du bloc vide
DISPARAÎT en modification (RG-5.08) — on enregistre via le bas. -->
<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"
:immatriculation="form.immatriculation.value"
:plate-free-format="form.plateFreeFormat.value"
:errors="fullBlockErrors"
@update:block="(field, value) => updateBlock('full', field, value)"
@update:immatriculation="(v) => form.immatriculation.value = v"
@update:plate-free-format="(v) => form.plateFreeFormat.value = v"
@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é). -->
<!-- Bas d'écran : « Enregistrer » (remplace « Valider », RG-5.08) +
« Imprimer » (absent à l'ajout, RG-5.08). -->
<div class="mt-12 flex justify-center gap-6">
<MalioButton
variant="secondary"
@@ -123,22 +119,30 @@
/>
<MalioButton
variant="primary"
:label="primaryLabel"
:label="t('logistique.weighingTickets.form.save')"
:disabled="saving"
@click="submitPrimary"
@click="submitSave"
/>
</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">
<MalioModal v-model="autoModal.open" modal-class="max-w-md">
<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>
<p>{{ t('logistique.weighingTickets.form.weighbridge.confirmMessage') }}</p>
<p v-if="autoModal.error" class="mt-4 text-m-danger">{{ autoModal.error }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('logistique.weighingTickets.form.weighbridge.cancel')"
@click="autoModal.open = false"
/>
<MalioButton
variant="primary"
button-class="flex-1"
:label="t('logistique.weighingTickets.form.weighbridge.validate')"
:disabled="autoModal.loading"
@click="confirmAuto"
@@ -147,35 +151,35 @@
</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"
>
<MalioModal v-model="manualModal.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold uppercase">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
</template>
<div class="flex flex-col gap-2">
<MalioInputText
<div class="flex flex-col gap-4">
<MalioInputNumber
v-model="manualModal.weight"
:mask="NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.manual.weight')"
:required="true"
:min="0"
:error="manualModal.errors.weight"
/>
<MalioInputText
v-model="manualModal.dsd"
:mask="NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.manual.dsd')"
v-model="manualModal.manualNumber"
:label="t('logistique.weighingTickets.form.manual.number')"
:required="true"
:error="manualModal.errors.dsd"
:error="manualModal.errors.manualNumber"
/>
</div>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('logistique.weighingTickets.form.manual.cancel')"
@click="manualModal.open = false"
/>
<MalioButton
variant="primary"
button-class="flex-1"
:label="t('logistique.weighingTickets.form.manual.save')"
:disabled="manualModal.loading"
@click="confirmManual"
@@ -191,8 +195,6 @@ import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logist
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()
@@ -217,8 +219,9 @@ 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.
// Numéro + site immuables (RG-5.09), affichés en lecture seule.
const ticketNumber = ref<string>('')
const siteName = ref<string>('')
const headerTitle = computed(() =>
ticketNumber.value
@@ -226,15 +229,6 @@ const headerTitle = computed(() =>
: 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). */
@@ -242,7 +236,7 @@ function goBack(): void {
router.push('/weighing-tickets')
}
// ── Contrepartie (RG-5.03) — ordre maquette : Fournisseur / Client / Autre. ───
// ── Contrepartie (RG-5.03) ───────────────────────────────────────────────────
const counterpartyOptions = computed<RefOption[]>(() => [
{ value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') },
{ value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') },
@@ -259,11 +253,13 @@ const emptyBlockErrors = computed<Record<string, string>>(() => ({
date: errors.emptyDate,
weight: errors.emptyWeight,
dsd: errors.emptyDsd,
immatriculation: errors.immatriculation,
}))
const fullBlockErrors = computed<Record<string, string>>(() => ({
date: errors.fullDate,
weight: errors.fullWeight,
dsd: errors.fullDsd,
immatriculation: errors.immatriculation,
}))
/** Mute un champ d'un bloc de pesée (état centralisé dans le form). */
@@ -285,7 +281,6 @@ function openAuto(target: 'empty' | 'full'): void {
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
@@ -294,7 +289,6 @@ async function confirmAuto(): Promise<void> {
const reading = await weighbridge.triggerAuto()
form.applyReading(form[autoModal.target], reading)
autoModal.open = false
await saveDraft()
}
catch (e) {
autoModal.error = weighbridge.extractWeighbridgeError(e)
@@ -309,83 +303,55 @@ const manualModal = reactive({
open: false,
loading: false,
target: 'empty' as 'empty' | 'full',
weight: null as string | null,
dsd: null as string | null,
weight: null as string | number | null,
manualNumber: 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.manualNumber = 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)
const manualNumber = (manualModal.manualNumber ?? '').trim()
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 (manualNumber === '') {
manualModal.errors = { ...manualModal.errors, manualNumber: t('logistique.weighingTickets.form.manual.numberRequired') }
}
if (Object.keys(manualModal.errors).length > 0) return
manualModal.loading = true
try {
const reading = await weighbridge.triggerManual(weight as number, dsd as number)
const reading = await weighbridge.triggerManual(weight as number, manualNumber)
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) }
manualModal.errors = { 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> {
// ── Soumission / impression ──────────────────────────────────────────────────
/** « Enregistrer » : PATCH /weighing_tickets/{id} (recalcul net serveur, RG-5.05). */
async function submitSave(): Promise<void> {
if (saving.value) return
saving.value = true
clearErrors()
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')
await api.patch(`/weighing_tickets/${ticketId}`, form.buildUpdatePayload(), { toast: false })
router.push('/weighing-tickets')
}
catch (e) {
@@ -405,10 +371,12 @@ function printTicket(): void {
}
onMounted(async () => {
// Référentiels (selects contrepartie) en parallèle, non bloquants.
referentials.load().catch(() => {})
try {
const detail = await fetchTicket(ticketId)
ticketNumber.value = detail.number ?? ''
ticketNumber.value = detail.number
siteName.value = detail.site?.name ?? ''
form.hydrate(detail)
}
catch {
@@ -45,18 +45,13 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { formatDateFr, formatWeightKg } from '~/modules/logistique/utils/weighingTicketFormat'
import { computed, onMounted, ref } from 'vue'
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') })
@@ -75,7 +70,6 @@ const {
fetch: loadTickets,
goToPage,
setItemsPerPage,
reset: reloadFromFirstPage,
} = useWeighingTicketsRepository()
// Mappe les tickets en objets « plats » formates pour MalioDataTable (items typees
@@ -84,16 +78,12 @@ const {
// 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 ?? '',
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'),
netWeight: formatWeight(ticket.netWeight),
})))
const columns = [
@@ -103,9 +93,36 @@ const columns = [
{ 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') },
]
/** Format court francais JJ-MM-AAAA (spec M5). 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()}`
}
/**
* Poids net affiche en kg avec separateur de milliers (espace) + suffixe « Kg »
* (spec-front § formatage : « 7 150 Kg »). Chaine vide si poids absent (ticket
* dont la pesee a plein n'est pas encore finalisee). Groupement manuel (espace
* ASCII) pour un rendu deterministe, independant de l'ICU de l'environnement.
*/
function formatWeight(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`
}
/** 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`)
@@ -158,16 +175,5 @@ function triggerDownload(blob: Blob, filename: string): void {
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>
@@ -13,19 +13,30 @@
<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 titreform = mt-[48px] standard). -->
<div class="pb-[20px]">
<div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<div class="mt-[48px] flex flex-col gap-8">
<!-- Bloc « Poids à vide » (porte la contrepartie, RG-5.03) -->
<WeighingBlock
block-id="empty"
:title="t('logistique.weighingTickets.form.emptyBlock')"
:block="form.empty"
:immatriculation="form.immatriculation.value"
:plate-free-format="form.plateFreeFormat.value"
:errors="emptyBlockErrors"
:disabled="emptyLocked"
@update:block="(field, value) => updateBlock('empty', field, value)"
@update:immatriculation="(v) => form.immatriculation.value = v"
@update:plate-free-format="(v) => form.plateFreeFormat.value = v"
@request-auto="openAuto('empty')"
@request-manual="openManual('empty')"
>
<!-- Contrepartie : sélecteur + champ conditionnel (RG-5.03). -->
<template #counterparty>
<MalioSelect
:model-value="form.counterpartyType.value"
:options="counterpartyOptions"
:label="t('logistique.weighingTickets.form.counterparty.type')"
:required="true"
:disabled="emptyLocked"
empty-option-label=""
:error="errors.counterpartyType"
@update:model-value="onCounterpartyTypeChange"
@@ -36,6 +47,7 @@
:options="referentials.suppliers.value"
:label="t('logistique.weighingTickets.form.counterparty.supplier')"
:required="true"
:disabled="emptyLocked"
empty-option-label=""
:error="errors.supplier"
@update:model-value="(v: string | number | null) => form.supplierIri.value = v === null ? null : String(v)"
@@ -46,6 +58,7 @@
:options="referentials.clients.value"
:label="t('logistique.weighingTickets.form.counterparty.client')"
:required="true"
:disabled="emptyLocked"
empty-option-label=""
:error="errors.client"
@update:model-value="(v: string | number | null) => form.clientIri.value = v === null ? null : String(v)"
@@ -55,80 +68,70 @@
:model-value="form.otherLabel.value"
:label="t('logistique.weighingTickets.form.counterparty.other')"
:required="true"
:disabled="emptyLocked"
:error="errors.otherLabel"
@update:model-value="(v: string | null) => form.otherLabel.value = v"
/>
</template>
</WeighingBlock>
<!-- 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>
<!-- « Enregistrer » du bloc vide : POST initial du ticket (disparaît une
fois le ticket créé — RG-5.08). -->
<div v-if="form.ticketId.value === null" class="flex justify-center">
<MalioButton
variant="primary"
:label="t('logistique.weighingTickets.form.save')"
:disabled="creating"
@click="submitCreate"
/>
</div>
<!-- ── Bloc « Poids à vide » ───────────────────────────────────────-->
<!-- ── Bloc « Poids à plein » ───────────────────────────────────────-->
<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"
:immatriculation="form.immatriculation.value"
:plate-free-format="form.plateFreeFormat.value"
:errors="fullBlockErrors"
@update:block="(field, value) => updateBlock('full', field, value)"
@update:immatriculation="(v) => form.immatriculation.value = v"
@update:plate-free-format="(v) => form.plateFreeFormat.value = v"
@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. -->
<!-- « Valider » (bas d'écran) : PATCH de la pesée à plein puis ouverture du
bon de pesée PDF (RG-5.08). Indisponible tant que le ticket n'est pas créé. -->
<div class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('logistique.weighingTickets.form.validate')"
:disabled="validating"
:disabled="validating || form.ticketId.value === null"
@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">
<MalioModal v-model="autoModal.open" modal-class="max-w-md">
<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>
<p>{{ t('logistique.weighingTickets.form.weighbridge.confirmMessage') }}</p>
<!-- Erreur de pont indisponible affichée INLINE dans la modal + invite
à la pesée manuelle (RG-5.06). -->
<p v-if="autoModal.error" class="mt-4 text-m-danger">{{ autoModal.error }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('logistique.weighingTickets.form.weighbridge.cancel')"
@click="autoModal.open = false"
/>
<MalioButton
variant="primary"
button-class="flex-1"
:label="t('logistique.weighingTickets.form.weighbridge.validate')"
:disabled="autoModal.loading"
@click="confirmAuto"
@@ -137,35 +140,35 @@
</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"
>
<MalioModal v-model="manualModal.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold uppercase">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
</template>
<div class="flex flex-col gap-2">
<MalioInputText
<div class="flex flex-col gap-4">
<MalioInputNumber
v-model="manualModal.weight"
:mask="NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.manual.weight')"
:required="true"
:min="0"
:error="manualModal.errors.weight"
/>
<MalioInputText
v-model="manualModal.dsd"
:mask="NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.manual.dsd')"
v-model="manualModal.manualNumber"
:label="t('logistique.weighingTickets.form.manual.number')"
:required="true"
:error="manualModal.errors.dsd"
:error="manualModal.errors.manualNumber"
/>
</div>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('logistique.weighingTickets.form.manual.cancel')"
@click="manualModal.open = false"
/>
<MalioButton
variant="primary"
button-class="flex-1"
:label="t('logistique.weighingTickets.form.manual.save')"
:disabled="manualModal.loading"
@click="confirmManual"
@@ -180,8 +183,6 @@ 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()
@@ -200,6 +201,10 @@ const weighbridge = useWeighbridge()
const referentials = useWeighingTicketReferentials()
const { errors, clearErrors, handleApiError } = useFormErrors()
// Le bloc vide se verrouille une fois le ticket créé (numéro/site attribués).
const emptyLocked = computed(() => form.ticketId.value !== null)
const creating = ref(false)
const validating = ref(false)
/** Retour vers la liste (flèche d'en-tête). */
@@ -207,7 +212,8 @@ function goBack(): void {
router.push('/weighing-tickets')
}
// ── Contrepartie (RG-5.03) — ordre maquette : Fournisseur / Client / Autre. ───
// ── 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') },
@@ -224,15 +230,18 @@ const emptyBlockErrors = computed<Record<string, string>>(() => ({
date: errors.emptyDate,
weight: errors.emptyWeight,
dsd: errors.emptyDsd,
immatriculation: errors.immatriculation,
}))
const fullBlockErrors = computed<Record<string, string>>(() => ({
date: errors.fullDate,
weight: errors.fullWeight,
dsd: errors.fullDsd,
immatriculation: errors.immatriculation,
}))
/** 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 {
// Affectation typée via l'index du bloc reactif (date est le seul champ éditable).
(form[target] as Record<string, unknown>)[field as string] = value
}
@@ -250,7 +259,7 @@ function openAuto(target: 'empty' | 'full'): void {
autoModal.open = true
}
/** Déclenche la pesée bascule puis enregistre le brouillon (ERP-193). */
/** Déclenche la pesée bascule ; erreur (RG-5.06) affichée dans la modal. */
async function confirmAuto(): Promise<void> {
if (autoModal.loading) return
autoModal.loading = true
@@ -259,9 +268,9 @@ async function confirmAuto(): Promise<void> {
const reading = await weighbridge.triggerAuto()
form.applyReading(form[autoModal.target], reading)
autoModal.open = false
await saveDraft()
}
catch (error) {
// Pont indisponible : message inline + invite à la pesée manuelle.
autoModal.error = weighbridge.extractWeighbridgeError(error)
}
finally {
@@ -274,96 +283,80 @@ const manualModal = reactive({
open: false,
loading: false,
target: 'empty' as 'empty' | 'full',
weight: null as string | null,
dsd: null as string | null,
weight: null as string | number | null,
manualNumber: 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.manualNumber = null
manualModal.errors = {}
manualModal.open = true
}
/** Valide la saisie manuelle (poids + DSD), remplit le bloc puis enregistre le brouillon. */
/** Valide la saisie manuelle puis remplit le bloc (DSD calculé serveur, RG-5.04). */
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)
const manualNumber = (manualModal.manualNumber ?? '').trim()
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 (manualNumber === '') {
manualModal.errors = { ...manualModal.errors, manualNumber: t('logistique.weighingTickets.form.manual.numberRequired') }
}
if (Object.keys(manualModal.errors).length > 0) return
manualModal.loading = true
try {
const reading = await weighbridge.triggerManual(weight as number, dsd as number)
const reading = await weighbridge.triggerManual(weight as number, manualNumber)
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) }
manualModal.errors = { weight: weighbridge.extractWeighbridgeError(error) }
}
finally {
manualModal.loading = false
}
}
// ── Persistance ──────────────────────────────────────────────────────────────
// ── Soumissions ──────────────────────────────────────────────────────────────
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> {
/** « Enregistrer » du bloc vide : POST /weighing_tickets (création + pesée à vide). */
async function submitCreate(): Promise<void> {
if (creating.value) return
creating.value = true
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
const created = await api.post<TicketResponse>('/weighing_tickets', form.buildCreatePayload(), {
headers: { Accept: 'application/ld+json' },
toast: false,
})
form.ticketId.value = created.id
}
catch (error) {
handleApiError(error, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
return false
}
finally {
creating.value = 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.
*/
/** « Valider » : PATCH de la pesée à plein puis ouverture du bon de pesée PDF (RG-5.08). */
async function submitValidate(): Promise<void> {
if (validating.value) return
if (validating.value || form.ticketId.value === null) return
validating.value = true
clearErrors()
try {
if (!(await saveDraft())) return
await api.patch(`/weighing_tickets/${form.ticketId.value}/validate`, form.buildValidatePayload(), { toast: false })
await api.patch(`/weighing_tickets/${form.ticketId.value}`, form.buildFullPayload(), { toast: false })
// Bon de pesée = PDF généré côté back (Twig, ERP-192) — on l'ouvre, on ne
// dessine aucun gabarit côté front (RG-5.08).
window.open(`/api/weighing_tickets/${form.ticketId.value}/print.pdf`, '_blank')
router.push('/weighing-tickets')
}
@@ -376,6 +369,7 @@ async function submitValidate(): Promise<void> {
}
onMounted(() => {
// Échec du chargement des référentiels non bloquant : les selects restent vides.
referentials.load().catch(() => {})
})
</script>
@@ -1,52 +0,0 @@
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('')
})
})
})
@@ -1,39 +0,0 @@
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, ''),
}
@@ -1,32 +0,0 @@
/**
* 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,140 +1,131 @@
<template>
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<!-- Suppression : modal de confirmation cote parent. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }"
@click="$emit('remove')"
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : modal de confirmation cote parent. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }"
@click="$emit('remove')"
/>
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). -->
<MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.siteIris)"
:model-value="model.siteIris"
:options="siteOptions"
:label="t('technique.providers.form.address.sites')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/>
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
<MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris"
:options="contactOptions"
:label="t('technique.providers.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<MalioSelect
v-if="!hideEmpty || isFilled(model.country)"
:model-value="model.country"
:options="countryOptions"
:label="t('technique.providers.form.address.country')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.postalCode)"
:model-value="model.postalCode"
:label="t('technique.providers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect
v-if="!degraded && (!hideEmpty || isFilled(model.city))"
:model-value="model.city"
:options="cityOptions"
:label="t('technique.providers.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="onCityChange"
/>
<MalioInputText
v-else-if="degraded && (!hideEmpty || isFilled(model.city))"
:model-value="model.city"
:label="t('technique.providers.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('technique.providers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('technique.providers.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('technique.providers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<!-- 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'))"
/>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
<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')"
:model-value="model.streetComplement"
:label="t('technique.providers.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('technique.providers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('technique.providers.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('technique.providers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
<MalioInputText
:model-value="model.streetComplement"
:label="t('technique.providers.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
</div>
</div>
</template>
@@ -152,8 +143,6 @@ const POSTAL_CODE_MASK = '#####'
const props = defineProps<{
/** Brouillon de l'adresse (v-model). */
modelValue: ProviderAddressFormDraft
/** Titre du bloc (ex: « Adresse 1 »). */
title: string
/** Sites Starseed disponibles. */
siteOptions: RefOption[]
/** Contacts deja saisis, rattachables a l'adresse. */
@@ -161,8 +150,6 @@ const props = defineProps<{
/** Pays disponibles (France par defaut). */
countryOptions: RefOption[]
removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
@@ -1,93 +1,84 @@
<template>
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc) ou en lecture seule. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('technique.providers.form.contact.remove') }"
@click="$emit('remove')"
/>
</div>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc) ou en lecture seule. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('technique.providers.form.contact.remove') }"
@click="$emit('remove')"
/>
<!-- Grille 4 colonnes des champs du contact. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName"
:label="t('technique.providers.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName"
:label="t('technique.providers.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
<MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName"
:label="t('technique.providers.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:model-value="model.jobTitle"
:label="t('technique.providers.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName"
:label="t('technique.providers.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
<MalioInputText
:model-value="model.jobTitle"
:label="t('technique.providers.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
</div>
<MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email"
:label="t('technique.providers.form.contact.email')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary"
:label="t('technique.providers.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('technique.providers.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<!-- 2e numero : revele a la demande (max 2 telephones par contact). -->
<MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
:model-value="model.phoneSecondary"
:label="t('technique.providers.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
: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>
</template>
@@ -102,12 +93,8 @@ const PHONE_MASK = '## ## ## ## ##'
const props = defineProps<{
/** Brouillon du contact (v-model). */
modelValue: ProviderContactFormDraft
/** Titre du bloc (ex: « Contact 1 »). */
title: string
/** Affiche l'icone de suppression (1er bloc non supprimable). */
removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
/** Bloc en lecture seule (onglet valide). */
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
@@ -46,7 +46,6 @@ function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<str
return mount(ProviderAddressBlock, {
props: {
modelValue: { ...emptyProviderAddress(), ...overrides },
title: 'Adresse 1',
siteOptions: [],
contactOptions: [],
countryOptions: [],
@@ -29,7 +29,6 @@ function mountBlock(errors?: Record<string, string>) {
return mount(ProviderContactBlock, {
props: {
modelValue: emptyProviderContact(),
title: 'Contact 1',
...(errors ? { errors } : {}),
},
global: {
@@ -72,9 +72,7 @@
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -106,8 +104,6 @@
v-for="(address, index) in addresses"
:key="index"
:model-value="address"
:title="t('technique.providers.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
@@ -140,10 +136,8 @@
<!-- Onglet Comptabilite (present si accounting.view ; editable si manage). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('technique.providers.form.accounting.siren')"
@@ -212,27 +206,21 @@
</div>
</div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08).
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08). -->
<div
v-for="(rib, index) in visibleRibs"
:key="index"
class="pb-[20px]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
</div>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('technique.providers.form.accounting.ribLabel')"
@@ -81,8 +81,6 @@
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
:last="index === contacts.length - 1"
disabled
hide-empty
/>
@@ -96,8 +94,6 @@
v-for="(view, index) in addressViews"
:key="index"
:model-value="view.draft"
:title="t('technique.providers.form.address.title', { n: index + 1 })"
:last="index === addressViews.length - 1"
:site-options="view.siteOptions"
:contact-options="contactOptions"
:country-options="countryOptionsFor(view.draft.country)"
@@ -112,10 +108,8 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText v-if="isFilled(accounting.siren)" :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" disabled />
<MalioInputText v-if="isFilled(accounting.accountNumber)" :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" disabled />
<MalioSelect v-if="isFilled(accounting.tvaModeIri)" :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" disabled empty-option-label="" />
@@ -126,16 +120,13 @@
</div>
</div>
<!-- Blocs RIB (uniquement si type de reglement = LCR).
Titre « RIB N », filet de separation sauf sous le dernier. -->
<!-- Blocs RIB (uniquement si type de reglement = LCR). -->
<div
v-for="(rib, index) in visibleRibs"
:key="index"
class="pb-[20px]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<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">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText v-if="isFilled(rib.label)" :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" disabled />
<MalioInputText v-if="isFilled(rib.bic)" :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" disabled />
<MalioInputText v-if="isFilled(rib.iban)" :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" disabled />
@@ -73,9 +73,7 @@
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="isValidated('contact')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -110,8 +108,6 @@
v-for="(address, index) in addresses"
:key="index"
:model-value="address"
:title="t('technique.providers.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
@@ -143,10 +139,8 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view ; editable si manage). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('technique.providers.form.accounting.siren')"
@@ -216,27 +210,21 @@
</div>
</div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08).
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08). -->
<div
v-for="(rib, index) in visibleRibs"
:key="index"
class="pb-[20px]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
</div>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('technique.providers.form.accounting.ribLabel')"
@@ -1,113 +1,103 @@
<template>
<!-- Adresse UNIQUE par transporteur (ERP-172) : un seul bloc, jamais supprimable. -->
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc, en noir (adresse unique, sans suppression). -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<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)]">
<!-- Pays : prerempli « France » (RG-4.05). -->
<MalioSelect
v-if="!hideEmpty || isFilled(model.country)"
:model-value="model.country"
:options="countryOptions"
: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>
<!-- Grille 4 colonnes des champs de l'adresse. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Pays : prerempli « France » (RG-4.05). -->
<MalioSelect
v-if="!hideEmpty || isFilled(model.country)"
:model-value="model.country"
:options="countryOptions"
: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>
<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>
<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>
</template>
@@ -128,12 +118,8 @@ const POSTAL_CODE_MASK = '#####'
const props = defineProps<{
/** Brouillon de l'adresse (v-model). */
modelValue: CarrierAddressFormDraft
/** Titre du bloc (ex: « Adresse 1 »). */
title: string
/** Pays disponibles (France par defaut). */
countryOptions: RefOption[]
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
@@ -1,93 +1,84 @@
<template>
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<!-- Suppression : ouvre une modal de confirmation côté parent. Masquée si
non supprimable (1er bloc) ou en lecture seule. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('transport.carriers.form.contact.remove') }"
@click="$emit('remove')"
/>
</div>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : ouvre une modal de confirmation côté parent. Masquée si
non supprimable (1er bloc) ou en lecture seule. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('transport.carriers.form.contact.remove') }"
@click="$emit('remove')"
/>
<!-- Grille 4 colonnes des champs du contact. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName"
:label="t('transport.carriers.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName"
:label="t('transport.carriers.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText (inheritAttrs:false)
renvoie `class` sur l'input interne, pas sur la cellule de grille. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
<MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName"
:label="t('transport.carriers.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:model-value="model.jobTitle"
:label="t('transport.carriers.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName"
:label="t('transport.carriers.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText (inheritAttrs:false)
renvoie `class` sur l'input interne, pas sur la cellule de grille. -->
<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)"
: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>
</template>
@@ -102,12 +93,8 @@ const PHONE_MASK = '## ## ## ## ##'
const props = defineProps<{
/** Brouillon du contact (v-model). */
modelValue: CarrierContactFormDraft
/** Titre du bloc (ex: « Contact 1 »). */
title: string
/** Affiche l'icône de suppression (1er bloc non supprimable). */
removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
/** Bloc en lecture seule (onglet validé). */
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { debounce } from '~/shared/utils/debounce'
import { formatDateFr } from '~/shared/utils/date'
import { useQualimatSearch, type QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
/**
@@ -93,6 +92,19 @@ function isExpired(value: string): boolean {
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 ───────────────────────────────────────────────
const confirmOpen = ref(false)
@@ -56,7 +56,6 @@ function mountBlock(overrides: Record<string, unknown> = {}) {
return mount(CarrierAddressBlock, {
props: {
modelValue: { ...emptyCarrierAddress(), ...overrides },
title: 'Adresse 1',
countryOptions: [{ value: 'France', label: 'France' }],
},
global: {
@@ -143,8 +143,6 @@
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
<CarrierAddressBlock
:model-value="address"
:title="t('transport.carriers.form.address.title')"
:last="true"
:country-options="countryOptions"
:errors="addressErrors"
@update:model-value="(v) => address = v"
@@ -162,9 +160,7 @@
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('transport.carriers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
@@ -123,8 +123,6 @@
<!-- Adresse UNIQUE (ERP-172). -->
<CarrierAddressBlock
:model-value="address"
:title="t('transport.carriers.form.address.title')"
:last="true"
:country-options="countryOptionsFor(address.country)"
disabled
hide-empty
@@ -138,8 +136,6 @@
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('transport.carriers.form.contact.title', { n: index + 1 })"
:last="index === contacts.length - 1"
disabled
hide-empty
/>
@@ -141,7 +141,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { formatDateFr } from '~/shared/utils/date'
interface FilterOption {
value: string
@@ -236,6 +235,20 @@ function isValidityExpired(item: Record<string, unknown>): boolean {
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}). */
function onRowClick(item: Record<string, unknown>): void {
router.push(`/carriers/${item.id}`)
@@ -180,8 +180,6 @@
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
<CarrierAddressBlock
:model-value="address"
:title="t('transport.carriers.form.address.title')"
:last="true"
:country-options="countryOptions"
:disabled="isQualimat || isValidated('addresses')"
:errors="addressErrors"
@@ -209,9 +207,7 @@
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('transport.carriers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="isValidated('contacts')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
+1 -26
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { formatDateFr, todayIso } from '../date'
import { todayIso } from '../date'
describe('todayIso', () => {
it('formate la date locale en YYYY-MM-DD (zero-pad mois/jour)', () => {
@@ -17,28 +17,3 @@ describe('todayIso', () => {
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('')
})
})
-34
View File
@@ -15,37 +15,3 @@ export function todayIso(now: Date = new Date()): string {
const day = String(now.getDate()).padStart(2, '0')
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}`
}
-91
View File
@@ -1,91 +0,0 @@
<?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,
));
}
}
-41
View File
@@ -1,41 +0,0 @@
<?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,7 +12,6 @@ use ApiPlatform\Metadata\Post;
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\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\Doctrine\DoctrineWeighingTicketRepository;
use App\Module\Sites\Domain\Entity\Site; // relation ORM partagee (§ 2.1)
@@ -85,18 +84,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
]],
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(
security: "is_granted('logistique.weighing_tickets.manage')",
normalizationContext: ['groups' => [
@@ -108,10 +95,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
'default:read',
]],
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,
),
new Patch(
@@ -125,30 +108,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
'default:read',
]],
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,
processor: WeighingTicketProcessor::class,
),
@@ -169,20 +128,14 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
{
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\GeneratedValue]
#[ORM\Column]
#[Groups(['weighing_ticket:read'])]
private ?int $id = null;
/** Numero {siteCode}-TP-{NNNN} — attribue serveur a la VALIDATION, null tant que brouillon, immuable ensuite (RG-5.02, ERP-193). */
#[ORM\Column(length: 20, nullable: true)]
/** Numero {siteCode}-TP-{NNNN} — attribue serveur, lecture seule, immuable (RG-5.02). */
#[ORM\Column(length: 20)]
#[Groups(['weighing_ticket:read'])]
private ?string $number = null;
@@ -192,9 +145,9 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
#[Groups(['weighing_ticket:item:read'])]
private ?Site $site = null;
/** 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, nullable: true)]
#[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.', groups: ['finalize'])]
/** CLIENT | FOURNISSEUR | AUTRE (RG-5.03) — pilote le champ associe obligatoire. */
#[ORM\Column(name: 'counterparty_type', length: 12)]
#[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.')]
#[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR', 'AUTRE'], message: 'Type de contrepartie invalide.')]
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
private ?string $counterpartyType = null;
@@ -217,9 +170,9 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
private ?string $otherLabel = null;
/** 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, nullable: true)]
#[Assert\NotBlank(message: 'L\'immatriculation est obligatoire.', normalizer: 'trim', groups: ['finalize'])]
/** Plaque du vehicule, partagee entre les 2 formulaires (RG-5.01). Masque XX-000-XX sauf plateFreeFormat. */
#[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'L\'immatriculation est obligatoire.', 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'])]
private ?string $immatriculation = null;
@@ -237,12 +190,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?DateTimeImmutable $emptyDate = null;
/**
* 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.
*/
/** Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07). */
#[ORM\Column(name: 'empty_weight', nullable: true)]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?int $emptyWeight = null;
@@ -257,6 +205,12 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
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) ===
#[ORM\Column(name: 'full_date', type: 'datetime_immutable', nullable: true)]
@@ -278,21 +232,17 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
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. */
#[ORM\Column(name: 'net_weight', nullable: true)]
#[Groups(['weighing_ticket:read'])]
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. */
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
private ?DateTimeImmutable $deletedAt = null;
@@ -309,7 +259,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
* (chk_wt_*_branch) et la normalisation du Processor (qui null-ifie les
* champs hors-branche — ERP-185).
*/
#[Assert\Callback(groups: ['finalize'])]
#[Assert\Callback]
public function validateCounterpartyConsistency(ExecutionContextInterface $context): void
{
switch ($this->counterpartyType) {
@@ -345,31 +295,6 @@ 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
* disponible, sinon date de la pesee a vide. Getter calcule (jamais
@@ -534,6 +459,18 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
return $this;
}
public function getEmptyManualNumber(): ?string
{
return $this->emptyManualNumber;
}
public function setEmptyManualNumber(?string $emptyManualNumber): static
{
$this->emptyManualNumber = $emptyManualNumber;
return $this;
}
public function getFullDate(): ?DateTimeImmutable
{
return $this->fullDate;
@@ -582,6 +519,18 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
return $this;
}
public function getFullManualNumber(): ?string
{
return $this->fullManualNumber;
}
public function setFullManualNumber(?string $fullManualNumber): static
{
$this->fullManualNumber = $fullManualNumber;
return $this;
}
public function getNetWeight(): ?int
{
return $this->netWeight;
@@ -594,23 +543,6 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
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
{
return $this->deletedAt;
@@ -20,16 +20,17 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
*
* - AUTO (`{ "mode": "AUTO" }`) → `{ weight, dsd, mode }` (stub : poids
* aleatoire ∈ [10000,50000] kg + DSD du site, RG-5.04 / RG-5.06).
* - MANUAL (`{ "mode": "MANUAL", "weight": <int>, "dsd": <int> }`)
* → `{ 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.
* - MANUAL (`{ "mode": "MANUAL", "weight": <int>, "manualNumber": "<str>" }`)
* → `{ weight, dsd, manualNumber, mode }` (DSD = dernier DSD du site + 1).
*
* `read: false` : pas de chargement d'entite existante — le payload est
* denormalise directement dans cette ressource, puis le Processor prend le relais.
*
* ⚠ En AUTO, le `dsd` renvoye est fourni par le pont. En MANUAL, c'est la valeur
* saisie. Le ticket persiste fait foi.
* ⚠ Le `dsd` renvoye ici est PREVISIONNEL : l'attribution AUTORITAIRE du DSD
* (et du numero de ticket) est refaite/verrouillee a la creation du ticket
* (`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(
shortName: 'WeighbridgeReading',
@@ -62,40 +63,29 @@ final class WeighbridgeReadingResource
#[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])]
public ?int $weight = null;
/**
* 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.')]
/** 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')]
#[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;
/**
* RG metier MANUAL : le pont n'est pas lu, l'operateur saisit le poids ET le DSD
* → les deux sont obligatoires. Porte par un Callback pour que chaque 422 cible
* son propertyPath (`weight` / `dsd`) et soit mappee inline (ERP-101). En AUTO,
* poids et DSD sont fournis par le pont (saisie client ignoree).
* RG metier : en pesee MANUAL, le poids est saisi par l'operateur (le pont
* n'est pas lu) → il est obligatoire. Porte par un Callback pour que le 422
* cible le propertyPath `weight` (mapping inline front, ERP-101). En AUTO,
* le poids fourni par le client est ignore (renseigne par le pont).
*/
#[Assert\Callback]
public function validateManualFields(ExecutionContextInterface $context): void
public function validateManualWeight(ExecutionContextInterface $context): void
{
if ('MANUAL' !== $this->mode) {
return;
}
if (null === $this->weight) {
if ('MANUAL' === $this->mode && null === $this->weight) {
$context->buildViolation('Le poids est obligatoire en pesée manuelle.')
->atPath('weight')
->addViolation()
;
}
if (null === $this->dsd) {
$context->buildViolation('Le DSD est obligatoire en pesée manuelle.')
->atPath('dsd')
->addViolation()
;
}
}
}
@@ -6,6 +6,7 @@ namespace App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
use App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface;
use App\Module\Logistique\Domain\Exception\WeighbridgeUnavailableException;
use App\Module\Logistique\Infrastructure\ApiPlatform\Resource\WeighbridgeReadingResource;
@@ -22,9 +23,7 @@ use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
* - AUTO : lit le pont (WeighbridgeReaderInterface) → poids + DSD. Si la
* bascule est indisponible (WeighbridgeUnavailableException) → HTTP 503
* « Pont bascule indisponible — passez en pesee manuelle » (RG-5.06).
* - 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).
* - MANUAL : conserve le poids saisi et alloue le DSD (dernier + 1, RG-5.04).
*
* @implements ProcessorInterface<WeighbridgeReadingResource, WeighbridgeReadingResource>
*/
@@ -33,6 +32,7 @@ final class WeighbridgeReadingProcessor implements ProcessorInterface
public function __construct(
private readonly CurrentSiteProviderInterface $currentSiteProvider,
private readonly WeighbridgeReaderInterface $weighbridgeReader,
private readonly DsdAllocatorInterface $dsdAllocator,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): WeighbridgeReadingResource
@@ -65,15 +65,17 @@ final class WeighbridgeReadingProcessor implements ProcessorInterface
);
}
$data->weight = $reading->weight;
$data->dsd = $reading->dsd;
$data->weight = $reading->weight;
$data->dsd = $reading->dsd;
$data->manualNumber = null; // pas de numero papier en mode bascule
return $data;
}
// MANUAL : poids ET DSD sont saisis par l'operateur (validateManualFields
// garantit leur presence) et conserves tels quels — aucun auto-increment
// (ERP-193). Rien a recalculer cote serveur.
// MANUAL : le poids est saisi (validateManualWeight garantit sa presence),
// seul le DSD est attribue serveur (dernier DSD du site + 1, RG-5.04).
$data->dsd = $this->dsdAllocator->next($site);
return $data;
}
}
@@ -67,14 +67,14 @@ final class WeighingTicketProcessor implements ProcessorInterface
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
// Une entite non geree par l'ORM = creation (POST). On rattache le site
// 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).
// Une entite non geree par l'ORM = creation (POST) : site + numero ne sont
// attribues qu'a ce moment et restent immuables ensuite (RG-5.09).
$isNew = !$this->em->contains($data);
if ($isNew) {
$data->setSite($this->resolveCurrentSite());
$site = $this->resolveCurrentSite();
$data->setSite($site);
$data->setNumber($this->numberAllocator->allocate($site));
}
$this->applyCounterpartyExclusivity($data);
@@ -84,23 +84,11 @@ final class WeighingTicketProcessor implements ProcessorInterface
// depuis la base. Garde defensive si jamais il manque (ne devrait pas).
$site = $data->getSite();
if ($site instanceof Site) {
$this->allocateAutoDsd($data, $site);
$this->allocateAutoDsd($data, $site, $isNew);
}
$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);
}
@@ -121,73 +109,36 @@ final class WeighingTicketProcessor implements ProcessorInterface
/**
* RG-5.03 : garantit l'exclusivite de la contrepartie en forcant a null les
* champs hors-branche selon counterpartyType. La PRESENCE du champ requis n'est
* validee qu'a la VALIDATION (Assert\Callback groupe finalize, ERP-193) : un
* BROUILLON peut donc arriver ici avec un type choisi mais SANS son champ associe
* (l'operateur a ouvert le menu avant de selectionner). On retire alors la
* 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.
* champs hors-branche selon counterpartyType. La PRESENCE du champ requis est
* deja validee en amont (Assert\Callback de l'entite) ; ici on evite qu'un
* payload portant a la fois client_id ET supplier_id ne fasse echouer les CHECK
* Postgres (500 generique au lieu d'une donnee coherente). otherLabel est
* normalise (trim) dans la branche AUTRE.
*/
private function applyCounterpartyExclusivity(WeighingTicket $data): void
{
switch ($data->getCounterpartyType()) {
case 'CLIENT':
if (null === $data->getClient()) {
$this->clearCounterparty($data);
break;
}
$data->setSupplier(null);
$data->setOtherLabel(null);
break;
case 'FOURNISSEUR':
if (null === $data->getSupplier()) {
$this->clearCounterparty($data);
break;
}
$data->setClient(null);
$data->setOtherLabel(null);
break;
case 'AUTRE':
$label = $this->normalizer->normalizeOtherLabel($data->getOtherLabel());
if (null === $label) {
$this->clearCounterparty($data);
break;
}
$data->setClient(null);
$data->setSupplier(null);
$data->setOtherLabel($label);
$data->setOtherLabel($this->normalizer->normalizeOtherLabel($data->getOtherLabel()));
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
* + masque XX-000-XX hors « Tout format »). Un format invalide est traduit en
@@ -211,25 +162,21 @@ final class WeighingTicketProcessor implements ProcessorInterface
}
/**
* RG-5.04 : le DSD d'une pesee est attribue A LA PESEE (POST /api/weighbridge_readings)
* et CONSERVE tel quel sur le ticket — on ne le reattribue PAS au save. Raison :
* le DSD est l'index de pesee du pont, deja verrouille (FOR UPDATE) a l'emission ;
* demain il proviendra directement du materiel (driver reel derriere
* WeighbridgeReaderInterface) et devra etre persiste a l'identique. Reallouer ici
* ecraserait cet index (double comptage aujourd'hui, perte de l'index reel demain)
* 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).
* RG-5.04 : (re)attribution AUTORITAIRE du DSD pour chaque pesee AUTO via
* DsdAllocator (verrou FOR UPDATE). A la creation, le DSD prévisionnel envoye
* par le client (issu de POST /api/weighbridge_readings) est ecrase. Sur PATCH,
* on n'alloue que pour une pesee AUTO encore depourvue de DSD (ex. la pesee a
* plein realisee apres coup) — sinon on churne le compteur a chaque edition.
* Les pesees MANUELLES conservent leur DSD (deja alloue par l'endpoint de
* pesee, « dernier + 1 »).
*/
private function allocateAutoDsd(WeighingTicket $data, Site $site): void
private function allocateAutoDsd(WeighingTicket $data, Site $site, bool $isNew): void
{
if ('AUTO' === $data->getEmptyMode() && null === $data->getEmptyDsd()) {
if ('AUTO' === $data->getEmptyMode() && ($isNew || null === $data->getEmptyDsd())) {
$data->setEmptyDsd($this->dsdAllocator->next($site));
}
if ('AUTO' === $data->getFullMode() && null === $data->getFullDsd()) {
if ('AUTO' === $data->getFullMode() && ($isNew || null === $data->getFullDsd())) {
$data->setFullDsd($this->dsdAllocator->next($site));
}
}
@@ -1,103 +0,0 @@
<?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();
}
}
@@ -146,11 +146,8 @@ final class WeighingTicketExportController
{
return [
'Numéro',
// Contrepartie eclatee en 3 colonnes mutuellement exclusives (miroir de
// la liste / repertoire, ERP-193) plutot que « type + nom ».
'Fournisseur',
'Client',
'Autre',
'Type contrepartie',
'Contrepartie',
'Date',
'Immatriculation',
'Poids vide (kg)',
@@ -158,7 +155,6 @@ final class WeighingTicketExportController
'Poids net (kg)',
'DSD vide',
'DSD plein',
'Statut',
];
}
@@ -170,14 +166,10 @@ final class WeighingTicketExportController
private function buildRows(array $tickets): iterable
{
foreach ($tickets as $ticket) {
$type = $ticket->getCounterpartyType();
yield [
$ticket->getNumber() ?? '',
// Une seule des 3 colonnes est renseignee selon le type (RG-5.03).
'FOURNISSEUR' === $type ? ($ticket->getSupplier()?->getCompanyName() ?? '') : '',
'CLIENT' === $type ? ($ticket->getClient()?->getCompanyName() ?? '') : '',
'AUTRE' === $type ? ($ticket->getOtherLabel() ?? '') : '',
$ticket->getNumber(),
$this->counterpartyTypeLabel($ticket->getCounterpartyType()),
$this->counterpartyName($ticket),
$ticket->getDisplayDate()?->format('d/m/Y H:i') ?? '',
$ticket->getImmatriculation() ?? '',
$ticket->getEmptyWeight() ?? '',
@@ -185,22 +177,36 @@ final class WeighingTicketExportController
$ticket->getNetWeight() ?? '',
$ticket->getEmptyDsd() ?? '',
$ticket->getFullDsd() ?? '',
$this->statusLabel($ticket->getStatus()),
];
}
}
/**
* Libelle FR du statut du cycle de vie (ERP-193) : « En attente » (DRAFT) ou
* « Terminée » (VALIDATED). Renvoie la valeur brute pour une valeur inattendue
* (garde-fou : ne masque pas une donnee corrompue).
* Libelle FR du type de contrepartie (RG-5.03). Renvoie la valeur brute pour
* une valeur inattendue (garde-fou : ne masque pas une donnee corrompue).
*/
private function statusLabel(string $status): string
private function counterpartyTypeLabel(?string $type): string
{
return match ($status) {
WeighingTicket::STATUS_DRAFT => 'En attente',
WeighingTicket::STATUS_VALIDATED => 'Terminée',
default => $status,
return match ($type) {
'CLIENT' => 'Client',
'FOURNISSEUR' => 'Fournisseur',
'AUTRE' => 'Autre',
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 => '',
};
}
@@ -1,75 +0,0 @@
<?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.

Before

Width:  |  Height:  |  Size: 7.0 KiB

@@ -553,27 +553,28 @@ final class ColumnCommentsCatalog
// -> app:apply-column-comments les rejoue depuis ce catalogue. Strings
// identiques aux COMMENT de la migration Version20260617150000.
'weighing_ticket' => [
'_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.',
'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. 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). 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).',
'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).',
'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.',
'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_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).',
'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_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).',
'net_weight' => 'Poids net = full_weight - empty_weight (kg), calcule serveur (RG-5.05). Null si une pesee manque. Colonne Poids de la liste.',
'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.',
'deleted_at' => 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.',
'_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.',
'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).',
'counterparty_type' => 'Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (chk_wt_counterparty_type, RG-5.03). 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).',
'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).',
'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).',
'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_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_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_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_mode' => 'Mode de la pesee a plein : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_full_mode (RG-5.06).',
'full_manual_number' => 'Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a plein (RG-5.04).',
'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.',
] + self::timestampableBlamableComments(),
];
}
@@ -1,78 +0,0 @@
{#
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,8 +220,7 @@ abstract class AbstractWeighingTicketApiTestCase extends AbstractApiTestCase
/**
* POST un ticket et renvoie la reponse (assertions de statut a la charge de
* l'appelant). Cree un BROUILLON (status DRAFT, sans numero, ERP-193) — la
* validation est portee par validateTicket().
* l'appelant).
*/
protected function postTicket(Client $http, array $payload): ResponseInterface
{
@@ -231,32 +230,6 @@ 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.
*
@@ -56,16 +56,18 @@ final class WeighbridgeReadingApiTest extends AbstractApiTestCase
self::assertLessThanOrEqual(50000, $data['weight']);
self::assertIsInt($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 testManualWeighingKeepsWeightAndEnteredDsd(): void
public function testManualWeighingKeepsWeightAndAllocatesDsd(): void
{
$client = $this->manageClientWithCurrentSite();
$response = $client->request('POST', '/api/weighbridge_readings', [
'headers' => ['Content-Type' => 'application/ld+json'],
// Le DSD est SAISI par l'operateur et conserve tel quel (ERP-193).
'json' => ['mode' => 'MANUAL', 'weight' => 23187, 'dsd' => 16619],
'json' => ['mode' => 'MANUAL', 'weight' => 23187, 'manualNumber' => 'PAP-555'],
]);
self::assertResponseStatusCodeSame(200);
@@ -73,7 +75,8 @@ final class WeighbridgeReadingApiTest extends AbstractApiTestCase
self::assertSame('MANUAL', $data['mode']);
self::assertSame(23187, $data['weight']);
self::assertSame(16619, $data['dsd'], 'Le DSD saisi est conserve, pas d\'auto-increment.');
self::assertSame('PAP-555', $data['manualNumber']);
self::assertGreaterThanOrEqual(1, $data['dsd']);
}
public function testManagePermissionIsRequired(): void
@@ -114,25 +117,11 @@ final class WeighbridgeReadingApiTest extends AbstractApiTestCase
'json' => ['mode' => 'MANUAL'],
]);
// Garde-fou ERP-101 : la 422 doit cibler `weight` (Callback validateManualFields).
// Garde-fou ERP-101 : la 422 doit cibler `weight` (Callback validateManualWeight).
self::assertResponseStatusCodeSame(422);
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
* porter une violation sur le `propertyPath` attendu, consommable inline par
@@ -72,10 +72,8 @@ final class WeighingTicketExportControllerTest extends AbstractApiTestCase
// 1re ligne = en-tetes attendus (ordre des colonnes § 4.5).
$header = $this->gridFromResponse($response->getContent())[0];
self::assertSame('Numéro', $header[0]);
// Contrepartie eclatee en 3 colonnes (miroir liste, ERP-193).
self::assertContains('Fournisseur', $header);
self::assertContains('Client', $header);
self::assertContains('Autre', $header);
self::assertContains('Type contrepartie', $header);
self::assertContains('Contrepartie', $header);
self::assertContains('Date', $header);
self::assertContains('Immatriculation', $header);
self::assertContains('Poids vide (kg)', $header);
@@ -83,7 +81,6 @@ final class WeighingTicketExportControllerTest extends AbstractApiTestCase
self::assertContains('Poids net (kg)', $header);
self::assertContains('DSD vide', $header);
self::assertContains('DSD plein', $header);
self::assertContains('Statut', $header);
}
/**
@@ -102,11 +99,8 @@ final class WeighingTicketExportControllerTest extends AbstractApiTestCase
$cell = static fn (string $label) => $row[array_search($label, $header, true)] ?? null;
// Contrepartie Client → colonne « Client » renseignée, « Fournisseur » / « Autre » vides.
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('Client', $cell('Type contrepartie'));
self::assertStringContainsString('BÉTON SA', (string) $cell('Contrepartie'));
self::assertSame('AB-123-CD', $cell('Immatriculation'));
self::assertSame(7150, (int) $cell('Poids vide (kg)'));
self::assertSame(14300, (int) $cell('Poids plein (kg)'));
@@ -190,7 +184,6 @@ final class WeighingTicketExportControllerTest extends AbstractApiTestCase
$ticket->setFullDsd(42);
$ticket->setFullMode('AUTO');
$ticket->setNetWeight(7150);
$ticket->setStatus(WeighingTicket::STATUS_VALIDATED);
$em->persist($ticket);
$em->flush();
@@ -1,135 +0,0 @@
<?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,12 +26,13 @@ final class WeighingTicketNumberingTest extends AbstractWeighingTicketApiTestCas
$http = $this->authManageOnSite($site);
$client = $this->seedTestClient('Num');
// Le numero est attribue a la VALIDATION (brouillon -> valide, ERP-193).
$first = $this->createValidatedTicket($http, $this->validClientTicketPayload($client));
$second = $this->createValidatedTicket($http, $this->validClientTicketPayload($client));
$first = $this->postTicket($http, $this->validClientTicketPayload($client));
self::assertResponseStatusCodeSame(201);
$second = $this->postTicket($http, $this->validClientTicketPayload($client));
self::assertResponseStatusCodeSame(201);
$n1 = (string) $first['number'];
$n2 = (string) $second['number'];
$n1 = (string) $first->toArray()['number'];
$n2 = (string) $second->toArray()['number'];
self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n1);
self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n2);
@@ -48,8 +49,8 @@ final class WeighingTicketNumberingTest extends AbstractWeighingTicketApiTestCas
$http86 = $this->authManageOnSite($this->siteByCode('86'));
$http17 = $this->authManageOnSite($this->siteByCode('17'));
$n86 = (string) $this->createValidatedTicket($http86, $this->validClientTicketPayload($client))['number'];
$n17 = (string) $this->createValidatedTicket($http17, $this->validClientTicketPayload($client))['number'];
$n86 = (string) $this->postTicket($http86, $this->validClientTicketPayload($client))->toArray()['number'];
$n17 = (string) $this->postTicket($http17, $this->validClientTicketPayload($client))->toArray()['number'];
// Chaque site encode son propre code dans le numero ; sequences disjointes.
self::assertStringStartsWith('86-TP-', $n86);
@@ -62,8 +63,7 @@ final class WeighingTicketNumberingTest extends AbstractWeighingTicketApiTestCas
$http = $this->authManageOnSite($site);
$client = $this->seedTestClient('Immutable');
// Ticket valide (numero attribue) puis tentative de re-ecriture.
$created = $this->createValidatedTicket($http, $this->validClientTicketPayload($client));
$created = $this->postTicket($http, $this->validClientTicketPayload($client))->toArray();
$id = (int) $created['id'];
$number = (string) $created['number'];
@@ -1,73 +0,0 @@
<?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);
$clientEntity = $this->seedTestClient('Negoce');
// Brouillon cree puis valide (numero attribue a la validation, ERP-193).
$createdBody = $this->createValidatedTicket($http, $this->validClientTicketPayload($clientEntity));
$created = $this->postTicket($http, $this->validClientTicketPayload($clientEntity));
self::assertResponseStatusCodeSame(201);
$createdBody = $created->toArray();
$id = (int) $createdBody['id'];
$number = (string) $createdBody['number'];
self::assertSame('VALIDATED', $createdBody['status']);
$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();
@@ -69,9 +69,6 @@ final class WeighingTicketSerializationContractTest extends AbstractWeighingTick
// displayDate (date du ticket = fullDate ?? emptyDate) expose en liste.
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 ===
self::assertIsArray($detail['site']);
self::assertSame('86', $detail['site']['code']);
@@ -98,7 +95,9 @@ final class WeighingTicketSerializationContractTest extends AbstractWeighingTick
$http = $this->authManageOnSite($site);
$supplierEntity = $this->seedTestSupplier('Ferraille');
$createdBody = $this->createValidatedTicket($http, $this->validSupplierTicketPayload($supplierEntity));
$created = $this->postTicket($http, $this->validSupplierTicketPayload($supplierEntity));
self::assertResponseStatusCodeSame(201);
$createdBody = $created->toArray();
$id = (int) $createdBody['id'];
$number = (string) $createdBody['number'];
@@ -145,17 +145,14 @@ final class CounterpartyValidationTest extends TestCase
}
/**
* 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']).
* Liste des propertyPath des violations de l'entite.
*
* @return list<string>
*/
private function violationPaths(WeighingTicket $ticket): array
{
$paths = [];
foreach ($this->validator->validate($ticket, null, ['Default', 'finalize']) as $violation) {
foreach ($this->validator->validate($ticket) as $violation) {
$paths[] = $violation->getPropertyPath();
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Post;
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
use App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface;
use App\Module\Logistique\Domain\Exception\WeighbridgeUnavailableException;
use App\Module\Logistique\Domain\Weighbridge\WeighbridgeReading;
@@ -20,8 +21,8 @@ use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
* Processor de l'action `POST /api/weighbridge_readings` (§ 4.2).
*
* Couvre les 4 chemins sans BDD ni HTTP (stubs purs) : AUTO (lecture pont),
* MANUAL (poids ET DSD saisis conserves tels quels, ERP-193), indisponibilite →
* 503 (RG-5.06) et absence de site courant → 400.
* MANUAL (allocation DSD seule), indisponibilite → 503 (RG-5.06) et absence de
* site courant → 400.
*
* @internal
*/
@@ -29,7 +30,8 @@ final class WeighbridgeReadingProcessorTest extends TestCase
{
private function site(): Site
{
// getId() reste null (non persiste) — sans incidence : reader stubbe.
// getId() reste null (non persiste) — sans incidence : reader et allocator
// sont stubbes dans ces tests unitaires.
return new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233');
}
@@ -41,7 +43,11 @@ final class WeighbridgeReadingProcessorTest extends TestCase
$reader = $this->createStub(WeighbridgeReaderInterface::class);
$reader->method('read')->willReturn(new WeighbridgeReading(23000, 42));
$processor = new WeighbridgeReadingProcessor($siteProvider, $reader);
$processor = new WeighbridgeReadingProcessor(
$siteProvider,
$reader,
$this->createStub(DsdAllocatorInterface::class),
);
$resource = new WeighbridgeReadingResource();
$resource->mode = 'AUTO';
@@ -50,28 +56,34 @@ final class WeighbridgeReadingProcessorTest extends TestCase
self::assertSame(23000, $result->weight);
self::assertSame(42, $result->dsd);
self::assertNull($result->manualNumber);
self::assertSame('AUTO', $result->mode);
}
public function testManualModeKeepsWeightAndDsdAsEntered(): void
public function testManualModeKeepsWeightAndAllocatesDsd(): void
{
$siteProvider = $this->createStub(CurrentSiteProviderInterface::class);
$siteProvider->method('get')->willReturn($this->site());
$allocator = $this->createStub(DsdAllocatorInterface::class);
$allocator->method('next')->willReturn(43);
$processor = new WeighbridgeReadingProcessor(
$siteProvider,
$this->createStub(WeighbridgeReaderInterface::class),
$allocator,
);
$resource = new WeighbridgeReadingResource();
$resource->mode = 'MANUAL';
$resource->weight = 23187;
$resource->dsd = 16619; // DSD saisi par l'operateur
$resource = new WeighbridgeReadingResource();
$resource->mode = 'MANUAL';
$resource->weight = 23187;
$resource->manualNumber = 'PAP-555';
$result = $processor->process($resource, new Post());
self::assertSame(23187, $result->weight, 'Le poids saisi est conserve en manuel.');
self::assertSame(16619, $result->dsd, 'Le DSD saisi est conserve tel quel — pas d\'auto-increment (ERP-193).');
self::assertSame(43, $result->dsd);
self::assertSame('PAP-555', $result->manualNumber);
self::assertSame('MANUAL', $result->mode);
}
@@ -83,7 +95,11 @@ final class WeighbridgeReadingProcessorTest extends TestCase
$reader = $this->createStub(WeighbridgeReaderInterface::class);
$reader->method('read')->willThrowException(new WeighbridgeUnavailableException());
$processor = new WeighbridgeReadingProcessor($siteProvider, $reader);
$processor = new WeighbridgeReadingProcessor(
$siteProvider,
$reader,
$this->createStub(DsdAllocatorInterface::class),
);
$resource = new WeighbridgeReadingResource();
$resource->mode = 'AUTO';
@@ -105,6 +121,7 @@ final class WeighbridgeReadingProcessorTest extends TestCase
$processor = new WeighbridgeReadingProcessor(
$siteProvider,
$this->createStub(WeighbridgeReaderInterface::class),
$this->createStub(DsdAllocatorInterface::class),
);
$resource = new WeighbridgeReadingResource();