feat(front) : branchement site courant + formats d'affichage (ERP-191) #143

Closed
tristan wants to merge 12 commits from feat/erp-191-i18n-site-courant into feat/erp-190-ecran-modification-ticket-pesee
41 changed files with 2128 additions and 637 deletions
+1
View File
@@ -12,6 +12,7 @@
"doctrine/doctrine-bundle": "^3.2", "doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6", "doctrine/orm": "^3.6",
"dompdf/dompdf": "^3.0",
"lexik/jwt-authentication-bundle": "^3.2", "lexik/jwt-authentication-bundle": "^3.2",
"nelmio/cors-bundle": "^2.6", "nelmio/cors-bundle": "^2.6",
"nyholm/psr7": "^1.8", "nyholm/psr7": "^1.8",
Generated
+446 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "b029c1484227c926d39dfd3ae5cb0699", "content-hash": "224bae08ec63f217eabf5b2b611deaa0",
"packages": [ "packages": [
{ {
"name": "api-platform/doctrine-common", "name": "api-platform/doctrine-common",
@@ -2520,6 +2520,161 @@
}, },
"time": "2026-02-08T16:21:46+00:00" "time": "2026-02-08T16:21:46+00:00"
}, },
{
"name": "dompdf/dompdf",
"version": "v3.1.5",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"shasum": ""
},
"require": {
"dompdf/php-font-lib": "^1.0.0",
"dompdf/php-svg-lib": "^1.0.0",
"ext-dom": "*",
"ext-mbstring": "*",
"masterminds/html5": "^2.0",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"ext-gd": "*",
"ext-json": "*",
"ext-zip": "*",
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "^3.5",
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
},
"suggest": {
"ext-gd": "Needed to process images",
"ext-gmagick": "Improves image processing performance",
"ext-imagick": "Improves image processing performance",
"ext-zlib": "Needed for pdf stream compression"
},
"type": "library",
"autoload": {
"psr-4": {
"Dompdf\\": "src/"
},
"classmap": [
"lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1"
],
"authors": [
{
"name": "The Dompdf Community",
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
}
],
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
"source": "https://github.com/dompdf/dompdf/tree/v3.1.5"
},
"time": "2026-03-03T13:54:37+00:00"
},
{
"name": "dompdf/php-font-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-font-lib.git",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
},
"type": "library",
"autoload": {
"psr-4": {
"FontLib\\": "src/FontLib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "The FontLib Community",
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse, export and make subsets of different types of font files.",
"homepage": "https://github.com/dompdf/php-font-lib",
"support": {
"issues": "https://github.com/dompdf/php-font-lib/issues",
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
},
"time": "2026-01-20T14:10:26+00:00"
},
{
"name": "dompdf/php-svg-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabberworm/php-css-parser": "^8.4 || ^9.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
},
"type": "library",
"autoload": {
"psr-4": {
"Svg\\": "src/Svg"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "The SvgLib Community",
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse and export to PDF SVG files.",
"homepage": "https://github.com/dompdf/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
},
"time": "2026-01-02T16:01:13+00:00"
},
{ {
"name": "lcobucci/jwt", "name": "lcobucci/jwt",
"version": "5.6.0", "version": "5.6.0",
@@ -2894,6 +3049,73 @@
}, },
"time": "2022-12-02T22:17:43+00:00" "time": "2022-12-02T22:17:43+00:00"
}, },
{
"name": "masterminds/html5",
"version": "2.10.1",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
"reference": "fd5018f6815fff903946d0564977b44ce8010e29"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29",
"reference": "fd5018f6815fff903946d0564977b44ce8010e29",
"shasum": ""
},
"require": {
"ext-dom": "*",
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.7-dev"
}
},
"autoload": {
"psr-4": {
"Masterminds\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Matt Butcher",
"email": "technosophos@gmail.com"
},
{
"name": "Matt Farina",
"email": "matt@mattfarina.com"
},
{
"name": "Asmir Mustafic",
"email": "goetas@gmail.com"
}
],
"description": "An HTML5 parser and serializer.",
"homepage": "http://masterminds.github.io/html5-php",
"keywords": [
"HTML5",
"dom",
"html",
"parser",
"querypath",
"serializer",
"xml"
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.10.1"
},
"time": "2026-06-23T18:43:15+00:00"
},
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
"version": "3.10.0", "version": "3.10.0",
@@ -3937,6 +4159,86 @@
}, },
"time": "2021-10-29T13:26:27+00:00" "time": "2021-10-29T13:26:27+00:00"
}, },
{
"name": "sabberworm/php-css-parser",
"version": "v9.4.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "1.4.0",
"phpstan/extension-installer": "1.4.3",
"phpstan/phpstan": "1.12.33 || 2.2.2",
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.16",
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11",
"phpunit/phpunit": "8.5.52",
"rawr/phpunit-data-provider": "3.3.1",
"rector/rector": "1.2.10 || 2.4.6",
"rector/type-perfect": "1.0.0 || 2.1.3",
"squizlabs/php_codesniffer": "4.0.1",
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3"
},
"suggest": {
"ext-mbstring": "for parsing UTF-8 CSS"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "9.5.x-dev"
}
},
"autoload": {
"files": [
"src/Rule/Rule.php",
"src/RuleSet/RuleContainer.php"
],
"psr-4": {
"Sabberworm\\CSS\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Raphael Schweikert"
},
{
"name": "Oliver Klee",
"email": "github@oliverklee.de"
},
{
"name": "Jake Hotson",
"email": "jake.github@qzdesign.co.uk"
}
],
"description": "Parser for CSS Files written in PHP",
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"keywords": [
"css",
"parser",
"stylesheet"
],
"support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0"
},
"time": "2026-06-18T15:10:53+00:00"
},
{ {
"name": "symfony/asset", "name": "symfony/asset",
"version": "v8.0.8", "version": "v8.0.8",
@@ -8779,6 +9081,149 @@
], ],
"time": "2026-03-30T15:14:47+00:00" "time": "2026-03-30T15:14:47+00:00"
}, },
{
"name": "thecodingmachine/safe",
"version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/safe.git",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10",
"squizlabs/php_codesniffer": "^3.2"
},
"type": "library",
"autoload": {
"files": [
"lib/special_cases.php",
"generated/apache.php",
"generated/apcu.php",
"generated/array.php",
"generated/bzip2.php",
"generated/calendar.php",
"generated/classobj.php",
"generated/com.php",
"generated/cubrid.php",
"generated/curl.php",
"generated/datetime.php",
"generated/dir.php",
"generated/eio.php",
"generated/errorfunc.php",
"generated/exec.php",
"generated/fileinfo.php",
"generated/filesystem.php",
"generated/filter.php",
"generated/fpm.php",
"generated/ftp.php",
"generated/funchand.php",
"generated/gettext.php",
"generated/gmp.php",
"generated/gnupg.php",
"generated/hash.php",
"generated/ibase.php",
"generated/ibmDb2.php",
"generated/iconv.php",
"generated/image.php",
"generated/imap.php",
"generated/info.php",
"generated/inotify.php",
"generated/json.php",
"generated/ldap.php",
"generated/libxml.php",
"generated/lzf.php",
"generated/mailparse.php",
"generated/mbstring.php",
"generated/misc.php",
"generated/mysql.php",
"generated/mysqli.php",
"generated/network.php",
"generated/oci8.php",
"generated/opcache.php",
"generated/openssl.php",
"generated/outcontrol.php",
"generated/pcntl.php",
"generated/pcre.php",
"generated/pgsql.php",
"generated/posix.php",
"generated/ps.php",
"generated/pspell.php",
"generated/readline.php",
"generated/rnp.php",
"generated/rpminfo.php",
"generated/rrd.php",
"generated/sem.php",
"generated/session.php",
"generated/shmop.php",
"generated/sockets.php",
"generated/sodium.php",
"generated/solr.php",
"generated/spl.php",
"generated/sqlsrv.php",
"generated/ssdeep.php",
"generated/ssh2.php",
"generated/stream.php",
"generated/strings.php",
"generated/swoole.php",
"generated/uodbc.php",
"generated/uopz.php",
"generated/url.php",
"generated/var.php",
"generated/xdiff.php",
"generated/xml.php",
"generated/xmlrpc.php",
"generated/yaml.php",
"generated/yaz.php",
"generated/zip.php",
"generated/zlib.php"
],
"classmap": [
"lib/DateTime.php",
"lib/DateTimeImmutable.php",
"lib/Exceptions/",
"generated/Exceptions/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": {
"issues": "https://github.com/thecodingmachine/safe/issues",
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
},
"funding": [
{
"url": "https://github.com/OskarStark",
"type": "github"
},
{
"url": "https://github.com/shish",
"type": "github"
},
{
"url": "https://github.com/silasjoisten",
"type": "github"
},
{
"url": "https://github.com/staabm",
"type": "github"
}
],
"time": "2026-02-04T18:08:13+00:00"
},
{ {
"name": "twig/twig", "name": "twig/twig",
"version": "v3.24.0", "version": "v3.24.0",
+11 -9
View File
@@ -703,15 +703,18 @@
"supplier": "Fournisseur", "supplier": "Fournisseur",
"other": "Autre", "other": "Autre",
"date": "Date", "date": "Date",
"weight": "Poids" "weight": "Poids",
"status": "Statut"
},
"status": {
"draft": "En attente",
"validated": "Terminée"
}, },
"form": { "form": {
"back": "Retour à la liste", "back": "Retour à la liste",
"addTitle": "Ajouter un ticket de pesée", "addTitle": "Ajouter un ticket de pesée",
"emptyBlock": "Poids à vide", "emptyBlock": "Poids à vide",
"fullBlock": "Poids à plein", "fullBlock": "Poids à plein",
"number": "Numéro",
"site": "Site",
"date": "Date", "date": "Date",
"weight": "Poids (Kg)", "weight": "Poids (Kg)",
"dsd": "DSD", "dsd": "DSD",
@@ -720,6 +723,8 @@
"save": "Enregistrer", "save": "Enregistrer",
"validate": "Valider", "validate": "Valider",
"print": "Imprimer", "print": "Imprimer",
"weightRequired": "Le poids est obligatoire : effectuez une pesée.",
"dsdRequired": "Le DSD est obligatoire : effectuez une pesée.",
"counterparty": { "counterparty": {
"type": "Fournisseur / Client / Autre", "type": "Fournisseur / Client / Autre",
"supplier": "Fournisseur", "supplier": "Fournisseur",
@@ -729,20 +734,17 @@
"weighbridge": { "weighbridge": {
"auto": "Pesée bascule", "auto": "Pesée bascule",
"manual": "Pesée manuelle", "manual": "Pesée manuelle",
"confirmTitle": "Pesée bascule", "confirmTitle": "Êtes-vous sûr de vouloir déclencher une pesée ?",
"confirmMessage": "Êtes-vous sûr de vouloir déclencher une pesée ?",
"cancel": "Annuler",
"validate": "Valider", "validate": "Valider",
"unavailable": "Pont bascule indisponible — passez en pesée manuelle." "unavailable": "Pont bascule indisponible — passez en pesée manuelle."
}, },
"manual": { "manual": {
"title": "Pesée manuelle", "title": "Pesée manuelle",
"weight": "Poids (Kg)", "weight": "Poids (Kg)",
"number": "Numéro de pesée", "dsd": "DSD",
"save": "Enregistrer", "save": "Enregistrer",
"cancel": "Annuler",
"weightRequired": "Le poids est obligatoire.", "weightRequired": "Le poids est obligatoire.",
"numberRequired": "Le numéro de pesée est obligatoire." "dsdRequired": "Le DSD est obligatoire."
} }
}, },
"edit": { "edit": {
@@ -1,17 +1,22 @@
<template> <template>
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <!-- Padding vertical piloté par la page (1er bloc sans pt, dernier sans pb). -->
<div>
<!-- En-tête du bloc : titre + boutons de pesée (bascule / manuelle). --> <!-- En-tête du bloc : titre + boutons de pesée (bascule / manuelle). -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-m-primary">{{ title }}</h2> <h2 class="text-[20px] font-semibold text-m-primary">{{ title }}</h2>
<div class="flex items-center gap-4"> <div class="flex items-center gap-8">
<MalioButton <MalioButton
variant="secondary" variant="secondary"
icon-name="mdi:weight"
icon-position="left"
:label="t('logistique.weighingTickets.form.weighbridge.auto')" :label="t('logistique.weighingTickets.form.weighbridge.auto')"
:disabled="disabled" :disabled="disabled"
@click="$emit('request-auto')" @click="$emit('request-auto')"
/> />
<MalioButton <MalioButton
variant="primary" variant="primary"
icon-name="mdi:weight"
icon-position="left"
:label="t('logistique.weighingTickets.form.weighbridge.manual')" :label="t('logistique.weighingTickets.form.weighbridge.manual')"
:disabled="disabled" :disabled="disabled"
@click="$emit('request-manual')" @click="$emit('request-manual')"
@@ -19,13 +24,12 @@
</div> </div>
</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"> <div class="mt-6 grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Contrepartie : rendue par le parent (bloc vide uniquement) via le slot. --> <!-- Date/heure de la pesée — date du jour + heure courante par défaut
<slot name="counterparty" /> (RG-5.07), ré-horodatée à la validation de la pesée. -->
<MalioDateTime
<!-- 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" :model-value="block.date"
:label="t('logistique.weighingTickets.form.date')" :label="t('logistique.weighingTickets.form.date')"
:required="true" :required="true"
@@ -35,95 +39,68 @@
@update:model-value="(v: string | null) => emitBlock('date', v)" @update:model-value="(v: string | null) => emitBlock('date', v)"
/> />
<!-- Poids : readonly, rempli par la pesée (RG-5.07). Unité Kg dans le label. --> <!-- Poids : champ texte verrouillé sur les chiffres, toujours désactivé
<MalioInputNumber (rempli par la pesée, jamais saisi à la main — RG-5.07). -->
:model-value="block.weight" <MalioInputText
:model-value="weightDisplay"
:mask="NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.weight')" :label="t('logistique.weighingTickets.form.weight')"
:required="true" :required="true"
:readonly="true" :disabled="true"
:disabled="disabled"
:error="errors.weight" :error="errors.weight"
/> />
<!-- DSD : readonly, rempli par la pesée (RG-5.04 / RG-5.07). --> <!-- DSD : champ texte verrouillé sur les chiffres, toujours désactivé
<MalioInputNumber (rempli par la pesée — RG-5.04 / RG-5.07). -->
:model-value="block.dsd" <MalioInputText
:model-value="dsdDisplay"
:mask="NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.dsd')" :label="t('logistique.weighingTickets.form.dsd')"
:required="true" :required="true"
:readonly="true" :disabled="true"
:disabled="disabled"
:error="errors.dsd" :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>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm' 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. * Bloc de pesée (« Poids à vide » ou « Poids à plein ») de l'écran Ticket de pesée.
* Champs Date / Poids / DSD / Immatriculation / « Tout format » + boutons de pesée. * Champs Date/heure / Poids / DSD + boutons de pesée (bascule / manuelle). Depuis
* L'immatriculation et « Tout format » sont PARTAGÉS entre les 2 blocs (RG-5.01) : * ERP-193, la contrepartie, l'immatriculation et « Tout format » sont remontés dans
* portés par le form parent et remontés en `update:*`. Le slot `counterparty` * les 4 champs du haut de page (hors blocs). Masque numérique factorisé dans
* permet au parent d'injecter la contrepartie sur le seul bloc vide (RG-5.03). * `utils/weighingMasks`.
*/ */
// Masque plaque FR SIV `XX-000-XX` (maska) : 2 lettres, 3 chiffres, 2 lettres, const props = withDefaults(defineProps<{
// 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). */ /** Identifiant technique du bloc (pour les `id` de champs uniques). */
blockId: string blockId: string
title: string title: string
block: WeighingBlockState 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). */ /** Erreurs 422 par champ (propertyPath → message). */
errors?: Record<string, string> errors?: Record<string, string>
disabled?: boolean disabled?: boolean
}>() }>(), {
errors: () => ({}),
disabled: false,
})
const emit = defineEmits<{ const emit = defineEmits<{
'update:block': [field: keyof WeighingBlockState, value: unknown] 'update:block': [field: keyof WeighingBlockState, value: unknown]
'update:immatriculation': [value: string | null]
'update:plateFreeFormat': [value: boolean]
'request-auto': [] 'request-auto': []
'request-manual': [] 'request-manual': []
}>() }>()
const { t } = useI18n() const { t } = useI18n()
const errors = computed(() => props.errors ?? {}) // Poids / DSD : champs texte → on présente l'entier sous forme de chaîne (vide
// tant que la pesée n'a pas rempli la valeur).
const weightDisplay = computed(() => (props.block.weight === null ? '' : String(props.block.weight)))
const dsdDisplay = computed(() => (props.block.dsd === null ? '' : String(props.block.dsd)))
/** Remonte la mutation d'un champ du bloc au parent (état des pesées centralisé). */ /** Remonte la mutation d'un champ du bloc au parent (état des pesées centralisé). */
function emitBlock(field: keyof WeighingBlockState, value: unknown): void { function emitBlock(field: keyof WeighingBlockState, value: unknown): void {
@@ -26,18 +26,19 @@ describe('useWeighbridge', () => {
expect(reading).toEqual({ weight: 23187, dsd: 42, mode: 'AUTO' }) expect(reading).toEqual({ weight: 23187, dsd: 42, mode: 'AUTO' })
}) })
it('MANUAL : POST { mode: MANUAL, weight, manualNumber } et renvoie la lecture', async () => { it('MANUAL : POST { mode: MANUAL, weight, dsd } et renvoie la lecture', async () => {
mockPost.mockResolvedValue({ weight: 5000, dsd: 43, manualNumber: 'PAP-555', mode: 'MANUAL' }) // Le DSD est saisi par l'opérateur et conservé tel quel (ERP-193).
mockPost.mockResolvedValue({ weight: 5000, dsd: 16619, mode: 'MANUAL' })
const { triggerManual } = useWeighbridge() const { triggerManual } = useWeighbridge()
const reading = await triggerManual(5000, 'PAP-555') const reading = await triggerManual(5000, 16619)
expect(mockPost).toHaveBeenCalledWith( expect(mockPost).toHaveBeenCalledWith(
'/weighbridge_readings', '/weighbridge_readings',
{ mode: 'MANUAL', weight: 5000, manualNumber: 'PAP-555' }, { mode: 'MANUAL', weight: 5000, dsd: 16619 },
expect.objectContaining({ toast: false }), expect.objectContaining({ toast: false }),
) )
expect(reading.dsd).toBe(43) expect(reading.dsd).toBe(16619)
}) })
it('erreur (RG-5.06) : extractWeighbridgeError privilégie le detail du 503', () => { it('erreur (RG-5.06) : extractWeighbridgeError privilégie le detail du 503', () => {
@@ -1,20 +1,45 @@
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
// `todayIso` est importé par le composable : on le stubbe pour une date déterministe. // `nowIsoDateTime` est importé par le composable : on le stubbe pour un instant déterministe.
vi.mock('~/shared/utils/date', () => ({ todayIso: () => '2026-06-22' })) vi.mock('~/shared/utils/date', () => ({ nowIsoDateTime: () => '2026-06-22T08:30:00' }))
const { useWeighingTicketForm } = await import('../useWeighingTicketForm') const { useWeighingTicketForm } = await import('../useWeighingTicketForm')
describe('useWeighingTicketForm', () => { describe('useWeighingTicketForm', () => {
it('initialise les 2 blocs à la date du jour (RG-5.07), sans poids ni DSD', () => { it('initialise les 2 blocs à la date/heure courante (RG-5.07), sans poids ni DSD', () => {
const form = useWeighingTicketForm() const form = useWeighingTicketForm()
expect(form.empty.date).toBe('2026-06-22') expect(form.empty.date).toBe('2026-06-22T08:30:00')
expect(form.full.date).toBe('2026-06-22') expect(form.full.date).toBe('2026-06-22T08:30:00')
expect(form.empty.weight).toBeNull() expect(form.empty.weight).toBeNull()
expect(form.empty.dsd).toBeNull() expect(form.empty.dsd).toBeNull()
expect(form.counterpartyType.value).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) ──────────────────────────────── // ── Contrepartie conditionnelle (RG-5.03) ────────────────────────────────
it('CLIENT : ne conserve que le client, purge supplier et otherLabel', () => { it('CLIENT : ne conserve que le client, purge supplier et otherLabel', () => {
const form = useWeighingTicketForm() const form = useWeighingTicketForm()
@@ -28,7 +53,7 @@ describe('useWeighingTicketForm', () => {
expect(form.supplierIri.value).toBeNull() expect(form.supplierIri.value).toBeNull()
expect(form.otherLabel.value).toBeNull() expect(form.otherLabel.value).toBeNull()
const payload = form.buildCreatePayload() const payload = form.buildDraftPayload()
expect(payload.counterpartyType).toBe('CLIENT') expect(payload.counterpartyType).toBe('CLIENT')
expect(payload.client).toBe('/api/clients/629') expect(payload.client).toBe('/api/clients/629')
expect(payload).not.toHaveProperty('supplier') expect(payload).not.toHaveProperty('supplier')
@@ -43,7 +68,7 @@ describe('useWeighingTicketForm', () => {
expect(form.counterpartyField.value).toBe('supplier') expect(form.counterpartyField.value).toBe('supplier')
expect(form.clientIri.value).toBeNull() expect(form.clientIri.value).toBeNull()
expect(form.buildCreatePayload().supplier).toBe('/api/suppliers/7') expect(form.buildDraftPayload().supplier).toBe('/api/suppliers/7')
}) })
it('AUTRE : ne conserve que le libellé libre', () => { it('AUTRE : ne conserve que le libellé libre', () => {
@@ -54,7 +79,7 @@ describe('useWeighingTicketForm', () => {
expect(form.counterpartyField.value).toBe('other') expect(form.counterpartyField.value).toBe('other')
expect(form.clientIri.value).toBeNull() expect(form.clientIri.value).toBeNull()
expect(form.buildCreatePayload().otherLabel).toBe('Reprise interne') expect(form.buildDraftPayload().otherLabel).toBe('Reprise interne')
}) })
// ── Immatriculation / « Tout format » partagés entre blocs (RG-5.01) ────── // ── Immatriculation / « Tout format » partagés entre blocs (RG-5.01) ──────
@@ -63,48 +88,58 @@ describe('useWeighingTicketForm', () => {
form.immatriculation.value = 'AB-123-CD' form.immatriculation.value = 'AB-123-CD'
form.plateFreeFormat.value = true form.plateFreeFormat.value = true
// Les 2 payloads (création + finalisation) reflètent la même valeur. // Les 2 payloads (brouillon + validation) reflètent la même valeur.
expect(form.buildCreatePayload().immatriculation).toBe('AB-123-CD') expect(form.buildDraftPayload().immatriculation).toBe('AB-123-CD')
expect(form.buildCreatePayload().plateFreeFormat).toBe(true) expect(form.buildDraftPayload().plateFreeFormat).toBe(true)
expect(form.buildFullPayload().immatriculation).toBe('AB-123-CD') expect(form.buildValidatePayload().immatriculation).toBe('AB-123-CD')
expect(form.buildFullPayload().plateFreeFormat).toBe(true) expect(form.buildValidatePayload().plateFreeFormat).toBe(true)
}) })
// ── Application d'une lecture de pesée ──────────────────────────────────── // ── Application d'une lecture de pesée ────────────────────────────────────
it('applyReading remplit poids / DSD / mode du bloc visé', () => { it('applyReading remplit poids / DSD / mode et ré-horodate le bloc à l\'instant de la pee', () => {
const form = useWeighingTicketForm() 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' }) 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.weight).toBe(7150)
expect(form.empty.dsd).toBe(1) expect(form.empty.dsd).toBe(1)
expect(form.empty.mode).toBe('AUTO') expect(form.empty.mode).toBe('AUTO')
expect(form.empty.manualNumber).toBeNull()
form.applyReading(form.full, { weight: 14300, dsd: 2, mode: 'MANUAL', manualNumber: 'PAP-555' }) // Pesée manuelle : le DSD saisi (16619) est conservé tel quel (ERP-193).
form.applyReading(form.full, { weight: 14300, dsd: 16619, mode: 'MANUAL' })
expect(form.full.weight).toBe(14300) expect(form.full.weight).toBe(14300)
expect(form.full.manualNumber).toBe('PAP-555') expect(form.full.dsd).toBe(16619)
expect(form.full.mode).toBe('MANUAL')
}) })
it('buildCreatePayload porte la pesée à vide, buildFullPayload la pesée à plein', () => { it('buildDraftPayload porte les pesées effectuées ; buildValidatePayload les 4 champs du haut', () => {
const form = useWeighingTicketForm() const form = useWeighingTicketForm()
form.setCounterpartyType('CLIENT') form.setCounterpartyType('CLIENT')
form.clientIri.value = '/api/clients/1' 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.empty, { weight: 7150, dsd: 1, mode: 'AUTO' })
form.applyReading(form.full, { weight: 14300, dsd: 2, mode: 'AUTO' }) form.applyReading(form.full, { weight: 14300, dsd: 2, mode: 'AUTO' })
const create = form.buildCreatePayload() // Le brouillon porte LES DEUX pesées effectuées.
expect(create.emptyWeight).toBe(7150) const draft = form.buildDraftPayload()
expect(create.emptyDsd).toBe(1) expect(draft.emptyWeight).toBe(7150)
expect(create.emptyMode).toBe('AUTO') expect(draft.emptyMode).toBe('AUTO')
expect(create).not.toHaveProperty('fullWeight') expect(draft.fullWeight).toBe(14300)
expect(draft.fullMode).toBe('AUTO')
const full = form.buildFullPayload() // La validation ne porte que les 4 champs du haut (pesées déjà persistées).
expect(full.fullWeight).toBe(14300) const validate = form.buildValidatePayload()
expect(full.fullDsd).toBe(2) expect(validate.counterpartyType).toBe('CLIENT')
expect(full.fullMode).toBe('AUTO') expect(validate.client).toBe('/api/clients/1')
expect(validate.immatriculation).toBe('AB-123-CD')
expect(validate).not.toHaveProperty('emptyWeight')
expect(validate).not.toHaveProperty('fullWeight')
}) })
// ── Pré-remplissage (écran Modification, ERP-190) ───────────────────────── // ── Pré-remplissage (écran Modification, ERP-190) ─────────────────────────
it('hydrate pré-remplit l\'état depuis le détail (dates ISO ramenées à YYYY-MM-DD)', () => { it('hydrate pré-remplit l\'état depuis le détail (datetime ISO ramené en local, heure conservée)', () => {
const form = useWeighingTicketForm() const form = useWeighingTicketForm()
form.hydrate({ form.hydrate({
id: 9, id: 9,
@@ -127,9 +162,9 @@ describe('useWeighingTicketForm', () => {
expect(form.counterpartyField.value).toBe('client') expect(form.counterpartyField.value).toBe('client')
expect(form.clientIri.value).toBe('/api/clients/629') expect(form.clientIri.value).toBe('/api/clients/629')
expect(form.immatriculation.value).toBe('AB-123-CD') expect(form.immatriculation.value).toBe('AB-123-CD')
// Date datetime back -> date seule pour MalioDate. // Datetime back (avec fuseau) -> local sans fuseau, heure conservée pour MalioDateTime.
expect(form.empty.date).toBe('2026-06-17') expect(form.empty.date).toBe('2026-06-17T09:00:00')
expect(form.full.date).toBe('2026-06-17') expect(form.full.date).toBe('2026-06-17T09:12:00')
expect(form.empty.weight).toBe(7150) expect(form.empty.weight).toBe(7150)
expect(form.full.weight).toBe(14300) expect(form.full.weight).toBe(14300)
}) })
@@ -140,15 +175,16 @@ describe('useWeighingTicketForm', () => {
expect(form.otherLabel.value).toBe('Reprise') expect(form.otherLabel.value).toBe('Reprise')
expect(form.supplierIri.value).toBeNull() expect(form.supplierIri.value).toBeNull()
expect(form.plateFreeFormat.value).toBe(false) expect(form.plateFreeFormat.value).toBe(false)
// Pas de date back -> repli sur le jour (stub 2026-06-22). // Pas de date back -> repli sur l'instant courant (stub 2026-06-22T08:30:00).
expect(form.empty.date).toBe('2026-06-22') expect(form.empty.date).toBe('2026-06-22T08:30:00')
expect(form.empty.weight).toBeNull() expect(form.empty.weight).toBeNull()
}) })
it('buildUpdatePayload fusionne contrepartie + véhicule + les 2 pesées', () => { it('buildDraftPayload après hydrate porte contrepartie + véhicule + les 2 pesées', () => {
const form = useWeighingTicketForm() const form = useWeighingTicketForm()
form.hydrate({ form.hydrate({
id: 9, id: 9,
status: 'VALIDATED',
counterpartyType: 'CLIENT', counterpartyType: 'CLIENT',
client: { '@id': '/api/clients/629' }, client: { '@id': '/api/clients/629' },
immatriculation: 'AB-123-CD', immatriculation: 'AB-123-CD',
@@ -156,7 +192,9 @@ describe('useWeighingTicketForm', () => {
fullWeight: 14300, fullDsd: 2, fullMode: 'AUTO', fullWeight: 14300, fullDsd: 2, fullMode: 'AUTO',
}) })
const payload = form.buildUpdatePayload() expect(form.status.value).toBe('VALIDATED')
const payload = form.buildDraftPayload()
expect(payload.counterpartyType).toBe('CLIENT') expect(payload.counterpartyType).toBe('CLIENT')
expect(payload.client).toBe('/api/clients/629') expect(payload.client).toBe('/api/clients/629')
expect(payload.emptyWeight).toBe(7150) expect(payload.emptyWeight).toBe(7150)
@@ -7,8 +7,8 @@
* - AUTO (« Pesée bascule ») : le serveur résout le site courant, lit le poids * - 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 * (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. * pont est indisponible, on invite l'utilisateur à passer en pesée manuelle.
* - MANUAL (« Pesée manuelle ») : poids + numéro de pesée saisis ; le serveur * - MANUAL (« Pesée manuelle ») : poids + DSD saisis par l'opérateur ; le serveur
* calcule le DSD = dernier + 1 (RG-5.04). * les conserve tels quels — plus d'auto-incrément (ERP-193).
* *
* Composable UI-agnostique : il appelle l'API (`useApi`, jamais `$fetch`) et * 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 * renvoie la lecture, ou lève l'erreur — la gestion de la modal/de l'affichage
@@ -24,8 +24,6 @@ export interface WeighbridgeReading {
weight: number weight: number
dsd: number dsd: number
mode: WeighbridgeMode mode: WeighbridgeMode
/** Numéro de pesée saisi en mode MANUAL (absent en AUTO). */
manualNumber?: string
} }
export function useWeighbridge() { export function useWeighbridge() {
@@ -46,13 +44,13 @@ export function useWeighbridge() {
} }
/** /**
* Pesée manuelle (MANUAL). Le DSD est calculé serveur (dernier + 1, RG-5.04) ; * Pesée manuelle (MANUAL). Le poids ET le DSD sont saisis par l'opérateur (le
* le `manualNumber` est la référence du ticket papier / autre bascule. * DSD = numéro du pont réellement utilisé) et conservés tels quels (ERP-193).
*/ */
async function triggerManual(weight: number, manualNumber: string): Promise<WeighbridgeReading> { async function triggerManual(weight: number, dsd: number): Promise<WeighbridgeReading> {
return await api.post<WeighbridgeReading>( return await api.post<WeighbridgeReading>(
'/weighbridge_readings', '/weighbridge_readings',
{ mode: 'MANUAL', weight, manualNumber }, { mode: 'MANUAL', weight, dsd },
{ toast: false }, { toast: false },
) )
} }
@@ -1,5 +1,5 @@
import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge' import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge'
import type { CounterpartyType } from '~/modules/logistique/composables/useWeighingTicketForm' import type { CounterpartyType, WeighingTicketStatus } from '~/modules/logistique/composables/useWeighingTicketForm'
/** /**
* Détail d'un ticket de pesée (`GET /api/weighing_tickets/{id}`, spec-back * Détail d'un ticket de pesée (`GET /api/weighing_tickets/{id}`, spec-back
@@ -8,11 +8,13 @@ import type { CounterpartyType } from '~/modules/logistique/composables/useWeigh
*/ */
export interface WeighingTicketDetail { export interface WeighingTicketDetail {
id: number id: number
/** Numéro `{siteCode}-TP-{NNNN}` — immuable (RG-5.09). */ /** Cycle de vie (DRAFT/VALIDATED, ERP-193). */
number: string status?: WeighingTicketStatus
/** Numéro `{siteCode}-TP-{NNNN}` — null tant que brouillon, immuable ensuite (RG-5.09). */
number?: string | null
/** Site rattaché (embarqué) — immuable (RG-5.09). */ /** Site rattaché (embarqué) — immuable (RG-5.09). */
site?: { id: number, name: string, code: string } | null site?: { id: number, name: string, code: string } | null
counterpartyType: CounterpartyType counterpartyType?: CounterpartyType | null
client?: { '@id': string, companyName: string } | null client?: { '@id': string, companyName: string } | null
supplier?: { '@id': string, companyName: string } | null supplier?: { '@id': string, companyName: string } | null
otherLabel?: string | null otherLabel?: string | null
@@ -23,13 +25,11 @@ export interface WeighingTicketDetail {
emptyWeight?: number | null emptyWeight?: number | null
emptyDsd?: number | null emptyDsd?: number | null
emptyMode?: WeighbridgeMode | null emptyMode?: WeighbridgeMode | null
emptyManualNumber?: string | null
// Pesée à plein // Pesée à plein
fullDate?: string | null fullDate?: string | null
fullWeight?: number | null fullWeight?: number | null
fullDsd?: number | null fullDsd?: number | null
fullMode?: WeighbridgeMode | null fullMode?: WeighbridgeMode | null
fullManualNumber?: string | null
netWeight?: number | null netWeight?: number | null
} }
@@ -1,5 +1,5 @@
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref } from 'vue'
import { todayIso } from '~/shared/utils/date' import { nowIsoDateTime } from '~/shared/utils/date'
import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge' import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge'
/** /**
@@ -11,12 +11,13 @@ import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighb
* - **Contrepartie conditionnelle (RG-5.03)** : `counterpartyType` (CLIENT / * - **Contrepartie conditionnelle (RG-5.03)** : `counterpartyType` (CLIENT /
* FOURNISSEUR / AUTRE) pilote le champ requis (client / supplier / otherLabel). * FOURNISSEUR / AUTRE) pilote le champ requis (client / supplier / otherLabel).
* Changer de type purge les champs des autres types — aucune donnée fantôme. * Changer de type purge les champs des autres types — aucune donnée fantôme.
* - **Immatriculation + « Tout format » partagés entre les 2 blocs (RG-5.01)** : * - **Immatriculation + « Tout format »** font partie des 4 champs du haut, hors
* une seule valeur (refs uniques) — modifier l'un met à jour l'autre puisque * blocs (ERP-193). Une seule valeur, partagée entre les 2 pesées (RG-5.01).
* les 2 blocs bindent la même ref. * - **Cycle brouillon -> validé (ERP-193)** : `buildDraftPayload()` persiste l'état
* - **Workflow 2 temps** : `buildCreatePayload()` (POST à l'« Enregistrer » du * courant (pesée enregistrée dès la validation de sa modale, même sans
* bloc vide) crée le ticket avec la pesée à vide ; `buildFullPayload()` (PATCH * contrepartie/immat) via POST (création du brouillon) puis PATCH ; quand les 3
* au « Valider ») ajoute la pesée à plein (net recalculé serveur, RG-5.05). * champs du haut + les 2 pesées sont là, `buildValidatePayload()` finalise via
* `PATCH /weighing_tickets/{id}/validate` (numéro attribué, status VALIDATED).
* *
* Composable UI-agnostique et testable : aucune dépendance API ici (les appels * Composable UI-agnostique et testable : aucune dépendance API ici (les appels
* vivent dans l'écran via `useApi`). Instancié PAR écran (refs locales). * vivent dans l'écran via `useApi`). Instancié PAR écran (refs locales).
@@ -27,22 +28,24 @@ export type CounterpartyType = 'CLIENT' | 'FOURNISSEUR' | 'AUTRE'
/** Saisie d'une pesée (bloc vide OU bloc plein). */ /** Saisie d'une pesée (bloc vide OU bloc plein). */
export interface WeighingBlockState { export interface WeighingBlockState {
/** Date de la pesée (ISO `YYYY-MM-DD`) — jour par défaut (RG-5.07). */ /** Date/heure de la pesée (ISO local `YYYY-MM-DDTHH:mm:ss`) — date du jour + heure courante par défaut (RG-5.07). */
date: string | null date: string | null
/** Poids en kg — readonly, rempli par la pesée (bascule ou manuelle). */ /** Poids en kg — readonly, rempli par la pesée (bascule ou manuelle). */
weight: number | null weight: number | null
/** DSD — readonly, rempli par la pesée (RG-5.04). */ /** DSD — pesée bascule : fourni par le pont ; pesée manuelle : saisi (RG-5.04, ERP-193). */
dsd: number | null dsd: number | null
/** Mode de la dernière pesée appliquée au bloc. */ /** Mode de la dernière pesée appliquée au bloc. */
mode: WeighbridgeMode | null 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). */ /** Forme minimale d'un détail de ticket consommée par `hydrate` (cf. useWeighingTicket). */
export interface WeighingTicketHydration { export interface WeighingTicketHydration {
id: number id: number
counterpartyType: CounterpartyType status?: WeighingTicketStatus
counterpartyType?: CounterpartyType | null
client?: { '@id': string } | null client?: { '@id': string } | null
supplier?: { '@id': string } | null supplier?: { '@id': string } | null
otherLabel?: string | null otherLabel?: string | null
@@ -52,32 +55,47 @@ export interface WeighingTicketHydration {
emptyWeight?: number | null emptyWeight?: number | null
emptyDsd?: number | null emptyDsd?: number | null
emptyMode?: WeighbridgeMode | null emptyMode?: WeighbridgeMode | null
emptyManualNumber?: string | null
fullDate?: string | null fullDate?: string | null
fullWeight?: number | null fullWeight?: number | null
fullDsd?: number | null fullDsd?: number | null
fullMode?: WeighbridgeMode | null fullMode?: WeighbridgeMode | null
fullManualNumber?: string | 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 { * Ramène une chaîne ISO datetime du back (`2026-06-17T09:00:00+02:00`) au format
return value ? value.slice(0, 10) : null * 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
} }
/** Crée l'état initial d'un bloc de pesée (date = aujourd'hui, RG-5.07). */ /**
function emptyBlock(today: string): WeighingBlockState { * Retire les clés à valeur `null` d'un payload (pattern « omission des requis
* vides » M1). Avec `collectDenormalizationErrors` côté back, envoyer `null` sur
* un scalaire requis (ex. `counterpartyType`) produit une violation de TYPE
* opaque (« Cette valeur doit être de type string. ») au lieu du message métier
* `NotBlank` : une clé ABSENTE laisse au contraire jouer la contrainte `NotBlank`
* et son message FR. On omet donc les null ; les champs réellement requis non
* remplis déclenchent leur vrai message, les optionnels restent simplement absents.
*/
function compact(payload: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== null))
}
/** Crée l'état initial d'un bloc de pesée (date/heure = maintenant, RG-5.07). */
function emptyBlock(now: string): WeighingBlockState {
return { return {
date: today, date: now,
weight: null, weight: null,
dsd: null, dsd: null,
mode: null, mode: null,
manualNumber: null,
} }
} }
export function useWeighingTicketForm() { export function useWeighingTicketForm() {
const today = todayIso() const now = nowIsoDateTime()
// ── Contrepartie (RG-5.03) ─────────────────────────────────────────────── // ── Contrepartie (RG-5.03) ───────────────────────────────────────────────
const counterpartyType = ref<CounterpartyType | null>(null) const counterpartyType = ref<CounterpartyType | null>(null)
@@ -103,12 +121,16 @@ export function useWeighingTicketForm() {
const plateFreeFormat = ref<boolean>(false) const plateFreeFormat = ref<boolean>(false)
// ── Les deux pesées ─────────────────────────────────────────────────────── // ── Les deux pesées ───────────────────────────────────────────────────────
const empty = reactive<WeighingBlockState>(emptyBlock(today)) const empty = reactive<WeighingBlockState>(emptyBlock(now))
const full = reactive<WeighingBlockState>(emptyBlock(today)) const full = reactive<WeighingBlockState>(emptyBlock(now))
// Id du ticket créé (POST du bloc vide) — pilote le PATCH du bloc plein. // Id du ticket persisté (POST du 1er enregistrement de pesée) — pilote ensuite
// les PATCH (brouillon) puis la validation. Null tant que rien n'est persisté.
const ticketId = ref<number | null>(null) 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 * Champ de contrepartie attendu selon le type courant — utilisé par l'écran
* pour afficher conditionnellement le bon champ (RG-5.03). * pour afficher conditionnellement le bon champ (RG-5.03).
@@ -122,15 +144,35 @@ export function useWeighingTicketForm() {
} }
}) })
/** Applique une lecture de pesée (bascule/manuelle) à un bloc. */ /**
* Champs de pesée manquants d'un bloc (Poids / DSD), RG-5.07. Le back rend ces
* colonnes nullable (workflow 2 temps) : l'obligation « une pesée a été
* effectuée » est donc portée côté front (règle front-only, ERP-101). Renvoie
* les `propertyPath` manquants (ex. `['emptyWeight', 'emptyDsd']`), prêts à
* être posés en erreur inline via `useFormErrors.setError`.
*/
function missingWeighingFields(which: 'empty' | 'full'): string[] {
const block = which === 'empty' ? empty : full
const missing: string[] = []
if (block.weight === null) missing.push(`${which}Weight`)
if (block.dsd === null) missing.push(`${which}Dsd`)
return missing
}
/**
* Applique une lecture de pesée (bascule/manuelle) à un bloc. La pesée étant
* effectuée À CET INSTANT, on (ré)horodate le bloc à maintenant : la date/heure
* du ticket reflète le moment réel de la pesée validée, pas l'ouverture du
* formulaire (RG-5.07).
*/
function applyReading( function applyReading(
block: WeighingBlockState, block: WeighingBlockState,
reading: { weight: number, dsd: number, mode: WeighbridgeMode, manualNumber?: string }, reading: { weight: number, dsd: number, mode: WeighbridgeMode },
): void { ): void {
block.date = nowIsoDateTime()
block.weight = reading.weight block.weight = reading.weight
block.dsd = reading.dsd block.dsd = reading.dsd
block.mode = reading.mode block.mode = reading.mode
block.manualNumber = reading.manualNumber ?? null
} }
/** Partie « contrepartie » du payload (FK en IRI ou libellé libre). */ /** Partie « contrepartie » du payload (FK en IRI ou libellé libre). */
@@ -144,33 +186,47 @@ export function useWeighingTicketForm() {
} }
/** /**
* Payload de CRÉATION (POST /weighing_tickets, spec-back § 4.3) : contrepartie * Champs d'un bloc de pesée, UNIQUEMENT s'il a été pesé (poids renseigné) — on
* + véhicule + pesée à VIDE. Le numéro, le site et le net sont attribués * n'envoie pas la date par défaut d'un bloc vierge (sinon le back stockerait une
* serveur (rien à envoyer). Les noms de champs miroir des `propertyPath` back * date de pesée sans poids). Noms de clés alignés sur les `propertyPath` back.
* pour que `useFormErrors` mappe les 422 inline.
*/ */
function buildCreatePayload(): Record<string, unknown> { function blockPayload(prefix: 'empty' | 'full', block: WeighingBlockState): Record<string, unknown> {
if (block.weight === null) return {}
return { return {
[`${prefix}Date`]: block.date,
[`${prefix}Weight`]: block.weight,
[`${prefix}Dsd`]: block.dsd,
[`${prefix}Mode`]: block.mode,
}
}
/**
* Payload de BROUILLON (POST création / PATCH mise à jour, ERP-193) : l'état
* courant complet (4 champs du haut + pesées effectuées). Aucun champ n'est
* requis ici (le back valide en mode relâché) — une pesée s'enregistre sans
* contrepartie ni immatriculation. Numéro/site/net attribués serveur.
*/
function buildDraftPayload(): Record<string, unknown> {
return compact({
counterpartyType: counterpartyType.value, counterpartyType: counterpartyType.value,
...counterpartyPayload(), ...counterpartyPayload(),
immatriculation: immatriculation.value || null, immatriculation: immatriculation.value || null,
plateFreeFormat: plateFreeFormat.value, plateFreeFormat: plateFreeFormat.value,
emptyDate: empty.date || null, ...blockPayload('empty', empty),
emptyWeight: empty.weight, ...blockPayload('full', full),
emptyDsd: empty.dsd, })
emptyMode: empty.mode,
emptyManualNumber: empty.manualNumber || null,
}
} }
/** /**
* Pré-remplit le formulaire à partir du détail d'un ticket existant (écran * 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) → * 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). * non repris dans l'état éditable (affichés en lecture seule par l'écran).
* Les dates ISO du back (datetime) sont ramenées à `YYYY-MM-DD` pour MalioDate. * Les dates ISO du back (datetime + fuseau) sont ramenées au format local
* `YYYY-MM-DDTHH:mm:ss` attendu par MalioDateTime (heure conservée).
*/ */
function hydrate(detail: WeighingTicketHydration): void { function hydrate(detail: WeighingTicketHydration): void {
ticketId.value = detail.id ticketId.value = detail.id
status.value = detail.status ?? 'DRAFT'
counterpartyType.value = detail.counterpartyType ?? null counterpartyType.value = detail.counterpartyType ?? null
clientIri.value = detail.client?.['@id'] ?? null clientIri.value = detail.client?.['@id'] ?? null
supplierIri.value = detail.supplier?.['@id'] ?? null supplierIri.value = detail.supplier?.['@id'] ?? null
@@ -178,45 +234,31 @@ export function useWeighingTicketForm() {
immatriculation.value = detail.immatriculation ?? null immatriculation.value = detail.immatriculation ?? null
plateFreeFormat.value = detail.plateFreeFormat ?? false plateFreeFormat.value = detail.plateFreeFormat ?? false
empty.date = isoDateOnly(detail.emptyDate) ?? today empty.date = toLocalIsoDateTime(detail.emptyDate) ?? now
empty.weight = detail.emptyWeight ?? null empty.weight = detail.emptyWeight ?? null
empty.dsd = detail.emptyDsd ?? null empty.dsd = detail.emptyDsd ?? null
empty.mode = detail.emptyMode ?? null empty.mode = detail.emptyMode ?? null
empty.manualNumber = detail.emptyManualNumber ?? null
full.date = isoDateOnly(detail.fullDate) ?? today full.date = toLocalIsoDateTime(detail.fullDate) ?? now
full.weight = detail.fullWeight ?? null full.weight = detail.fullWeight ?? null
full.dsd = detail.fullDsd ?? null full.dsd = detail.fullDsd ?? null
full.mode = detail.fullMode ?? null full.mode = detail.fullMode ?? null
full.manualNumber = detail.fullManualNumber ?? null
} }
/** /**
* Payload de MODIFICATION (PATCH /weighing_tickets/{id}, ERP-190) : tous les * Payload de VALIDATION (PATCH /weighing_tickets/{id}/validate, ERP-193) : les
* champs éditables (contrepartie + véhicule + les 2 pesées). Le numéro et le * 4 champs du haut (contrepartie + immatriculation + « Tout format »). Les pesées
* site sont immuables (RG-5.09, ignorés par le back même si envoyés). Le net * sont déjà persistées par les enregistrements brouillon ; le back rejoue ici la
* est recalculé serveur (RG-5.05). * validation stricte (groupe `finalize` : 3 champs requis + 2 pesées) et attribue
* le numéro. Les `propertyPath` des 422 sont mappés inline par useFormErrors.
*/ */
function buildUpdatePayload(): Record<string, unknown> { function buildValidatePayload(): Record<string, unknown> {
return { ...buildCreatePayload(), ...buildFullPayload() } return compact({
} counterpartyType: counterpartyType.value,
...counterpartyPayload(),
/**
* 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, immatriculation: immatriculation.value || null,
plateFreeFormat: plateFreeFormat.value, plateFreeFormat: plateFreeFormat.value,
fullDate: full.date || null, })
fullWeight: full.weight,
fullDsd: full.dsd,
fullMode: full.mode,
fullManualNumber: full.manualNumber || null,
}
} }
return { return {
@@ -234,11 +276,12 @@ export function useWeighingTicketForm() {
empty, empty,
full, full,
applyReading, applyReading,
missingWeighingFields,
// workflow // workflow
ticketId, ticketId,
status,
hydrate, hydrate,
buildCreatePayload, buildDraftPayload,
buildFullPayload, buildValidatePayload,
buildUpdatePayload,
} }
} }
@@ -1,4 +1,5 @@
import { usePaginatedList } from '~/shared/composables/usePaginatedList' 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 * Vue MINIMALE d'une contrepartie embarquee (Client M1 ou Fournisseur M2) dans la
@@ -25,8 +26,10 @@ export interface WeighingTicketParty {
*/ */
export interface WeighingTicket { export interface WeighingTicket {
id: number id: number
/** Numero metier `{siteCode}-TP-{NNNN}` attribue par site (RG-5.02). */ /** Cycle de vie : DRAFT (« En attente ») ou VALIDATED (« Terminée ») — ERP-193. */
number: string status: WeighingTicketStatus
/** Numero metier `{siteCode}-TP-{NNNN}` — null tant que brouillon (RG-5.02). */
number: string | null
/** Embarque uniquement si contrepartie = Client (RG-5.03), sinon absent. */ /** Embarque uniquement si contrepartie = Client (RG-5.03), sinon absent. */
client: WeighingTicketParty | null client: WeighingTicketParty | null
/** Embarque uniquement si contrepartie = Fournisseur (RG-5.03), sinon absent. */ /** Embarque uniquement si contrepartie = Fournisseur (RG-5.03), sinon absent. */
@@ -27,7 +27,7 @@ vi.stubGlobal('useRoute', () => ({ params: { id: '9' } }))
vi.stubGlobal('useRouter', () => ({ push: mockPush })) vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('usePermissions', () => ({ can: () => true })) vi.stubGlobal('usePermissions', () => ({ can: () => true }))
vi.stubGlobal('navigateTo', vi.fn()) vi.stubGlobal('navigateTo', vi.fn())
vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), clearErrors: vi.fn(), handleApiError: vi.fn() })) vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), setError: vi.fn(), clearErrors: vi.fn(), handleApiError: vi.fn() }))
globalThis.open = mockOpen globalThis.open = mockOpen
const EditPage = (await import('../weighing-tickets/[id]/edit.vue')).default const EditPage = (await import('../weighing-tickets/[id]/edit.vue')).default
@@ -48,9 +48,10 @@ const InputStub = defineComponent({
}, },
}) })
// WeighingBlock stubbe : rend le slot counterparty (présent sur le bloc vide). // WeighingBlock stubbé (Date/Poids/DSD + boutons) — la contrepartie vit désormais
// dans les 4 champs du haut, hors bloc (ERP-193).
const BlockStub = defineComponent({ const BlockStub = defineComponent({
setup(_, { slots }) { return () => h('div', { 'data-testid': 'block' }, slots.counterparty?.()) }, setup() { return () => h('div', { 'data-testid': 'block' }) },
}) })
const ModalStub = defineComponent({ const ModalStub = defineComponent({
@@ -64,7 +65,7 @@ const stubs = {
MalioInputText: InputStub, MalioInputText: InputStub,
MalioInputNumber: InputStub, MalioInputNumber: InputStub,
MalioSelect: InputStub, MalioSelect: InputStub,
MalioDate: InputStub, MalioDateTime: InputStub,
MalioCheckbox: InputStub, MalioCheckbox: InputStub,
MalioModal: ModalStub, MalioModal: ModalStub,
WeighingBlock: BlockStub, WeighingBlock: BlockStub,
@@ -82,6 +83,7 @@ async function mountPage() {
const DETAIL = { const DETAIL = {
id: 9, id: 9,
status: 'VALIDATED',
number: '86-TP-0001', number: '86-TP-0001',
site: { id: 1, name: 'Chatellerault', code: '86' }, site: { id: 1, name: 'Chatellerault', code: '86' },
counterpartyType: 'CLIENT', counterpartyType: 'CLIENT',
@@ -100,18 +102,16 @@ describe('Écran Modification ticket de pesée (page /weighing-tickets/{id}/edit
mockOpen.mockReset() mockOpen.mockReset()
}) })
it('pré-remplit le numéro et le site en lecture seule (RG-5.09)', async () => { it('charge le ticket au montage (pré-remplissage via hydrate)', async () => {
const wrapper = await mountPage() await mountPage()
expect(mockFetchTicket).toHaveBeenCalledWith('9') 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('bascule des boutons : « Enregistrer » + « Imprimer » présents, pas de « Valider »', async () => { it('ticket validé : action principale « Enregistrer » + « Imprimer » (pas « Valider »)', async () => {
const wrapper = await mountPage() 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.save"]').exists()).toBe(true)
expect(wrapper.find('[data-label="logistique.weighingTickets.form.print"]').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) expect(wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').exists()).toBe(false)
}) })
@@ -121,15 +121,24 @@ describe('Écran Modification ticket de pesée (page /weighing-tickets/{id}/edit
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/9/print.pdf', '_blank') expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/9/print.pdf', '_blank')
}) })
it('« Enregistrer » PATCH le ticket puis revient à la liste', async () => { it('« Enregistrer » : PATCH brouillon puis PATCH /validate, retour à la liste', async () => {
const wrapper = await mountPage() const wrapper = await mountPage()
await wrapper.find('[data-label="logistique.weighingTickets.form.save"]').trigger('click') await wrapper.find('[data-label="logistique.weighingTickets.form.save"]').trigger('click')
await flushPromises() await flushPromises()
// 1. Persistance de l'état courant (brouillon) avec les 2 pesées.
expect(mockPatch).toHaveBeenCalledWith( expect(mockPatch).toHaveBeenCalledWith(
'/weighing_tickets/9', '/weighing_tickets/9',
expect.objectContaining({ counterpartyType: 'CLIENT', client: '/api/clients/629', fullWeight: 14300 }), expect.objectContaining({ counterpartyType: 'CLIENT', client: '/api/clients/629', fullWeight: 14300 }),
expect.objectContaining({ toast: false }), 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') expect(mockPush).toHaveBeenCalledWith('/weighing-tickets')
}) })
}) })
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, ref, reactive, Suspense } from 'vue'
// ── Mocks des composables modules (le form RÉEL est conservé). ────────────────
const mockPost = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
const mockPush = vi.hoisted(() => vi.fn())
const mockOpen = vi.hoisted(() => vi.fn())
vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({
useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: vi.fn().mockResolvedValue(undefined) }),
}))
vi.mock('~/modules/logistique/composables/useWeighbridge', () => ({
useWeighbridge: () => ({ triggerAuto: vi.fn(), triggerManual: vi.fn(), extractWeighbridgeError: () => 'err' }),
}))
// ── Auto-imports Nuxt stubbés globalement ───────────────────────────────────
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useHead', () => undefined)
vi.stubGlobal('useApi', () => ({ get: vi.fn(), post: mockPost, patch: mockPatch }))
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('usePermissions', () => ({ can: () => true }))
vi.stubGlobal('navigateTo', vi.fn())
vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), setError: vi.fn(), clearErrors: vi.fn(), handleApiError: vi.fn() }))
globalThis.open = mockOpen
const NewPage = (await import('../weighing-tickets/new.vue')).default
const ButtonStub = defineComponent({
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
emits: ['click'],
setup(props, { emit }) {
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
},
})
const InputStub = defineComponent({
props: { label: { type: String, default: '' }, modelValue: { default: null } },
setup(props) { return () => h('input', { 'data-label': props.label, 'value': props.modelValue as string }) },
})
const BlockStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'block' }) } })
const ModalStub = defineComponent({
props: { modelValue: { type: Boolean, default: false } },
setup(_, { slots }) { return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()]) },
})
const stubs = {
MalioButtonIcon: ButtonStub,
MalioButton: ButtonStub,
MalioInputText: InputStub,
MalioSelect: InputStub,
MalioDateTime: InputStub,
MalioCheckbox: InputStub,
MalioModal: ModalStub,
WeighingBlock: BlockStub,
}
async function mountPage() {
const wrapper = mount(defineComponent({
components: { NewPage },
setup: () => () => h(Suspense, null, { default: () => h(NewPage) }),
}), { global: { stubs } })
await flushPromises()
return wrapper
}
describe('Écran Ajouter ticket de pesée (page /weighing-tickets/new)', () => {
beforeEach(() => {
mockPost.mockReset().mockResolvedValue({ id: 42 })
mockPatch.mockReset().mockResolvedValue({})
mockPush.mockReset()
mockOpen.mockReset()
})
it('un seul bouton « Valider » (pas de « Enregistrer » séparé)', async () => {
const wrapper = await mountPage()
expect(wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').exists()).toBe(true)
expect(wrapper.find('[data-label="logistique.weighingTickets.form.save"]').exists()).toBe(false)
})
it('« Valider » : POST brouillon (création) puis PATCH /validate, PDF + retour liste', async () => {
const wrapper = await mountPage()
await wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').trigger('click')
await flushPromises()
// 1. Création du brouillon (POST) → récupère l'id.
expect(mockPost).toHaveBeenCalledWith(
'/weighing_tickets',
expect.any(Object),
expect.objectContaining({ toast: false }),
)
// 2. Validation (back autoritaire) sur l'id retourné.
expect(mockPatch).toHaveBeenCalledWith(
'/weighing_tickets/42/validate',
expect.any(Object),
expect.objectContaining({ toast: false }),
)
// 3. Ouverture du bon de pesée PDF + retour à la liste.
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/42/print.pdf', '_blank')
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets')
})
})
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils' import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { defineComponent, h, ref } from 'vue' import { defineComponent, h, ref } from 'vue'
// ── Auto-imports Nuxt stubbes globalement ─────────────────────────────────── // ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
@@ -9,6 +9,7 @@ const mockPush = vi.hoisted(() => vi.fn())
const mockApiGet = vi.hoisted(() => vi.fn()) const mockApiGet = vi.hoisted(() => vi.fn())
const mockCan = vi.hoisted(() => vi.fn()) const mockCan = vi.hoisted(() => vi.fn())
const mockFetch = vi.hoisted(() => vi.fn()) const mockFetch = vi.hoisted(() => vi.fn())
const mockReset = vi.hoisted(() => vi.fn())
const mockToastError = vi.hoisted(() => vi.fn()) const mockToastError = vi.hoisted(() => vi.fn())
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
@@ -17,6 +18,9 @@ vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
vi.stubGlobal('useRouter', () => ({ push: mockPush })) vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() })) vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
vi.stubGlobal('usePermissions', () => ({ can: mockCan })) 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. // Le repository est lui aussi un auto-import : on controle les items renvoyes.
// Contrepartie CLIENT (RG-5.03) → supplier / otherLabel absents (skip_null_values). // Contrepartie CLIENT (RG-5.03) → supplier / otherLabel absents (skip_null_values).
@@ -40,6 +44,7 @@ vi.stubGlobal('useWeighingTicketsRepository', () => ({
goToPage: vi.fn(), goToPage: vi.fn(),
setItemsPerPage: vi.fn(), setItemsPerPage: vi.fn(),
setFilters: vi.fn(), setFilters: vi.fn(),
reset: mockReset,
})) }))
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques // happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
@@ -86,8 +91,13 @@ const PageHeaderStub = defineComponent({
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) }, 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() { function mountPage() {
return mount(WeighingTicketsIndex, { const wrapper = mount(WeighingTicketsIndex, {
global: { global: {
stubs: { stubs: {
PageHeader: PageHeaderStub, PageHeader: PageHeaderStub,
@@ -96,6 +106,8 @@ function mountPage() {
}, },
}, },
}) })
mountedWrappers.push(wrapper)
return wrapper
} }
describe('Liste des tickets de pesée (page /weighing-tickets)', () => { describe('Liste des tickets de pesée (page /weighing-tickets)', () => {
@@ -104,8 +116,17 @@ describe('Liste des tickets de pesée (page /weighing-tickets)', () => {
mockApiGet.mockReset().mockResolvedValue(new Blob()) mockApiGet.mockReset().mockResolvedValue(new Blob())
mockCan.mockReset().mockReturnValue(true) mockCan.mockReset().mockReturnValue(true)
mockFetch.mockReset() mockFetch.mockReset()
mockReset.mockReset()
mockToastError.mockReset() mockToastError.mockReset()
capturedRows.value = [] 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 () => { it('charge la liste au montage', async () => {
@@ -114,6 +135,17 @@ describe('Liste des tickets de pesée (page /weighing-tickets)', () => {
expect(mockFetch).toHaveBeenCalled() 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 () => { it('formate la date au format JJ-MM-AAAA', async () => {
const wrapper = mountPage() const wrapper = mountPage()
await flushPromises() await flushPromises()
@@ -18,38 +18,14 @@
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('logistique.weighingTickets.edit.notFound') }}</p> <p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('logistique.weighingTickets.edit.notFound') }}</p>
<template v-else> <template v-else>
<!-- Numéro + site : lecture seule, immuables (RG-5.09). --> <!-- Form à plat, pleine largeur (sans box-shadow) : un filet noir 1px
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4"> sépare chacun des 3 blocs (divide-y). -->
<MalioInputText <div class="mt-[48px] flex flex-col divide-y divide-black">
:model-value="ticketNumber" <!-- 4 champs du haut : contrepartie + immatriculation + « Tout
:label="t('logistique.weighingTickets.form.number')" format » (ERP-193, hors blocs de pesée). 1er bloc : pas de
:readonly="true" padding-top (marge titreform = mt-[48px] standard). -->
:disabled="true" <div class="pb-[20px]">
/> <div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<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 <MalioSelect
:model-value="form.counterpartyType.value" :model-value="form.counterpartyType.value"
:options="counterpartyOptions" :options="counterpartyOptions"
@@ -87,28 +63,56 @@
:error="errors.otherLabel" :error="errors.otherLabel"
@update:model-value="(v: string | null) => form.otherLabel.value = v" @update:model-value="(v: string | null) => form.otherLabel.value = v"
/> />
</template>
</WeighingBlock>
<!-- Bloc « Poids à plein » : le bouton « Enregistrer » du bloc vide <!-- Pas de cellule vide sans type sélectionné : immat et « Tout
DISPARAÎT en modification (RG-5.08) — on enregistre via le bas. --> format » se collent au type ; le champ conditionnel les
décale une fois un type choisi. -->
<MalioInputText
:model-value="form.immatriculation.value"
:mask="form.plateFreeFormat.value ? FREE_PLATE_MASK : PLATE_MASK"
:label="t('logistique.weighingTickets.form.immatriculation')"
:required="true"
:error="errors.immatriculation"
@update:model-value="(v: string | null) => form.immatriculation.value = v"
/>
<MalioCheckbox
id="plate-free-format"
:model-value="form.plateFreeFormat.value"
:label="t('logistique.weighingTickets.form.plateFreeFormat')"
group-class="self-center"
@update:model-value="(v: boolean) => form.plateFreeFormat.value = v"
/>
</div>
</div>
<!-- ── Bloc « Poids à vide » ──────────────────────────────────── -->
<WeighingBlock <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" block-id="full"
:title="t('logistique.weighingTickets.form.fullBlock')" :title="t('logistique.weighingTickets.form.fullBlock')"
:block="form.full" :block="form.full"
:immatriculation="form.immatriculation.value"
:plate-free-format="form.plateFreeFormat.value"
:errors="fullBlockErrors" :errors="fullBlockErrors"
@update:block="(field, value) => updateBlock('full', field, value)" @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-auto="openAuto('full')"
@request-manual="openManual('full')" @request-manual="openManual('full')"
/> />
</div> </div>
<!-- Bas d'écran : « Enregistrer » (remplace « Valider », RG-5.08) + <!-- Bas d'écran : « Imprimer » (ouvre le PDF back) + action principale
« Imprimer » (absent à l'ajout, RG-5.08). --> (« Valider » si brouillon, « Enregistrer » si déjà validé). -->
<div class="mt-12 flex justify-center gap-6"> <div class="mt-12 flex justify-center gap-6">
<MalioButton <MalioButton
variant="secondary" variant="secondary"
@@ -119,30 +123,22 @@
/> />
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('logistique.weighingTickets.form.save')" :label="primaryLabel"
:disabled="saving" :disabled="saving"
@click="submitSave" @click="submitPrimary"
/> />
</div> </div>
</template> </template>
<!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────--> <!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────-->
<MalioModal v-model="autoModal.open" modal-class="max-w-md"> <MalioModal v-model="autoModal.open" modal-class="max-w-md" footer-class="justify-center pb-6">
<template #header> <template #header>
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2> <h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
</template> </template>
<p>{{ t('logistique.weighingTickets.form.weighbridge.confirmMessage') }}</p> <p v-if="autoModal.error" class="text-m-danger">{{ autoModal.error }}</p>
<p v-if="autoModal.error" class="mt-4 text-m-danger">{{ autoModal.error }}</p>
<template #footer> <template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('logistique.weighingTickets.form.weighbridge.cancel')"
@click="autoModal.open = false"
/>
<MalioButton <MalioButton
variant="primary" variant="primary"
button-class="flex-1"
:label="t('logistique.weighingTickets.form.weighbridge.validate')" :label="t('logistique.weighingTickets.form.weighbridge.validate')"
:disabled="autoModal.loading" :disabled="autoModal.loading"
@click="confirmAuto" @click="confirmAuto"
@@ -151,35 +147,35 @@
</MalioModal> </MalioModal>
<!-- ── Modal « Pesée manuelle » ────────────────────────────────────────--> <!-- ── Modal « Pesée manuelle » ────────────────────────────────────────-->
<MalioModal v-model="manualModal.open" modal-class="max-w-md"> <MalioModal
v-model="manualModal.open"
modal-class="max-w-md"
header-class="mx-7 px-0 pt-6 pb-3 border-b border-black"
body-class="px-7 pt-9"
footer-class="px-7 justify-center pb-6"
>
<template #header> <template #header>
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.manual.title') }}</h2> <h2 class="text-[24px] font-bold uppercase">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
</template> </template>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-2">
<MalioInputNumber <MalioInputText
v-model="manualModal.weight" v-model="manualModal.weight"
:mask="NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.manual.weight')" :label="t('logistique.weighingTickets.form.manual.weight')"
:required="true" :required="true"
:min="0"
:error="manualModal.errors.weight" :error="manualModal.errors.weight"
/> />
<MalioInputText <MalioInputText
v-model="manualModal.manualNumber" v-model="manualModal.dsd"
:label="t('logistique.weighingTickets.form.manual.number')" :mask="NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.manual.dsd')"
:required="true" :required="true"
:error="manualModal.errors.manualNumber" :error="manualModal.errors.dsd"
/> />
</div> </div>
<template #footer> <template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('logistique.weighingTickets.form.manual.cancel')"
@click="manualModal.open = false"
/>
<MalioButton <MalioButton
variant="primary" variant="primary"
button-class="flex-1"
:label="t('logistique.weighingTickets.form.manual.save')" :label="t('logistique.weighingTickets.form.manual.save')"
:disabled="manualModal.loading" :disabled="manualModal.loading"
@click="confirmManual" @click="confirmManual"
@@ -195,6 +191,7 @@ import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logist
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge' import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
import { useWeighingTicket } from '~/modules/logistique/composables/useWeighingTicket' import { useWeighingTicket } from '~/modules/logistique/composables/useWeighingTicket'
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials' import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
import { NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
const { t } = useI18n() const { t } = useI18n()
const api = useApi() const api = useApi()
@@ -219,9 +216,8 @@ const loading = ref(true)
const error = ref(false) const error = ref(false)
const saving = ref(false) const saving = ref(false)
// Numéro + site immuables (RG-5.09), affichés en lecture seule. // Numéro immuable (RG-5.09), rappelé dans le titre — vide tant que brouillon.
const ticketNumber = ref<string>('') const ticketNumber = ref<string>('')
const siteName = ref<string>('')
const headerTitle = computed(() => const headerTitle = computed(() =>
ticketNumber.value ticketNumber.value
@@ -229,6 +225,15 @@ const headerTitle = computed(() =>
: t('logistique.weighingTickets.edit.titleFallback'), : 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') }) useHead({ title: t('logistique.weighingTickets.edit.titleFallback') })
/** Retour vers la liste (flèche d'en-tête). */ /** Retour vers la liste (flèche d'en-tête). */
@@ -236,7 +241,7 @@ function goBack(): void {
router.push('/weighing-tickets') router.push('/weighing-tickets')
} }
// ── Contrepartie (RG-5.03) ─────────────────────────────────────────────────── // ── Contrepartie (RG-5.03) — ordre maquette : Fournisseur / Client / Autre. ───
const counterpartyOptions = computed<RefOption[]>(() => [ const counterpartyOptions = computed<RefOption[]>(() => [
{ value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') }, { value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') },
{ value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') }, { value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') },
@@ -253,13 +258,11 @@ const emptyBlockErrors = computed<Record<string, string>>(() => ({
date: errors.emptyDate, date: errors.emptyDate,
weight: errors.emptyWeight, weight: errors.emptyWeight,
dsd: errors.emptyDsd, dsd: errors.emptyDsd,
immatriculation: errors.immatriculation,
})) }))
const fullBlockErrors = computed<Record<string, string>>(() => ({ const fullBlockErrors = computed<Record<string, string>>(() => ({
date: errors.fullDate, date: errors.fullDate,
weight: errors.fullWeight, weight: errors.fullWeight,
dsd: errors.fullDsd, dsd: errors.fullDsd,
immatriculation: errors.immatriculation,
})) }))
/** Mute un champ d'un bloc de pesée (état centralisé dans le form). */ /** Mute un champ d'un bloc de pesée (état centralisé dans le form). */
@@ -281,6 +284,7 @@ function openAuto(target: 'empty' | 'full'): void {
autoModal.open = true autoModal.open = true
} }
/** Déclenche la pesée bascule puis enregistre le brouillon (ERP-193). */
async function confirmAuto(): Promise<void> { async function confirmAuto(): Promise<void> {
if (autoModal.loading) return if (autoModal.loading) return
autoModal.loading = true autoModal.loading = true
@@ -289,6 +293,7 @@ async function confirmAuto(): Promise<void> {
const reading = await weighbridge.triggerAuto() const reading = await weighbridge.triggerAuto()
form.applyReading(form[autoModal.target], reading) form.applyReading(form[autoModal.target], reading)
autoModal.open = false autoModal.open = false
await saveDraft()
} }
catch (e) { catch (e) {
autoModal.error = weighbridge.extractWeighbridgeError(e) autoModal.error = weighbridge.extractWeighbridgeError(e)
@@ -303,38 +308,40 @@ const manualModal = reactive({
open: false, open: false,
loading: false, loading: false,
target: 'empty' as 'empty' | 'full', target: 'empty' as 'empty' | 'full',
weight: null as string | number | null, weight: null as string | null,
manualNumber: null as string | null, dsd: null as string | null,
errors: {} as Record<string, string>, errors: {} as Record<string, string>,
}) })
function openManual(target: 'empty' | 'full'): void { function openManual(target: 'empty' | 'full'): void {
manualModal.target = target manualModal.target = target
manualModal.weight = null manualModal.weight = null
manualModal.manualNumber = null manualModal.dsd = null
manualModal.errors = {} manualModal.errors = {}
manualModal.open = true manualModal.open = true
} }
/** Valide la saisie manuelle (poids + DSD), remplit le bloc puis enregistre le brouillon. */
async function confirmManual(): Promise<void> { async function confirmManual(): Promise<void> {
if (manualModal.loading) return if (manualModal.loading) return
manualModal.errors = {} manualModal.errors = {}
const weight = manualModal.weight === null || manualModal.weight === '' ? null : Number(manualModal.weight) const weight = manualModal.weight === null || manualModal.weight === '' ? null : Number(manualModal.weight)
const manualNumber = (manualModal.manualNumber ?? '').trim() const dsd = manualModal.dsd === null || manualModal.dsd === '' ? null : Number(manualModal.dsd)
if (weight === null || Number.isNaN(weight)) { if (weight === null || Number.isNaN(weight)) {
manualModal.errors = { ...manualModal.errors, weight: t('logistique.weighingTickets.form.manual.weightRequired') } manualModal.errors = { ...manualModal.errors, weight: t('logistique.weighingTickets.form.manual.weightRequired') }
} }
if (manualNumber === '') { if (dsd === null || Number.isNaN(dsd)) {
manualModal.errors = { ...manualModal.errors, manualNumber: t('logistique.weighingTickets.form.manual.numberRequired') } manualModal.errors = { ...manualModal.errors, dsd: t('logistique.weighingTickets.form.manual.dsdRequired') }
} }
if (Object.keys(manualModal.errors).length > 0) return if (Object.keys(manualModal.errors).length > 0) return
manualModal.loading = true manualModal.loading = true
try { try {
const reading = await weighbridge.triggerManual(weight as number, manualNumber) const reading = await weighbridge.triggerManual(weight as number, dsd as number)
form.applyReading(form[manualModal.target], reading) form.applyReading(form[manualModal.target], reading)
manualModal.open = false manualModal.open = false
await saveDraft()
} }
catch (e) { catch (e) {
manualModal.errors = { weight: weighbridge.extractWeighbridgeError(e) } manualModal.errors = { weight: weighbridge.extractWeighbridgeError(e) }
@@ -344,14 +351,34 @@ async function confirmManual(): Promise<void> {
} }
} }
// ── Soumission / impression ────────────────────────────────────────────────── // ── Persistance / impression ──────────────────────────────────────────────────
/** « Enregistrer » : PATCH /weighing_tickets/{id} (recalcul net serveur, RG-5.05). */ /** Enregistre l'état courant en BROUILLON (PATCH). False sur erreur (422 inline). */
async function submitSave(): Promise<void> { async function saveDraft(): Promise<boolean> {
if (saving.value) return
saving.value = true
clearErrors() clearErrors()
try { try {
await api.patch(`/weighing_tickets/${ticketId}`, form.buildUpdatePayload(), { toast: false }) await api.patch(`/weighing_tickets/${ticketId}`, form.buildDraftPayload(), { toast: false })
return true
}
catch (e) {
handleApiError(e, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
return false
}
}
/**
* Action principale : persiste l'état courant puis finalise/re-valide via
* PATCH /validate (back autoritaire : 3 champs du haut + 2 pesées). Ouvre le bon de
* pesée PDF (RG-5.08) — aussi bien à la validation d'un brouillon qu'à
* l'enregistrement d'un ticket déjà validé. Retour à la liste au succès.
*/
async function submitPrimary(): Promise<void> {
if (saving.value) return
saving.value = true
try {
if (!(await saveDraft())) return
await api.patch(`/weighing_tickets/${ticketId}/validate`, form.buildValidatePayload(), { toast: false })
window.open(`/api/weighing_tickets/${ticketId}/print.pdf`, '_blank')
router.push('/weighing-tickets') router.push('/weighing-tickets')
} }
catch (e) { catch (e) {
@@ -371,12 +398,10 @@ function printTicket(): void {
} }
onMounted(async () => { onMounted(async () => {
// Référentiels (selects contrepartie) en parallèle, non bloquants.
referentials.load().catch(() => {}) referentials.load().catch(() => {})
try { try {
const detail = await fetchTicket(ticketId) const detail = await fetchTicket(ticketId)
ticketNumber.value = detail.number ticketNumber.value = detail.number ?? ''
siteName.value = detail.site?.name ?? ''
form.hydrate(detail) form.hydrate(detail)
} }
catch { catch {
@@ -45,13 +45,18 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { formatDateFr, formatWeightKg } from '~/modules/logistique/utils/weighingTicketFormat'
const { t } = useI18n() const { t } = useI18n()
const api = useApi() const api = useApi()
const router = useRouter() const router = useRouter()
const toast = useToast() const toast = useToast()
const { can } = usePermissions() 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') }) useHead({ title: t('logistique.weighingTickets.title') })
@@ -70,6 +75,7 @@ const {
fetch: loadTickets, fetch: loadTickets,
goToPage, goToPage,
setItemsPerPage, setItemsPerPage,
reset: reloadFromFirstPage,
} = useWeighingTicketsRepository() } = useWeighingTicketsRepository()
// Mappe les tickets en objets « plats » formates pour MalioDataTable (items typees // Mappe les tickets en objets « plats » formates pour MalioDataTable (items typees
@@ -78,12 +84,16 @@ const {
// restent vides. Date et poids sont formates ici (cf. helpers ci-dessous). // restent vides. Date et poids sont formates ici (cf. helpers ci-dessous).
const rows = computed(() => tickets.value.map(ticket => ({ const rows = computed(() => tickets.value.map(ticket => ({
id: ticket.id, id: ticket.id,
number: ticket.number, // Numéro vide tant que brouillon (attribué à la validation, ERP-193).
number: ticket.number ?? '',
client: ticket.client?.companyName ?? '', client: ticket.client?.companyName ?? '',
supplier: ticket.supplier?.companyName ?? '', supplier: ticket.supplier?.companyName ?? '',
otherLabel: ticket.otherLabel ?? '', otherLabel: ticket.otherLabel ?? '',
displayDate: formatDateFr(ticket.displayDate), displayDate: formatDateFr(ticket.displayDate),
netWeight: formatWeight(ticket.netWeight), netWeight: formatWeightKg(ticket.netWeight),
status: t(ticket.status === 'VALIDATED'
? 'logistique.weighingTickets.status.validated'
: 'logistique.weighingTickets.status.draft'),
}))) })))
const columns = [ const columns = [
@@ -93,36 +103,9 @@ const columns = [
{ key: 'otherLabel', label: t('logistique.weighingTickets.column.other') }, { key: 'otherLabel', label: t('logistique.weighingTickets.column.other') },
{ key: 'displayDate', label: t('logistique.weighingTickets.column.date') }, { key: 'displayDate', label: t('logistique.weighingTickets.column.date') },
{ key: 'netWeight', label: t('logistique.weighingTickets.column.weight') }, { 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). */ /** Clic sur une ligne → ecran Modification (pas de consultation separee, spec § Navigation). */
function onRowClick(item: Record<string, unknown>): void { function onRowClick(item: Record<string, unknown>): void {
router.push(`/weighing-tickets/${item.id}/edit`) router.push(`/weighing-tickets/${item.id}/edit`)
@@ -175,5 +158,16 @@ function triggerDownload(blob: Blob, filename: string): void {
URL.revokeObjectURL(url) 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) onMounted(loadTickets)
</script> </script>
@@ -13,30 +13,19 @@
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('logistique.weighingTickets.form.addTitle') }}</h1> <h1 class="text-[30px] font-semibold text-m-primary">{{ t('logistique.weighingTickets.form.addTitle') }}</h1>
</div> </div>
<div class="mt-[48px] flex flex-col gap-8"> <!-- Form à plat, pleine largeur (sans box-shadow) : un filet noir 1px
<!-- Bloc « Poids à vide » (porte la contrepartie, RG-5.03) --> sépare chacun des 3 blocs (divide-y). -->
<WeighingBlock <div class="mt-[48px] flex flex-col divide-y divide-black">
block-id="empty" <!-- 4 champs du haut : contrepartie (type + champ conditionnel),
:title="t('logistique.weighingTickets.form.emptyBlock')" immatriculation, « Tout format » (ERP-193, hors blocs de pesée).
:block="form.empty" 1er bloc : pas de padding-top (marge titreform = mt-[48px] standard). -->
:immatriculation="form.immatriculation.value" <div class="pb-[20px]">
:plate-free-format="form.plateFreeFormat.value" <div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
: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 <MalioSelect
:model-value="form.counterpartyType.value" :model-value="form.counterpartyType.value"
:options="counterpartyOptions" :options="counterpartyOptions"
:label="t('logistique.weighingTickets.form.counterparty.type')" :label="t('logistique.weighingTickets.form.counterparty.type')"
:required="true" :required="true"
:disabled="emptyLocked"
empty-option-label="" empty-option-label=""
:error="errors.counterpartyType" :error="errors.counterpartyType"
@update:model-value="onCounterpartyTypeChange" @update:model-value="onCounterpartyTypeChange"
@@ -47,7 +36,6 @@
:options="referentials.suppliers.value" :options="referentials.suppliers.value"
:label="t('logistique.weighingTickets.form.counterparty.supplier')" :label="t('logistique.weighingTickets.form.counterparty.supplier')"
:required="true" :required="true"
:disabled="emptyLocked"
empty-option-label="" empty-option-label=""
:error="errors.supplier" :error="errors.supplier"
@update:model-value="(v: string | number | null) => form.supplierIri.value = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => form.supplierIri.value = v === null ? null : String(v)"
@@ -58,7 +46,6 @@
:options="referentials.clients.value" :options="referentials.clients.value"
:label="t('logistique.weighingTickets.form.counterparty.client')" :label="t('logistique.weighingTickets.form.counterparty.client')"
:required="true" :required="true"
:disabled="emptyLocked"
empty-option-label="" empty-option-label=""
:error="errors.client" :error="errors.client"
@update:model-value="(v: string | number | null) => form.clientIri.value = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => form.clientIri.value = v === null ? null : String(v)"
@@ -68,70 +55,80 @@
:model-value="form.otherLabel.value" :model-value="form.otherLabel.value"
:label="t('logistique.weighingTickets.form.counterparty.other')" :label="t('logistique.weighingTickets.form.counterparty.other')"
:required="true" :required="true"
:disabled="emptyLocked"
:error="errors.otherLabel" :error="errors.otherLabel"
@update:model-value="(v: string | null) => form.otherLabel.value = v" @update:model-value="(v: string | null) => form.otherLabel.value = v"
/> />
</template>
</WeighingBlock>
<!-- « Enregistrer » du bloc vide : POST initial du ticket (disparaît une <!-- Pas de cellule vide quand aucun type n'est choisi : immat et
fois le ticket créé — RG-5.08). --> « Tout format » se collent au type, et le champ conditionnel
<div v-if="form.ticketId.value === null" class="flex justify-center"> les décale une fois un type sélectionné. -->
<MalioButton <!-- Immatriculation : masque XX-000-XX (plaque FR SIV) ; en « Tout
variant="primary" format », masque élargi. Partagée par les 2 pesées (RG-5.01). -->
:label="t('logistique.weighingTickets.form.save')" <MalioInputText
:disabled="creating" :model-value="form.immatriculation.value"
@click="submitCreate" :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> </div>
<!-- ── Bloc « Poids à plein » ───────────────────────────────────────--> <!-- ── Bloc « Poids à vide » ───────────────────────────────────────-->
<WeighingBlock <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" block-id="full"
:title="t('logistique.weighingTickets.form.fullBlock')" :title="t('logistique.weighingTickets.form.fullBlock')"
:block="form.full" :block="form.full"
:immatriculation="form.immatriculation.value"
:plate-free-format="form.plateFreeFormat.value"
:errors="fullBlockErrors" :errors="fullBlockErrors"
@update:block="(field, value) => updateBlock('full', field, value)" @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-auto="openAuto('full')"
@request-manual="openManual('full')" @request-manual="openManual('full')"
/> />
</div> </div>
<!-- « Valider » (bas d'écran) : PATCH de la pesée à plein puis ouverture du <!-- « Valider » : persiste l'état courant (brouillon) puis finalise (3 champs
bon de pesée PDF (RG-5.08). Indisponible tant que le ticket n'est pas créé. --> du haut + 2 pesées, validation back autoritaire) et ouvre le bon de
pesée PDF (RG-5.08, ERP-193). Toujours actif : les 422 s'affichent inline. -->
<div class="mt-12 flex justify-center"> <div class="mt-12 flex justify-center">
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('logistique.weighingTickets.form.validate')" :label="t('logistique.weighingTickets.form.validate')"
:disabled="validating || form.ticketId.value === null" :disabled="validating"
@click="submitValidate" @click="submitValidate"
/> />
</div> </div>
<!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────--> <!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────-->
<MalioModal v-model="autoModal.open" modal-class="max-w-md"> <MalioModal v-model="autoModal.open" modal-class="max-w-md" footer-class="justify-center pb-6">
<template #header> <template #header>
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2> <h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
</template> </template>
<p>{{ t('logistique.weighingTickets.form.weighbridge.confirmMessage') }}</p> <p v-if="autoModal.error" class="text-m-danger">{{ autoModal.error }}</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> <template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('logistique.weighingTickets.form.weighbridge.cancel')"
@click="autoModal.open = false"
/>
<MalioButton <MalioButton
variant="primary" variant="primary"
button-class="flex-1"
:label="t('logistique.weighingTickets.form.weighbridge.validate')" :label="t('logistique.weighingTickets.form.weighbridge.validate')"
:disabled="autoModal.loading" :disabled="autoModal.loading"
@click="confirmAuto" @click="confirmAuto"
@@ -140,35 +137,35 @@
</MalioModal> </MalioModal>
<!-- ── Modal « Pesée manuelle » ────────────────────────────────────────--> <!-- ── Modal « Pesée manuelle » ────────────────────────────────────────-->
<MalioModal v-model="manualModal.open" modal-class="max-w-md"> <MalioModal
v-model="manualModal.open"
modal-class="max-w-md"
header-class="mx-7 px-0 pt-6 pb-3 border-b border-black"
body-class="px-7 pt-9"
footer-class="px-7 justify-center pb-6"
>
<template #header> <template #header>
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.manual.title') }}</h2> <h2 class="text-[24px] font-bold uppercase">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
</template> </template>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-2">
<MalioInputNumber <MalioInputText
v-model="manualModal.weight" v-model="manualModal.weight"
:mask="NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.manual.weight')" :label="t('logistique.weighingTickets.form.manual.weight')"
:required="true" :required="true"
:min="0"
:error="manualModal.errors.weight" :error="manualModal.errors.weight"
/> />
<MalioInputText <MalioInputText
v-model="manualModal.manualNumber" v-model="manualModal.dsd"
:label="t('logistique.weighingTickets.form.manual.number')" :mask="NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.manual.dsd')"
:required="true" :required="true"
:error="manualModal.errors.manualNumber" :error="manualModal.errors.dsd"
/> />
</div> </div>
<template #footer> <template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('logistique.weighingTickets.form.manual.cancel')"
@click="manualModal.open = false"
/>
<MalioButton <MalioButton
variant="primary" variant="primary"
button-class="flex-1"
:label="t('logistique.weighingTickets.form.manual.save')" :label="t('logistique.weighingTickets.form.manual.save')"
:disabled="manualModal.loading" :disabled="manualModal.loading"
@click="confirmManual" @click="confirmManual"
@@ -183,6 +180,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm' import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge' import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials' import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
import { NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
const { t } = useI18n() const { t } = useI18n()
const api = useApi() const api = useApi()
@@ -201,10 +199,6 @@ const weighbridge = useWeighbridge()
const referentials = useWeighingTicketReferentials() const referentials = useWeighingTicketReferentials()
const { errors, clearErrors, handleApiError } = useFormErrors() 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) const validating = ref(false)
/** Retour vers la liste (flèche d'en-tête). */ /** Retour vers la liste (flèche d'en-tête). */
@@ -212,8 +206,7 @@ function goBack(): void {
router.push('/weighing-tickets') router.push('/weighing-tickets')
} }
// ── Contrepartie (RG-5.03) ─────────────────────────────────────────────────── // ── Contrepartie (RG-5.03) — ordre maquette : Fournisseur / Client / Autre. ───
// Ordre maquette : Fournisseur / Client / Autre.
const counterpartyOptions = computed<RefOption[]>(() => [ const counterpartyOptions = computed<RefOption[]>(() => [
{ value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') }, { value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') },
{ value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') }, { value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') },
@@ -230,18 +223,15 @@ const emptyBlockErrors = computed<Record<string, string>>(() => ({
date: errors.emptyDate, date: errors.emptyDate,
weight: errors.emptyWeight, weight: errors.emptyWeight,
dsd: errors.emptyDsd, dsd: errors.emptyDsd,
immatriculation: errors.immatriculation,
})) }))
const fullBlockErrors = computed<Record<string, string>>(() => ({ const fullBlockErrors = computed<Record<string, string>>(() => ({
date: errors.fullDate, date: errors.fullDate,
weight: errors.fullWeight, weight: errors.fullWeight,
dsd: errors.fullDsd, dsd: errors.fullDsd,
immatriculation: errors.immatriculation,
})) }))
/** Mute un champ d'un bloc de pesée (état centralisé dans le form). */ /** 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 { 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 (form[target] as Record<string, unknown>)[field as string] = value
} }
@@ -259,7 +249,7 @@ function openAuto(target: 'empty' | 'full'): void {
autoModal.open = true autoModal.open = true
} }
/** Déclenche la pesée bascule ; erreur (RG-5.06) affichée dans la modal. */ /** Déclenche la pesée bascule puis enregistre le brouillon (ERP-193). */
async function confirmAuto(): Promise<void> { async function confirmAuto(): Promise<void> {
if (autoModal.loading) return if (autoModal.loading) return
autoModal.loading = true autoModal.loading = true
@@ -268,9 +258,9 @@ async function confirmAuto(): Promise<void> {
const reading = await weighbridge.triggerAuto() const reading = await weighbridge.triggerAuto()
form.applyReading(form[autoModal.target], reading) form.applyReading(form[autoModal.target], reading)
autoModal.open = false autoModal.open = false
await saveDraft()
} }
catch (error) { catch (error) {
// Pont indisponible : message inline + invite à la pesée manuelle.
autoModal.error = weighbridge.extractWeighbridgeError(error) autoModal.error = weighbridge.extractWeighbridgeError(error)
} }
finally { finally {
@@ -283,39 +273,40 @@ const manualModal = reactive({
open: false, open: false,
loading: false, loading: false,
target: 'empty' as 'empty' | 'full', target: 'empty' as 'empty' | 'full',
weight: null as string | number | null, weight: null as string | null,
manualNumber: null as string | null, dsd: null as string | null,
errors: {} as Record<string, string>, errors: {} as Record<string, string>,
}) })
function openManual(target: 'empty' | 'full'): void { function openManual(target: 'empty' | 'full'): void {
manualModal.target = target manualModal.target = target
manualModal.weight = null manualModal.weight = null
manualModal.manualNumber = null manualModal.dsd = null
manualModal.errors = {} manualModal.errors = {}
manualModal.open = true manualModal.open = true
} }
/** Valide la saisie manuelle puis remplit le bloc (DSD calculé serveur, RG-5.04). */ /** Valide la saisie manuelle (poids + DSD), remplit le bloc puis enregistre le brouillon. */
async function confirmManual(): Promise<void> { async function confirmManual(): Promise<void> {
if (manualModal.loading) return if (manualModal.loading) return
manualModal.errors = {} manualModal.errors = {}
const weight = manualModal.weight === null || manualModal.weight === '' ? null : Number(manualModal.weight) const weight = manualModal.weight === null || manualModal.weight === '' ? null : Number(manualModal.weight)
const manualNumber = (manualModal.manualNumber ?? '').trim() const dsd = manualModal.dsd === null || manualModal.dsd === '' ? null : Number(manualModal.dsd)
if (weight === null || Number.isNaN(weight)) { if (weight === null || Number.isNaN(weight)) {
manualModal.errors = { ...manualModal.errors, weight: t('logistique.weighingTickets.form.manual.weightRequired') } manualModal.errors = { ...manualModal.errors, weight: t('logistique.weighingTickets.form.manual.weightRequired') }
} }
if (manualNumber === '') { if (dsd === null || Number.isNaN(dsd)) {
manualModal.errors = { ...manualModal.errors, manualNumber: t('logistique.weighingTickets.form.manual.numberRequired') } manualModal.errors = { ...manualModal.errors, dsd: t('logistique.weighingTickets.form.manual.dsdRequired') }
} }
if (Object.keys(manualModal.errors).length > 0) return if (Object.keys(manualModal.errors).length > 0) return
manualModal.loading = true manualModal.loading = true
try { try {
const reading = await weighbridge.triggerManual(weight as number, manualNumber) const reading = await weighbridge.triggerManual(weight as number, dsd as number)
form.applyReading(form[manualModal.target], reading) form.applyReading(form[manualModal.target], reading)
manualModal.open = false manualModal.open = false
await saveDraft()
} }
catch (error) { catch (error) {
manualModal.errors = { weight: weighbridge.extractWeighbridgeError(error) } manualModal.errors = { weight: weighbridge.extractWeighbridgeError(error) }
@@ -325,38 +316,47 @@ async function confirmManual(): Promise<void> {
} }
} }
// ── Soumissions ────────────────────────────────────────────────────────────── // ── Persistance ──────────────────────────────────────────────────────────────
interface TicketResponse { id: number } interface TicketResponse { id: number }
/** « Enregistrer » du bloc vide : POST /weighing_tickets (création + pesée à vide). */ /**
async function submitCreate(): Promise<void> { * Enregistre l'état courant en BROUILLON (ERP-193) : POST si le ticket n'existe pas
if (creating.value) return * encore (1ʳᵉ pesée enregistrée), PATCH ensuite. Renvoie false sur erreur (422
creating.value = true * mappée inline, ex. format d'immatriculation).
*/
async function saveDraft(): Promise<boolean> {
clearErrors() clearErrors()
try { try {
const created = await api.post<TicketResponse>('/weighing_tickets', form.buildCreatePayload(), { if (form.ticketId.value === null) {
headers: { Accept: 'application/ld+json' }, const created = await api.post<TicketResponse>('/weighing_tickets', form.buildDraftPayload(), {
toast: false, headers: { Accept: 'application/ld+json' },
}) toast: false,
form.ticketId.value = created.id })
form.ticketId.value = created.id
}
else {
await api.patch(`/weighing_tickets/${form.ticketId.value}`, form.buildDraftPayload(), { toast: false })
}
return true
} }
catch (error) { catch (error) {
handleApiError(error, { fallbackMessage: t('logistique.weighingTickets.toast.error') }) handleApiError(error, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
} return false
finally {
creating.value = false
} }
} }
/** « Valider » : PATCH de la pesée à plein puis ouverture du bon de pesée PDF (RG-5.08). */ /**
* « Valider » : persiste l'état courant puis finalise via PATCH /validate. La
* validation stricte (3 champs du haut + 2 pesées) est portée par le back ; les 422
* remontent inline. Succès → ouverture du bon de pesée PDF + retour à la liste.
*/
async function submitValidate(): Promise<void> { async function submitValidate(): Promise<void> {
if (validating.value || form.ticketId.value === null) return if (validating.value) return
validating.value = true validating.value = true
clearErrors()
try { try {
await api.patch(`/weighing_tickets/${form.ticketId.value}`, form.buildFullPayload(), { toast: false }) if (!(await saveDraft())) return
// 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). await api.patch(`/weighing_tickets/${form.ticketId.value}/validate`, form.buildValidatePayload(), { toast: false })
window.open(`/api/weighing_tickets/${form.ticketId.value}/print.pdf`, '_blank') window.open(`/api/weighing_tickets/${form.ticketId.value}/print.pdf`, '_blank')
router.push('/weighing-tickets') router.push('/weighing-tickets')
} }
@@ -369,7 +369,6 @@ async function submitValidate(): Promise<void> {
} }
onMounted(() => { onMounted(() => {
// Échec du chargement des référentiels non bloquant : les selects restent vides.
referentials.load().catch(() => {}) referentials.load().catch(() => {})
}) })
</script> </script>
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest'
import { formatDateFr, formatWeightKg, formatPlate } from '../weighingTicketFormat'
describe('weighingTicketFormat', () => {
// ── Date JJ-MM-AAAA ───────────────────────────────────────────────────────
describe('formatDateFr', () => {
it('formate un datetime ISO en JJ-MM-AAAA', () => {
expect(formatDateFr('2026-06-17T09:12:00+02:00')).toBe('17-06-2026')
})
it('zéro-pad le jour et le mois', () => {
expect(formatDateFr('2026-01-05T00:00:00Z')).toBe('05-01-2026')
})
it('retourne une chaîne vide si absente ou invalide', () => {
expect(formatDateFr(null)).toBe('')
expect(formatDateFr(undefined)).toBe('')
expect(formatDateFr('pas-une-date')).toBe('')
})
})
// ── Poids « X XXX Kg » ────────────────────────────────────────────────────
describe('formatWeightKg', () => {
it('ajoute un séparateur de milliers (espace) et le suffixe Kg', () => {
expect(formatWeightKg(7150)).toBe('7 150 Kg')
expect(formatWeightKg(14300)).toBe('14 300 Kg')
expect(formatWeightKg(1000000)).toBe('1 000 000 Kg')
})
it('gère les petits nombres sans séparateur', () => {
expect(formatWeightKg(0)).toBe('0 Kg')
expect(formatWeightKg(999)).toBe('999 Kg')
})
it('retourne une chaîne vide si le poids est absent', () => {
expect(formatWeightKg(null)).toBe('')
expect(formatWeightKg(undefined)).toBe('')
})
})
// ── Immatriculation UPPER ─────────────────────────────────────────────────
describe('formatPlate', () => {
it('met en majuscules et trim', () => {
expect(formatPlate(' ab-123-cd ')).toBe('AB-123-CD')
})
it('retourne une chaîne vide si absente', () => {
expect(formatPlate(null)).toBe('')
expect(formatPlate('')).toBe('')
})
})
})
@@ -0,0 +1,39 @@
import type { MaskInputOptions } from 'maska'
/**
* Masques de saisie du module « Tickets de pesée » (M5). Partagés entre le
* composant de bloc (`WeighingBlock`) et les modales de pesée (écrans Ajouter /
* Modifier). La validation de format reste autoritaire côté serveur (RG-5.01).
*/
/**
* Masque « chiffres uniquement » (longueur libre) — Poids et DSD. Verrouille la
* saisie sur des entiers.
*/
export const NUMERIC_MASK: MaskInputOptions = {
mask: 'D',
tokens: { D: { pattern: /[0-9]/, multiple: true } },
}
/**
* Masque plaque FR SIV `XX-000-XX` : 2 lettres, 3 chiffres, 2 lettres, majuscules
* forcées. Utilisé quand « Tout format » n'est pas coché (RG-5.01).
*/
export const PLATE_MASK: MaskInputOptions = {
mask: 'AA-###-AA',
tokens: { A: { pattern: /[A-Za-z]/, transform: (c: string) => c.toUpperCase() } },
}
/**
* Masque « Tout format » (RG-5.01) : plaques anciennes / étrangères / engins. On
* autorise lettres, chiffres, espace et tiret, en MAJUSCULES, longueur libre —
* mais on filtre tout le reste (accents, ponctuation, symboles : « &é"'(_ç… »).
* Pattern maska charset du projet (cf. shared/utils/textSanitize) : `preProcess`
* retire d'abord les caractères hors charset (le token `multiple` glouton
* s'arrêterait sinon au 1er invalide), puis le token laisse passer le reste.
*/
export const FREE_PLATE_MASK: MaskInputOptions = {
mask: 'P',
tokens: { P: { pattern: /[A-Z0-9 -]/, multiple: true } },
preProcess: (value: string) => value.toUpperCase().replace(/[^A-Z0-9 -]/g, ''),
}
@@ -0,0 +1,46 @@
/**
* 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). Chaîne vide si la valeur est
* absente ou invalide. Lit les composantes locales (cohérent avec l'affichage
* des autres répertoires M1→M4).
*/
export 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 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() : ''
}
+15
View File
@@ -15,3 +15,18 @@ export function todayIso(now: Date = new Date()): string {
const day = String(now.getDate()).padStart(2, '0') const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}` return `${year}-${month}-${day}`
} }
/**
* Date-heure courante au format ISO LOCAL `YYYY-MM-DDTHH:mm:ss` (sans fuseau).
*
* C'est le format attendu par `MalioDateTime` (secondes incluses, pas d'offset
* horaire). Comme `todayIso`, on lit les composantes LOCALES (jamais
* `toISOString()`/UTC) pour ne pas décaler l'heure réelle. Paramètre `now`
* injectable pour les tests.
*/
export function nowIsoDateTime(now: Date = new Date()): string {
const hours = String(now.getHours()).padStart(2, '0')
const minutes = String(now.getMinutes()).padStart(2, '0')
const seconds = String(now.getSeconds()).padStart(2, '0')
return `${todayIso(now)}T${hours}:${minutes}:${seconds}`
}
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M5 — Tickets de pesee (ERP-193) : cycle de vie brouillon -> valide.
*
* Le metier peut desormais enregistrer une pesee (bascule ou manuelle) SANS avoir
* rempli la contrepartie ni l'immatriculation : le ticket est cree « brouillon »
* des la 1ere pesee, puis « valide » (numero attribue, status VALIDATED) quand les
* 3 champs requis (type + champ contrepartie + immatriculation) ET les 2 pesees
* sont renseignes.
*
* Schema impacte :
* - `counterparty_type`, `immatriculation`, `number` passent NULLABLE (un brouillon
* n'a encore ni contrepartie, ni immat, ni numero — le numero n'est attribue
* qu'a la validation pour eviter les trous de sequence). Les CHECK de branche
* chk_wt_*_branch tolerent deja un counterparty_type NULL (NULL <> 'X' = NULL,
* donc CHECK non viole).
* - nouvelle colonne `status` (DRAFT|VALIDATED). Les tickets EXISTANTS (crees sous
* l'ancien flux, donc complets) sont retro-marques VALIDATED ; le defaut des
* nouvelles lignes est DRAFT.
*
* Namespace racine `DoctrineMigrations` (et non modulaire) : la migration ALTER une
* table creee par la migration racine Version20260617150000. Doctrine Migrations
* 3.x trie par FQCN alphabetique entre namespaces -> une migration modulaire
* `App\Module\...` passerait AVANT la racine sur base vide (make db-reset) et
* tenterait l'ALTER avant le CREATE. Le namespace racine garantit le tri par
* timestamp (regle ABSOLUE n°11, cf. Version20260617170000 pour site.code).
*/
final class Version20260624100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-193 : weighing_ticket brouillon/valide (counterparty_type/immatriculation/number nullable + colonne status).';
}
public function up(Schema $schema): void
{
// Brouillon : ni contrepartie, ni immat, ni numero tant que non valide.
$this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN counterparty_type DROP NOT NULL');
$this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN immatriculation DROP NOT NULL');
$this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN number DROP NOT NULL');
// Statut du cycle de vie. Colonne ajoutee nullable, retro-remplie a VALIDATED
// pour les tickets existants (complets), puis figee NOT NULL DEFAULT DRAFT.
$this->addSql('ALTER TABLE weighing_ticket ADD COLUMN status VARCHAR(12)');
$this->addSql("UPDATE weighing_ticket SET status = 'VALIDATED'");
$this->addSql("ALTER TABLE weighing_ticket ALTER COLUMN status SET DEFAULT 'DRAFT'");
$this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN status SET NOT NULL');
$this->addSql("ALTER TABLE weighing_ticket ADD CONSTRAINT chk_wt_status CHECK (status IN ('DRAFT','VALIDATED'))");
// Commentaires (regle ABSOLUE n°12).
$this->comment('weighing_ticket', 'status', "Cycle de vie : DRAFT (En attente, pesee enregistree sans contrepartie/immat) ou VALIDATED (Terminee, valide avec numero). Defaut DRAFT.");
$this->comment('weighing_ticket', 'number', "Numero {siteCode}-TP-{NNNN}, unique par site, immuable. NULL tant que le ticket est brouillon : attribue a la validation (RG-5.02, ERP-193).");
$this->comment('weighing_ticket', 'counterparty_type', "Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (RG-5.03). NULL tant que brouillon ; requise a la validation. Pilote l'obligation client_id / supplier_id / other_label.");
$this->comment('weighing_ticket', 'immatriculation', "Plaque du vehicule, partagee entre pesee vide et plein (RG-5.01). NULL tant que brouillon ; requise a la validation. Masque XX-000-XX sauf plate_free_format.");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE weighing_ticket DROP CONSTRAINT IF EXISTS chk_wt_status');
$this->addSql('ALTER TABLE weighing_ticket DROP COLUMN IF EXISTS status');
// Restauration NOT NULL : echoue s'il subsiste des brouillons (number /
// counterparty_type / immatriculation NULL) — irreversible en presence de
// donnees brouillon, ce qui est attendu (le down sert au dev sur base saine).
$this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN number SET NOT NULL');
$this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN immatriculation SET NOT NULL');
$this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN counterparty_type SET NOT NULL');
}
/**
* Pose un COMMENT ON COLUMN en dollar-quoting Postgres ($_$...$_$) pour eviter
* tout echappement d'apostrophes dans les descriptions.
*/
private function comment(string $table, string $column, string $description): void
{
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
'"'.str_replace('"', '""', $table).'"',
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M5 — Tickets de pesee (ERP-193) : suppression du « numero de pesee » manuel.
*
* En pesee manuelle, l'operateur saisit desormais directement le DSD (le numero du
* pont qu'il a reellement utilise), conserve tel quel. Le champ texte separe
* `*_manual_number` (« Numero de pesee ») devient redondant — pour le client c'est
* la meme chose que le DSD — et est supprime.
*
* Namespace racine `DoctrineMigrations` : ALTER d'une table creee par la migration
* racine (cf. Version20260624100000) — meme contrainte de tri (regle ABSOLUE n°11).
*/
final class Version20260624110000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-193 : suppression de weighing_ticket.empty_manual_number / full_manual_number (DSD saisi en manuel).';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE weighing_ticket DROP COLUMN empty_manual_number');
$this->addSql('ALTER TABLE weighing_ticket DROP COLUMN full_manual_number');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE weighing_ticket ADD COLUMN empty_manual_number VARCHAR(50) DEFAULT NULL');
$this->addSql('ALTER TABLE weighing_ticket ADD COLUMN full_manual_number VARCHAR(50) DEFAULT NULL');
$this->addSql("COMMENT ON COLUMN weighing_ticket.empty_manual_number IS \$_\$Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a vide (RG-5.04).\$_\$");
$this->addSql("COMMENT ON COLUMN weighing_ticket.full_manual_number IS \$_\$Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a plein (RG-5.04).\$_\$");
}
}
@@ -12,6 +12,7 @@ use ApiPlatform\Metadata\Post;
use App\Module\Commercial\Domain\Entity\Client; // relation ORM partagee (§ 2.1) use App\Module\Commercial\Domain\Entity\Client; // relation ORM partagee (§ 2.1)
use App\Module\Commercial\Domain\Entity\Supplier; // relation ORM partagee (§ 2.1) use App\Module\Commercial\Domain\Entity\Supplier; // relation ORM partagee (§ 2.1)
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighingTicketProcessor; use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighingTicketProcessor;
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider\WeighingTicketPrintProvider;
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider\WeighingTicketProvider; use App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider\WeighingTicketProvider;
use App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository; use App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository;
use App\Module\Sites\Domain\Entity\Site; // relation ORM partagee (§ 2.1) use App\Module\Sites\Domain\Entity\Site; // relation ORM partagee (§ 2.1)
@@ -84,6 +85,18 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
]], ]],
provider: WeighingTicketProvider::class, provider: WeighingTicketProvider::class,
), ),
// Bon de pesee PDF (RG-5.08, spec § 2.12 / § 4.6) : operation dediee qui
// sert un binaire (pas une representation Hydra). Le provider retourne une
// Response -> la serialisation est court-circuitee. Pas de controller
// (decision spec § 4.6). Pas de format API Platform negocie : `.pdf` est
// litteral dans l'URI.
new Get(
uriTemplate: '/weighing_tickets/{id}/print.pdf',
security: "is_granted('logistique.weighing_tickets.view')",
provider: WeighingTicketPrintProvider::class,
output: false,
read: true,
),
new Post( new Post(
security: "is_granted('logistique.weighing_tickets.manage')", security: "is_granted('logistique.weighing_tickets.manage')",
normalizationContext: ['groups' => [ normalizationContext: ['groups' => [
@@ -95,6 +108,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
'default:read', 'default:read',
]], ]],
denormalizationContext: ['groups' => ['weighing_ticket:write']], denormalizationContext: ['groups' => ['weighing_ticket:write']],
// Erreurs de denormalisation (date non parsable, type/IRI invalide)
// remontees en 422 avec propertyPath (et non 400 opaque) -> mapping
// inline par champ cote front via useFormErrors (miroir M1 Client).
collectDenormalizationErrors: true,
processor: WeighingTicketProcessor::class, processor: WeighingTicketProcessor::class,
), ),
new Patch( new Patch(
@@ -108,6 +125,30 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
'default:read', 'default:read',
]], ]],
denormalizationContext: ['groups' => ['weighing_ticket:write']], denormalizationContext: ['groups' => ['weighing_ticket:write']],
collectDenormalizationErrors: true,
provider: WeighingTicketProvider::class,
processor: WeighingTicketProcessor::class,
),
// Validation (« Valider », ERP-193) : transition brouillon -> valide. Seule
// operation qui exige le groupe `finalize` (contrepartie + immatriculation +
// les 2 pesees, § 2.14) ; le Processor y attribue le numero et passe status
// a VALIDATED. Le POST/PATCH standard restent « brouillon » (validation
// Default relachee, on enregistre une pesee sans contrepartie/immat).
new Patch(
uriTemplate: '/weighing_tickets/{id}/validate',
name: 'weighing_ticket_validate',
security: "is_granted('logistique.weighing_tickets.manage')",
normalizationContext: ['groups' => [
'weighing_ticket:read',
'weighing_ticket:item:read',
'client:read',
'supplier:read',
'site:read',
'default:read',
]],
denormalizationContext: ['groups' => ['weighing_ticket:write']],
validationContext: ['groups' => ['Default', 'finalize']],
collectDenormalizationErrors: true,
provider: WeighingTicketProvider::class, provider: WeighingTicketProvider::class,
processor: WeighingTicketProcessor::class, processor: WeighingTicketProcessor::class,
), ),
@@ -128,14 +169,20 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
{ {
use TimestampableBlamableTrait; use TimestampableBlamableTrait;
/** Brouillon : pesee(s) enregistree(s), pas encore valide (« En attente »). */
public const string STATUS_DRAFT = 'DRAFT';
/** Valide : contrepartie + immatriculation + 2 pesees OK, numero attribue (« Terminée »). */
public const string STATUS_VALIDATED = 'VALIDATED';
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
#[Groups(['weighing_ticket:read'])] #[Groups(['weighing_ticket:read'])]
private ?int $id = null; private ?int $id = null;
/** Numero {siteCode}-TP-{NNNN} — attribue serveur, lecture seule, immuable (RG-5.02). */ /** Numero {siteCode}-TP-{NNNN} — attribue serveur a la VALIDATION, null tant que brouillon, immuable ensuite (RG-5.02, ERP-193). */
#[ORM\Column(length: 20)] #[ORM\Column(length: 20, nullable: true)]
#[Groups(['weighing_ticket:read'])] #[Groups(['weighing_ticket:read'])]
private ?string $number = null; private ?string $number = null;
@@ -145,9 +192,9 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
#[Groups(['weighing_ticket:item:read'])] #[Groups(['weighing_ticket:item:read'])]
private ?Site $site = null; private ?Site $site = null;
/** CLIENT | FOURNISSEUR | AUTRE (RG-5.03) — pilote le champ associe obligatoire. */ /** CLIENT | FOURNISSEUR | AUTRE (RG-5.03) — null tant que brouillon, requis a la validation. Pilote le champ associe obligatoire. */
#[ORM\Column(name: 'counterparty_type', length: 12)] #[ORM\Column(name: 'counterparty_type', length: 12, nullable: true)]
#[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.')] #[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.', groups: ['finalize'])]
#[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR', 'AUTRE'], message: 'Type de contrepartie invalide.')] #[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR', 'AUTRE'], message: 'Type de contrepartie invalide.')]
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])] #[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
private ?string $counterpartyType = null; private ?string $counterpartyType = null;
@@ -170,9 +217,9 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])] #[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
private ?string $otherLabel = null; private ?string $otherLabel = null;
/** Plaque du vehicule, partagee entre les 2 formulaires (RG-5.01). Masque XX-000-XX sauf plateFreeFormat. */ /** Plaque du vehicule, partagee entre les 2 formulaires (RG-5.01). Null tant que brouillon, requise a la validation. Masque XX-000-XX sauf plateFreeFormat. */
#[ORM\Column(length: 20)] #[ORM\Column(length: 20, nullable: true)]
#[Assert\NotBlank(message: 'L\'immatriculation est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'L\'immatriculation est obligatoire.', normalizer: 'trim', groups: ['finalize'])]
#[Assert\Length(max: 20, maxMessage: 'L\'immatriculation ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 20, maxMessage: 'L\'immatriculation ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])] #[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?string $immatriculation = null; private ?string $immatriculation = null;
@@ -190,7 +237,12 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])] #[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?DateTimeImmutable $emptyDate = null; private ?DateTimeImmutable $emptyDate = null;
/** Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07). */ /**
* Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07).
* Nullable au brouillon (on peut enregistrer la seule pesee a plein d'abord,
* ERP-193). L'obligation des DEUX pesees est portee par validateFinalization
* (groupe `finalize`), jouee uniquement a la validation.
*/
#[ORM\Column(name: 'empty_weight', nullable: true)] #[ORM\Column(name: 'empty_weight', nullable: true)]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])] #[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?int $emptyWeight = null; private ?int $emptyWeight = null;
@@ -205,12 +257,6 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])] #[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?string $emptyMode = null; private ?string $emptyMode = null;
/** Numero de pesee saisi en manuelle (distinct du DSD) — RG-5.04. */
#[ORM\Column(name: 'empty_manual_number', length: 50, nullable: true)]
#[Assert\Length(max: 50, maxMessage: 'Le numéro de pesée ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?string $emptyManualNumber = null;
// === Pesee a plein (§ 2.4) === // === Pesee a plein (§ 2.4) ===
#[ORM\Column(name: 'full_date', type: 'datetime_immutable', nullable: true)] #[ORM\Column(name: 'full_date', type: 'datetime_immutable', nullable: true)]
@@ -232,17 +278,21 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])] #[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?string $fullMode = null; private ?string $fullMode = null;
/** Numero de pesee saisi en manuelle (distinct du DSD) — RG-5.04. */
#[ORM\Column(name: 'full_manual_number', length: 50, nullable: true)]
#[Assert\Length(max: 50, maxMessage: 'Le numéro de pesée ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?string $fullManualNumber = null;
/** Poids net derive plein - vide (kg) — calcule serveur (RG-5.05). Colonne Poids de la liste. */ /** Poids net derive plein - vide (kg) — calcule serveur (RG-5.05). Colonne Poids de la liste. */
#[ORM\Column(name: 'net_weight', nullable: true)] #[ORM\Column(name: 'net_weight', nullable: true)]
#[Groups(['weighing_ticket:read'])] #[Groups(['weighing_ticket:read'])]
private ?int $netWeight = null; private ?int $netWeight = null;
/**
* Cycle de vie (ERP-193) : DRAFT (« En attente » — pesee enregistree sans
* contrepartie/immat) -> VALIDATED (« Terminée » — valide avec numero). Pose
* serveur (DRAFT a la creation, VALIDATED par l'operation `validate`) ; pas de
* groupe d'ecriture (jamais pilote par le client).
*/
#[ORM\Column(length: 12, options: ['default' => self::STATUS_DRAFT])]
#[Groups(['weighing_ticket:read'])]
private string $status = self::STATUS_DRAFT;
/** Soft-delete technique prepare mais non expose au M5 (§ 2.13) — pas de groupe. */ /** Soft-delete technique prepare mais non expose au M5 (§ 2.13) — pas de groupe. */
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)] #[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
private ?DateTimeImmutable $deletedAt = null; private ?DateTimeImmutable $deletedAt = null;
@@ -259,7 +309,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
* (chk_wt_*_branch) et la normalisation du Processor (qui null-ifie les * (chk_wt_*_branch) et la normalisation du Processor (qui null-ifie les
* champs hors-branche — ERP-185). * champs hors-branche — ERP-185).
*/ */
#[Assert\Callback] #[Assert\Callback(groups: ['finalize'])]
public function validateCounterpartyConsistency(ExecutionContextInterface $context): void public function validateCounterpartyConsistency(ExecutionContextInterface $context): void
{ {
switch ($this->counterpartyType) { switch ($this->counterpartyType) {
@@ -295,6 +345,31 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
} }
} }
/**
* Validation finale (ERP-193, § 2.14) : un ticket ne peut etre VALIDE qu'avec
* ses DEUX pesees renseignees (le poids net plein - vide n'a de sens que
* complet). Jouee uniquement dans le groupe `finalize` (operation `validate`) ;
* un brouillon peut ne porter qu'une seule pesee. Violations posees sur les
* champs poids -> mapping inline front (useFormErrors, ERP-101).
*/
#[Assert\Callback(groups: ['finalize'])]
public function validateFinalization(ExecutionContextInterface $context): void
{
if (null === $this->emptyWeight) {
$context->buildViolation('La pesée à vide est obligatoire pour valider le ticket.')
->atPath('emptyWeight')
->addViolation()
;
}
if (null === $this->fullWeight) {
$context->buildViolation('La pesée à plein est obligatoire pour valider le ticket.')
->atPath('fullWeight')
->addViolation()
;
}
}
/** /**
* Date du ticket affichee en LISTE (§ 4.0) : date de la pesee a plein si * Date du ticket affichee en LISTE (§ 4.0) : date de la pesee a plein si
* disponible, sinon date de la pesee a vide. Getter calcule (jamais * disponible, sinon date de la pesee a vide. Getter calcule (jamais
@@ -459,18 +534,6 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
return $this; return $this;
} }
public function getEmptyManualNumber(): ?string
{
return $this->emptyManualNumber;
}
public function setEmptyManualNumber(?string $emptyManualNumber): static
{
$this->emptyManualNumber = $emptyManualNumber;
return $this;
}
public function getFullDate(): ?DateTimeImmutable public function getFullDate(): ?DateTimeImmutable
{ {
return $this->fullDate; return $this->fullDate;
@@ -519,18 +582,6 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
return $this; return $this;
} }
public function getFullManualNumber(): ?string
{
return $this->fullManualNumber;
}
public function setFullManualNumber(?string $fullManualNumber): static
{
$this->fullManualNumber = $fullManualNumber;
return $this;
}
public function getNetWeight(): ?int public function getNetWeight(): ?int
{ {
return $this->netWeight; return $this->netWeight;
@@ -543,6 +594,23 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
return $this; return $this;
} }
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): static
{
$this->status = $status;
return $this;
}
public function isValidated(): bool
{
return self::STATUS_VALIDATED === $this->status;
}
public function getDeletedAt(): ?DateTimeImmutable public function getDeletedAt(): ?DateTimeImmutable
{ {
return $this->deletedAt; return $this->deletedAt;
@@ -20,17 +20,16 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
* *
* - AUTO (`{ "mode": "AUTO" }`) → `{ weight, dsd, mode }` (stub : poids * - AUTO (`{ "mode": "AUTO" }`) → `{ weight, dsd, mode }` (stub : poids
* aleatoire ∈ [10000,50000] kg + DSD du site, RG-5.04 / RG-5.06). * aleatoire ∈ [10000,50000] kg + DSD du site, RG-5.04 / RG-5.06).
* - MANUAL (`{ "mode": "MANUAL", "weight": <int>, "manualNumber": "<str>" }`) * - MANUAL (`{ "mode": "MANUAL", "weight": <int>, "dsd": <int> }`)
* → `{ weight, dsd, manualNumber, mode }` (DSD = dernier DSD du site + 1). * → `{ weight, dsd, mode }`. Le DSD est SAISI par l'operateur (numero du pont
* qu'il a reellement utilise) et conserve tel quel — plus d'auto-increment
* (ERP-193). Pas d'unicite : un DSD peut se repeter.
* *
* `read: false` : pas de chargement d'entite existante — le payload est * `read: false` : pas de chargement d'entite existante — le payload est
* denormalise directement dans cette ressource, puis le Processor prend le relais. * denormalise directement dans cette ressource, puis le Processor prend le relais.
* *
* ⚠ Le `dsd` renvoye ici est PREVISIONNEL : l'attribution AUTORITAIRE du DSD * ⚠ En AUTO, le `dsd` renvoye est fourni par le pont. En MANUAL, c'est la valeur
* (et du numero de ticket) est refaite/verrouillee a la creation du ticket * saisie. Le ticket persiste fait foi.
* (`POST /api/weighing_tickets`, ERP-185) pour eviter les collisions si deux
* postes pesent en parallele. Le front affiche cette valeur, mais c'est le
* ticket persiste qui fait foi.
*/ */
#[ApiResource( #[ApiResource(
shortName: 'WeighbridgeReading', shortName: 'WeighbridgeReading',
@@ -63,29 +62,40 @@ final class WeighbridgeReadingResource
#[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])] #[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])]
public ?int $weight = null; public ?int $weight = null;
/** Numero de pesee papier saisi en MANUAL (distinct du DSD, RG-5.04). */ /**
#[Assert\Length(max: 50, maxMessage: 'Le numéro de pesée ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')] * DSD de la pesee. En AUTO : fourni par le pont (lecture seule). En MANUAL :
* SAISI par l'operateur et conserve tel quel (ERP-193). Positif s'il est present
* (l'obligation en MANUAL est portee par le Callback ci-dessous).
*/
#[Assert\Positive(message: 'Le DSD doit être un entier positif.')]
#[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])] #[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])]
public ?string $manualNumber = null;
/** DSD attribue par le serveur (lecture seule) — previsionnel (cf. docbloc classe). */
#[Groups(['weighbridge_reading:read'])]
public ?int $dsd = null; public ?int $dsd = null;
/** /**
* RG metier : en pesee MANUAL, le poids est saisi par l'operateur (le pont * RG metier MANUAL : le pont n'est pas lu, l'operateur saisit le poids ET le DSD
* n'est pas lu) → il est obligatoire. Porte par un Callback pour que le 422 * → les deux sont obligatoires. Porte par un Callback pour que chaque 422 cible
* cible le propertyPath `weight` (mapping inline front, ERP-101). En AUTO, * son propertyPath (`weight` / `dsd`) et soit mappee inline (ERP-101). En AUTO,
* le poids fourni par le client est ignore (renseigne par le pont). * poids et DSD sont fournis par le pont (saisie client ignoree).
*/ */
#[Assert\Callback] #[Assert\Callback]
public function validateManualWeight(ExecutionContextInterface $context): void public function validateManualFields(ExecutionContextInterface $context): void
{ {
if ('MANUAL' === $this->mode && null === $this->weight) { if ('MANUAL' !== $this->mode) {
return;
}
if (null === $this->weight) {
$context->buildViolation('Le poids est obligatoire en pesée manuelle.') $context->buildViolation('Le poids est obligatoire en pesée manuelle.')
->atPath('weight') ->atPath('weight')
->addViolation() ->addViolation()
; ;
} }
if (null === $this->dsd) {
$context->buildViolation('Le DSD est obligatoire en pesée manuelle.')
->atPath('dsd')
->addViolation()
;
}
} }
} }
@@ -6,7 +6,6 @@ namespace App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProcessorInterface;
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
use App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface; use App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface;
use App\Module\Logistique\Domain\Exception\WeighbridgeUnavailableException; use App\Module\Logistique\Domain\Exception\WeighbridgeUnavailableException;
use App\Module\Logistique\Infrastructure\ApiPlatform\Resource\WeighbridgeReadingResource; use App\Module\Logistique\Infrastructure\ApiPlatform\Resource\WeighbridgeReadingResource;
@@ -23,7 +22,9 @@ use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
* - AUTO : lit le pont (WeighbridgeReaderInterface) → poids + DSD. Si la * - AUTO : lit le pont (WeighbridgeReaderInterface) → poids + DSD. Si la
* bascule est indisponible (WeighbridgeUnavailableException) → HTTP 503 * bascule est indisponible (WeighbridgeUnavailableException) → HTTP 503
* « Pont bascule indisponible — passez en pesee manuelle » (RG-5.06). * « Pont bascule indisponible — passez en pesee manuelle » (RG-5.06).
* - MANUAL : conserve le poids saisi et alloue le DSD (dernier + 1, RG-5.04). * - MANUAL : conserve le poids ET le DSD saisis par l'operateur tels quels — plus
* d'auto-increment (ERP-193 : le DSD saisi est la valeur du pont reellement
* utilisee, on ne la remplace pas).
* *
* @implements ProcessorInterface<WeighbridgeReadingResource, WeighbridgeReadingResource> * @implements ProcessorInterface<WeighbridgeReadingResource, WeighbridgeReadingResource>
*/ */
@@ -32,7 +33,6 @@ final class WeighbridgeReadingProcessor implements ProcessorInterface
public function __construct( public function __construct(
private readonly CurrentSiteProviderInterface $currentSiteProvider, private readonly CurrentSiteProviderInterface $currentSiteProvider,
private readonly WeighbridgeReaderInterface $weighbridgeReader, private readonly WeighbridgeReaderInterface $weighbridgeReader,
private readonly DsdAllocatorInterface $dsdAllocator,
) {} ) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): WeighbridgeReadingResource public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): WeighbridgeReadingResource
@@ -65,17 +65,15 @@ final class WeighbridgeReadingProcessor implements ProcessorInterface
); );
} }
$data->weight = $reading->weight; $data->weight = $reading->weight;
$data->dsd = $reading->dsd; $data->dsd = $reading->dsd;
$data->manualNumber = null; // pas de numero papier en mode bascule
return $data; return $data;
} }
// MANUAL : le poids est saisi (validateManualWeight garantit sa presence), // MANUAL : poids ET DSD sont saisis par l'operateur (validateManualFields
// seul le DSD est attribue serveur (dernier DSD du site + 1, RG-5.04). // garantit leur presence) et conserves tels quels — aucun auto-increment
$data->dsd = $this->dsdAllocator->next($site); // (ERP-193). Rien a recalculer cote serveur.
return $data; return $data;
} }
} }
@@ -67,14 +67,14 @@ final class WeighingTicketProcessor implements ProcessorInterface
return $this->persistProcessor->process($data, $operation, $uriVariables, $context); return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} }
// Une entite non geree par l'ORM = creation (POST) : site + numero ne sont // Une entite non geree par l'ORM = creation (POST). On rattache le site
// attribues qu'a ce moment et restent immuables ensuite (RG-5.09). // courant (cloisonnement + base de la numerotation), immuable ensuite
// (RG-5.09). Le NUMERO n'est PLUS attribue ici : un ticket nait « brouillon »
// (status DRAFT par defaut) et n'est numerote qu'a la validation (ERP-193).
$isNew = !$this->em->contains($data); $isNew = !$this->em->contains($data);
if ($isNew) { if ($isNew) {
$site = $this->resolveCurrentSite(); $data->setSite($this->resolveCurrentSite());
$data->setSite($site);
$data->setNumber($this->numberAllocator->allocate($site));
} }
$this->applyCounterpartyExclusivity($data); $this->applyCounterpartyExclusivity($data);
@@ -84,11 +84,23 @@ final class WeighingTicketProcessor implements ProcessorInterface
// depuis la base. Garde defensive si jamais il manque (ne devrait pas). // depuis la base. Garde defensive si jamais il manque (ne devrait pas).
$site = $data->getSite(); $site = $data->getSite();
if ($site instanceof Site) { if ($site instanceof Site) {
$this->allocateAutoDsd($data, $site, $isNew); $this->allocateAutoDsd($data, $site);
} }
$this->computeNetWeight($data); $this->computeNetWeight($data);
// Operation `validate` (« Valider », ERP-193) : transition brouillon -> valide.
// La validation stricte (groupe finalize : contrepartie + immat + 2 pesees) a
// deja joue en amont. On attribue le numero {siteCode}-TP-{NNNN} (compteur
// verrouille, RG-5.02 ; uniquement s'il n'existe pas encore, immuable) puis on
// passe le statut a VALIDATED.
if ('weighing_ticket_validate' === $operation->getName()) {
if (null === $data->getNumber() && $site instanceof Site) {
$data->setNumber($this->numberAllocator->allocate($site));
}
$data->setStatus(WeighingTicket::STATUS_VALIDATED);
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context); return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} }
@@ -162,21 +174,25 @@ final class WeighingTicketProcessor implements ProcessorInterface
} }
/** /**
* RG-5.04 : (re)attribution AUTORITAIRE du DSD pour chaque pesee AUTO via * RG-5.04 : le DSD d'une pesee est attribue A LA PESEE (POST /api/weighbridge_readings)
* DsdAllocator (verrou FOR UPDATE). A la creation, le DSD prévisionnel envoye * et CONSERVE tel quel sur le ticket — on ne le reattribue PAS au save. Raison :
* par le client (issu de POST /api/weighbridge_readings) est ecrase. Sur PATCH, * le DSD est l'index de pesee du pont, deja verrouille (FOR UPDATE) a l'emission ;
* on n'alloue que pour une pesee AUTO encore depourvue de DSD (ex. la pesee a * demain il proviendra directement du materiel (driver reel derriere
* plein realisee apres coup) — sinon on churne le compteur a chaque edition. * WeighbridgeReaderInterface) et devra etre persiste a l'identique. Reallouer ici
* Les pesees MANUELLES conservent leur DSD (deja alloue par l'endpoint de * ecraserait cet index (double comptage aujourd'hui, perte de l'index reel demain)
* pesee, « dernier + 1 »). * et ferait diverger le DSD previsionnel affiche du DSD enregistre.
*
* On n'alloue donc qu'en FILET DE SECURITE : pesee AUTO sans DSD (ex. ticket cree
* sans passer par l'endpoint de pesee). Les pesees MANUELLES conservent egalement
* leur DSD (alloue « dernier + 1 » par l'endpoint de pesee).
*/ */
private function allocateAutoDsd(WeighingTicket $data, Site $site, bool $isNew): void private function allocateAutoDsd(WeighingTicket $data, Site $site): void
{ {
if ('AUTO' === $data->getEmptyMode() && ($isNew || null === $data->getEmptyDsd())) { if ('AUTO' === $data->getEmptyMode() && null === $data->getEmptyDsd()) {
$data->setEmptyDsd($this->dsdAllocator->next($site)); $data->setEmptyDsd($this->dsdAllocator->next($site));
} }
if ('AUTO' === $data->getFullMode() && ($isNew || null === $data->getFullDsd())) { if ('AUTO' === $data->getFullMode() && null === $data->getFullDsd()) {
$data->setFullDsd($this->dsdAllocator->next($site)); $data->setFullDsd($this->dsdAllocator->next($site));
} }
} }
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use App\Module\Logistique\Domain\Repository\WeighingTicketRepositoryInterface;
use App\Module\Logistique\Infrastructure\Pdf\WeighingTicketPdfRenderer;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Sites\Domain\Entity\Site;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Provider de l'operation `GET /api/weighing_tickets/{id}/print.pdf` : sert le bon
* de pesee en PDF (M5, spec-back § 2.12 / § 4.6 — RG-5.08). Operation API Platform
* dediee (pas de controller, decision spec § 4.6) ; le binaire est genere par
* {@see WeighingTicketPdfRenderer} (template Twig -> Dompdf).
*
* Le provider retourne directement une {@see Response} : API Platform court-circuite
* alors la serialisation Hydra (le SerializeListener/RespondListener detectent une
* Response et la renvoient telle quelle). `Content-Type: application/pdf`,
* disposition `inline` (le front ouvre l'apercu — RG-5.08).
*
* Securite & visibilite — miroir de {@see WeighingTicketProvider::provideItem()} :
* - permission `logistique.weighing_tickets.view` portee par l'operation (403) ;
* - 404 si ticket introuvable, soft-delete (non expose au M5 — § 2.13), ou hors
* perimetre du site courant (anti-enumeration, § 2.3 / RG-5.09).
*
* @implements ProviderInterface<WeighingTicket>
*/
final class WeighingTicketPrintProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository')]
private readonly WeighingTicketRepositoryInterface $repository,
private readonly WeighingTicketPdfRenderer $renderer,
private readonly CurrentSiteProviderInterface $currentSiteProvider,
private readonly Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
{
$ticket = $this->findVisibleTicket($uriVariables['id'] ?? null);
if (null === $ticket) {
throw new NotFoundHttpException('Ticket de pesée introuvable.');
}
$pdf = $this->renderer->render($ticket);
$response = new Response($pdf);
$response->headers->set('Content-Type', 'application/pdf');
$response->headers->set(
'Content-Disposition',
sprintf('inline; filename="bon-pesee-%s.pdf"', $ticket->getNumber() ?? (string) $ticket->getId()),
);
return $response;
}
/**
* Charge le ticket visible par l'utilisateur courant, ou null (-> 404) :
* introuvable, soft-delete, ou hors perimetre du site courant. Logique
* identique a WeighingTicketProvider::provideItem() (cloisonnement § 2.3).
*/
private function findVisibleTicket(mixed $id): ?WeighingTicket
{
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
return null;
}
$ticket = $this->repository->findById((int) $id);
if (null === $ticket || null !== $ticket->getDeletedAt()) {
return null;
}
$scopeSite = $this->currentScopeSite();
if (null !== $scopeSite && $ticket->getSite()?->getId() !== $scopeSite->getId()) {
return null;
}
return $ticket;
}
/**
* Site servant a cloisonner, ou null si aucun cloisonnement ne s'applique
* (user `sites.bypass_scope`, ou pas de site courant). Miroir de
* WeighingTicketProvider::currentScopeSite().
*/
private function currentScopeSite(): ?Site
{
if ($this->security->isGranted('sites.bypass_scope')) {
return null;
}
return $this->currentSiteProvider->get();
}
}
@@ -146,8 +146,11 @@ final class WeighingTicketExportController
{ {
return [ return [
'Numéro', 'Numéro',
'Type contrepartie', // Contrepartie eclatee en 3 colonnes mutuellement exclusives (miroir de
'Contrepartie', // la liste / repertoire, ERP-193) plutot que « type + nom ».
'Fournisseur',
'Client',
'Autre',
'Date', 'Date',
'Immatriculation', 'Immatriculation',
'Poids vide (kg)', 'Poids vide (kg)',
@@ -155,6 +158,7 @@ final class WeighingTicketExportController
'Poids net (kg)', 'Poids net (kg)',
'DSD vide', 'DSD vide',
'DSD plein', 'DSD plein',
'Statut',
]; ];
} }
@@ -166,10 +170,14 @@ final class WeighingTicketExportController
private function buildRows(array $tickets): iterable private function buildRows(array $tickets): iterable
{ {
foreach ($tickets as $ticket) { foreach ($tickets as $ticket) {
$type = $ticket->getCounterpartyType();
yield [ yield [
$ticket->getNumber(), $ticket->getNumber() ?? '',
$this->counterpartyTypeLabel($ticket->getCounterpartyType()), // Une seule des 3 colonnes est renseignee selon le type (RG-5.03).
$this->counterpartyName($ticket), 'FOURNISSEUR' === $type ? ($ticket->getSupplier()?->getCompanyName() ?? '') : '',
'CLIENT' === $type ? ($ticket->getClient()?->getCompanyName() ?? '') : '',
'AUTRE' === $type ? ($ticket->getOtherLabel() ?? '') : '',
$ticket->getDisplayDate()?->format('d/m/Y H:i') ?? '', $ticket->getDisplayDate()?->format('d/m/Y H:i') ?? '',
$ticket->getImmatriculation() ?? '', $ticket->getImmatriculation() ?? '',
$ticket->getEmptyWeight() ?? '', $ticket->getEmptyWeight() ?? '',
@@ -177,36 +185,22 @@ final class WeighingTicketExportController
$ticket->getNetWeight() ?? '', $ticket->getNetWeight() ?? '',
$ticket->getEmptyDsd() ?? '', $ticket->getEmptyDsd() ?? '',
$ticket->getFullDsd() ?? '', $ticket->getFullDsd() ?? '',
$this->statusLabel($ticket->getStatus()),
]; ];
} }
} }
/** /**
* Libelle FR du type de contrepartie (RG-5.03). Renvoie la valeur brute pour * Libelle FR du statut du cycle de vie (ERP-193) : « En attente » (DRAFT) ou
* une valeur inattendue (garde-fou : ne masque pas une donnee corrompue). * « Terminée » (VALIDATED). Renvoie la valeur brute pour une valeur inattendue
* (garde-fou : ne masque pas une donnee corrompue).
*/ */
private function counterpartyTypeLabel(?string $type): string private function statusLabel(string $status): string
{ {
return match ($type) { return match ($status) {
'CLIENT' => 'Client', WeighingTicket::STATUS_DRAFT => 'En attente',
'FOURNISSEUR' => 'Fournisseur', WeighingTicket::STATUS_VALIDATED => 'Terminée',
'AUTRE' => 'Autre', default => $status,
default => $type ?? '',
};
}
/**
* Nom de la contrepartie selon le type (RG-5.03) : raison sociale du client,
* du fournisseur, ou libelle libre « Autre ». Client / Supplier sont
* fetch-joines par le repository (anti N+1, § 4.0).
*/
private function counterpartyName(WeighingTicket $ticket): string
{
return match ($ticket->getCounterpartyType()) {
'CLIENT' => $ticket->getClient()?->getCompanyName() ?? '',
'FOURNISSEUR' => $ticket->getSupplier()?->getCompanyName() ?? '',
'AUTRE' => $ticket->getOtherLabel() ?? '',
default => '',
}; };
} }
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Infrastructure\Pdf;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use Dompdf\Dompdf;
use Dompdf\Options;
use Twig\Environment;
/**
* Rend le ticket de pesee (M5, spec-back § 2.12 / § 4.6 — RG-5.08) : hydrate le
* template Twig `logistique/weighing_ticket_print.html.twig` avec le ticket, puis
* convertit le HTML en PDF via Dompdf (pur PHP, aucune dependance systeme — choix
* valide avec Matthieu, ERP-192).
*
* Le gabarit reproduit le modele fourni (ticket_pesee.pdf) : en-tete FIXE (logo +
* identite societe), titre, les deux pesees (poids / N° pesee / DSD + date) et le
* poids net. Le rendu ne depend PAS du site (decision Tristan, ERP-192) : le logo
* et l'identite societe sont constants.
*
* Service technique d'infrastructure (pas de logique metier) : le contenu/affiche
* est decide par le template ; ICI on ne fait que charger le logo et generer le
* binaire.
*/
final class WeighingTicketPdfRenderer
{
/** Logo societe embarque dans l'en-tete (fixe, hors versioning par site). */
private const string LOGO_PATH = __DIR__.'/assets/logo-lpc-liot.png';
public function __construct(
private readonly Environment $twig,
) {}
/**
* Genere le binaire PDF du ticket de pesee pour un ticket donne.
*
* Dompdf : remote desactive (aucune ressource externe chargee — securite ; le
* logo passe en data-URI), A4 portrait, police par defaut DejaVu Sans (UTF-8
* -> accents FR et « ° » corrects).
*/
public function render(WeighingTicket $ticket): string
{
$html = $this->twig->render('logistique/weighing_ticket_print.html.twig', [
'ticket' => $ticket,
'logoSrc' => $this->logoDataUri(),
]);
$options = new Options();
$options->set('isRemoteEnabled', false);
$options->set('defaultFont', 'DejaVu Sans');
$dompdf = new Dompdf($options);
$dompdf->loadHtml($html, 'UTF-8');
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
return (string) $dompdf->output();
}
/**
* Logo societe encode en data-URI base64, ou null s'il est introuvable (le
* template degrade alors sans bloquer la generation du PDF).
*/
private function logoDataUri(): ?string
{
$binary = @file_get_contents(self::LOGO_PATH);
if (false === $binary) {
return null;
}
return 'data:image/png;base64,'.base64_encode($binary);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

@@ -553,28 +553,27 @@ final class ColumnCommentsCatalog
// -> app:apply-column-comments les rejoue depuis ce catalogue. Strings // -> app:apply-column-comments les rejoue depuis ce catalogue. Strings
// identiques aux COMMENT de la migration Version20260617150000. // identiques aux COMMENT de la migration Version20260617150000.
'weighing_ticket' => [ 'weighing_ticket' => [
'_table' => 'Tickets de pesee (M5 Logistique) — pesee a vide + a plein au pont bascule, contrepartie Client/Fournisseur/Autre. Cloisonne par site courant.', '_table' => 'Tickets de pesee (M5 Logistique) — pesee a vide + a plein au pont bascule, contrepartie Client/Fournisseur/Autre. Cloisonne par site courant.',
'id' => 'Identifiant interne auto-incremente.', 'id' => 'Identifiant interne auto-incremente.',
'site_id' => 'Site du pont bascule (cloisonnement § 2.3). FK -> site.id, ON DELETE RESTRICT. Renseigne serveur depuis le site courant, immuable (RG-5.09).', 'site_id' => 'Site du pont bascule (cloisonnement § 2.3). FK -> site.id, ON DELETE RESTRICT. Renseigne serveur depuis le site courant, immuable (RG-5.09).',
'number' => 'Numero {siteCode}-TP-{NNNN}, unique par site (uq_weighing_ticket_number), immuable. Sequence weighing_ticket_counter (RG-5.02).', 'number' => 'Numero {siteCode}-TP-{NNNN}, unique par site (uq_weighing_ticket_number), immuable. NULL tant que brouillon : attribue a la validation (RG-5.02, ERP-193).',
'counterparty_type' => 'Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (chk_wt_counterparty_type, RG-5.03). Pilote l obligation client_id / supplier_id / other_label.', 'counterparty_type' => 'Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (chk_wt_counterparty_type, RG-5.03). NULL tant que brouillon, requise a la validation. Pilote l obligation client_id / supplier_id / other_label.',
'client_id' => 'Branche CLIENT (RG-5.03) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi counterparty_type = CLIENT, nul sinon (chk_wt_client_branch).', 'client_id' => 'Branche CLIENT (RG-5.03) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi counterparty_type = CLIENT, nul sinon (chk_wt_client_branch).',
'supplier_id' => 'Branche FOURNISSEUR (RG-5.03) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi counterparty_type = FOURNISSEUR (chk_wt_supplier_branch).', 'supplier_id' => 'Branche FOURNISSEUR (RG-5.03) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi counterparty_type = FOURNISSEUR (chk_wt_supplier_branch).',
'other_label' => 'Branche AUTRE (RG-5.03) : libelle libre de la contrepartie. Requis ssi counterparty_type = AUTRE, nul sinon (chk_wt_other_branch).', 'other_label' => 'Branche AUTRE (RG-5.03) : libelle libre de la contrepartie. Requis ssi counterparty_type = AUTRE, nul sinon (chk_wt_other_branch).',
'immatriculation' => 'Plaque du vehicule, partagee entre pesee vide et plein. Masque XX-000-XX sauf si plate_free_format (RG-5.01). Normalisee serveur (trim/UPPER).', 'immatriculation' => 'Plaque du vehicule, partagee entre pesee vide et plein. NULL tant que brouillon, requise a la validation. Masque XX-000-XX sauf si plate_free_format (RG-5.01). Normalisee serveur (trim/UPPER).',
'plate_free_format' => '« Tout format » : desactive le masque XX-000-XX de l immatriculation (RG-5.01). Partage entre les 2 formulaires. Faux par defaut.', 'plate_free_format' => '« Tout format » : desactive le masque XX-000-XX de l immatriculation (RG-5.01). Partage entre les 2 formulaires. Faux par defaut.',
'empty_date' => 'Date/heure de la pesee a vide (tare). Defaut jour courant cote front (RG-5.07). Null tant que la pesee vide n est pas faite.', 'empty_date' => 'Date/heure de la pesee a vide (tare). Defaut jour courant cote front (RG-5.07). Null tant que la pesee vide n est pas faite.',
'empty_weight' => 'Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07).', 'empty_weight' => 'Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07).',
'empty_dsd' => 'Compteur DSD du pont a la pesee a vide. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).', 'empty_dsd' => 'Compteur DSD du pont a la pesee a vide. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).',
'empty_mode' => 'Mode de la pesee a vide : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_empty_mode (RG-5.06).', 'empty_mode' => 'Mode de la pesee a vide : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_empty_mode (RG-5.06).',
'empty_manual_number' => 'Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a vide (RG-5.04).', 'full_date' => 'Date/heure de la pesee a plein (brut). Null tant que la pesee plein n est pas faite.',
'full_date' => 'Date/heure de la pesee a plein (brut). Null tant que la pesee plein n est pas faite.', 'full_weight' => 'Poids a plein (brut) en kg — readonly UI, rempli par la pesee (RG-5.07).',
'full_weight' => 'Poids a plein (brut) en kg — readonly UI, rempli par la pesee (RG-5.07).', 'full_dsd' => 'Compteur DSD du pont a la pesee a plein. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).',
'full_dsd' => 'Compteur DSD du pont a la pesee a plein. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).', 'full_mode' => 'Mode de la pesee a plein : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_full_mode (RG-5.06).',
'full_mode' => 'Mode de la pesee a plein : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_full_mode (RG-5.06).', 'net_weight' => 'Poids net = full_weight - empty_weight (kg), calcule serveur (RG-5.05). Null si une pesee manque. Colonne Poids de la liste.',
'full_manual_number' => 'Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a plein (RG-5.04).', 'status' => 'Cycle de vie (ERP-193) : DRAFT (« En attente », pesee enregistree sans contrepartie/immat) ou VALIDATED (« Terminée », valide avec numero). chk_wt_status. Defaut DRAFT.',
'net_weight' => 'Poids net = full_weight - empty_weight (kg), calcule serveur (RG-5.05). Null si une pesee manque. Colonne Poids de la liste.', 'deleted_at' => 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.',
'deleted_at' => 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.',
] + self::timestampableBlamableComments(), ] + self::timestampableBlamableComments(),
]; ];
} }
@@ -0,0 +1,78 @@
{#
Ticket de pesée (M5 Logistique) — gabarit imprimable hydraté côté serveur puis
converti en PDF par WeighingTicketPdfRenderer (Dompdf). Cf. spec-back M5 § 2.12
/ § 4.6 (RG-5.08). Reproduit fidèlement le modèle fourni (ticket_pesee.pdf).
En-tête FIXE (logo + identité société) : le ticket ne change pas en fonction du
site (décision Tristan, ERP-192). Le logo est injecté en data-URI par le renderer
(logoSrc) ; l'identité société est en dur ci-dessous.
Contraintes Dompdf : CSS2.1 (pas de flexbox/grid), mise en page par tableaux.
Police DejaVu Sans (UTF-8 — accents FR et « ° » rendus correctement).
#}
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<style>
@page { margin: 18mm 16mm; }
* { font-family: "DejaVu Sans", sans-serif; }
body { color: #000; font-size: 10px; margin: 0; }
.logo { margin-bottom: 16px; }
.logo img { height: 100px; }
.company-name { font-weight: bold; font-size: 12px; }
.company-line { font-size: 12px; }
.title { font-size: 22px; font-weight: bold; margin: 22px 0 18px; }
/* Lignes des deux pesées : tableau sans bordure, colonnes alignées. */
.weighings { border-collapse: collapse; font-size: 12px; }
.weighings td { vertical-align: top; white-space: nowrap; }
.weighings .c-label { width: 130px; }
.weighings .c-weight { width: 95px; }
.weighings .c-num { width: 175px; }
.weighings .c-dsd { width: auto; }
.net { font-size: 18px; font-weight: bold; margin-top: 26px; }
</style>
</head>
<body>
{% if logoSrc %}
<div class="logo"><img src="{{ logoSrc }}" alt="LPC LIOT"></div>
{% endif %}
<div class="company-name">SA LIOT Châtellerault</div>
<div class="company-line">Email : lpc.contacts@lpc-liot.fr</div>
<div class="company-line">RCS Châtellerault B 339 505 612</div>
<div class="title">Ticket de pesée</div>
{#
DSD de la pesée : valeur du pont en AUTO, valeur saisie par l'opérateur en
MANUAL (ERP-193). Un seul champ `dsd` dans les deux cas.
#}
{% set emptyRef = ticket.emptyDsd %}
{% set fullRef = ticket.fullDsd %}
<table class="weighings">
<tr>
<td class="c-label">Poids à vide</td>
<td class="c-weight">{{ ticket.emptyWeight is not null ? ticket.emptyWeight ~ ' kg' : '' }}</td>
<td class="c-num">N° pesée à vide</td>
<td class="c-dsd">{% if emptyRef is not null %}DSD : {{ emptyRef }}{% endif %}{% if ticket.emptyDate %} {{ ticket.emptyDate|date('d/m/Y H:i:s') }}{% endif %}</td>
</tr>
<tr>
<td class="c-label">Poids à plein</td>
<td class="c-weight">{{ ticket.fullWeight is not null ? ticket.fullWeight ~ ' kg' : '' }}</td>
<td class="c-num">N° pesée à plein</td>
<td class="c-dsd">{% if fullRef is not null %}DSD : {{ fullRef }}{% endif %}{% if ticket.fullDate %} {{ ticket.fullDate|date('d/m/Y H:i:s') }}{% endif %}</td>
</tr>
</table>
<div class="net">Poids : {{ ticket.netWeight is not null ? ticket.netWeight ~ ' kg' : '—' }}</div>
</body>
</html>
@@ -220,7 +220,8 @@ abstract class AbstractWeighingTicketApiTestCase extends AbstractApiTestCase
/** /**
* POST un ticket et renvoie la reponse (assertions de statut a la charge de * POST un ticket et renvoie la reponse (assertions de statut a la charge de
* l'appelant). * l'appelant). Cree un BROUILLON (status DRAFT, sans numero, ERP-193) — la
* validation est portee par validateTicket().
*/ */
protected function postTicket(Client $http, array $payload): ResponseInterface protected function postTicket(Client $http, array $payload): ResponseInterface
{ {
@@ -230,6 +231,32 @@ abstract class AbstractWeighingTicketApiTestCase extends AbstractApiTestCase
]); ]);
} }
/**
* « Valider » un ticket : PATCH /weighing_tickets/{id}/validate (ERP-193).
* Declenche la validation stricte (groupe finalize) + attribution du numero +
* passage en VALIDATED. Body vide par defaut = on valide l'etat deja persiste.
*/
protected function validateTicket(Client $http, int $id, array $payload = []): ResponseInterface
{
return $http->request('PATCH', '/api/weighing_tickets/'.$id.'/validate', [
'headers' => ['Content-Type' => self::MERGE],
'json' => [] === $payload ? new \stdClass() : $payload,
]);
}
/**
* POST un brouillon complet puis le valide ; renvoie le ticket VALIDE (numero
* attribue). Le payload doit porter contrepartie + immatriculation + 2 pesees.
*
* @return array<string, mixed>
*/
protected function createValidatedTicket(Client $http, array $payload): array
{
$id = (int) $this->postTicket($http, $payload)->toArray()['id'];
return $this->validateTicket($http, $id)->toArray();
}
/** /**
* Retrouve un membre d'une collection Hydra par son id. * Retrouve un membre d'une collection Hydra par son id.
* *
@@ -56,18 +56,16 @@ final class WeighbridgeReadingApiTest extends AbstractApiTestCase
self::assertLessThanOrEqual(50000, $data['weight']); self::assertLessThanOrEqual(50000, $data['weight']);
self::assertIsInt($data['dsd']); self::assertIsInt($data['dsd']);
self::assertGreaterThanOrEqual(1, $data['dsd']); self::assertGreaterThanOrEqual(1, $data['dsd']);
// manualNumber est null en mode bascule (cle potentiellement omise si
// skip_null_values est actif — tolerant aux deux cas).
self::assertNull($data['manualNumber'] ?? null);
} }
public function testManualWeighingKeepsWeightAndAllocatesDsd(): void public function testManualWeighingKeepsWeightAndEnteredDsd(): void
{ {
$client = $this->manageClientWithCurrentSite(); $client = $this->manageClientWithCurrentSite();
$response = $client->request('POST', '/api/weighbridge_readings', [ $response = $client->request('POST', '/api/weighbridge_readings', [
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['mode' => 'MANUAL', 'weight' => 23187, 'manualNumber' => 'PAP-555'], // Le DSD est SAISI par l'operateur et conserve tel quel (ERP-193).
'json' => ['mode' => 'MANUAL', 'weight' => 23187, 'dsd' => 16619],
]); ]);
self::assertResponseStatusCodeSame(200); self::assertResponseStatusCodeSame(200);
@@ -75,8 +73,7 @@ final class WeighbridgeReadingApiTest extends AbstractApiTestCase
self::assertSame('MANUAL', $data['mode']); self::assertSame('MANUAL', $data['mode']);
self::assertSame(23187, $data['weight']); self::assertSame(23187, $data['weight']);
self::assertSame('PAP-555', $data['manualNumber']); self::assertSame(16619, $data['dsd'], 'Le DSD saisi est conserve, pas d\'auto-increment.');
self::assertGreaterThanOrEqual(1, $data['dsd']);
} }
public function testManagePermissionIsRequired(): void public function testManagePermissionIsRequired(): void
@@ -117,11 +114,25 @@ final class WeighbridgeReadingApiTest extends AbstractApiTestCase
'json' => ['mode' => 'MANUAL'], 'json' => ['mode' => 'MANUAL'],
]); ]);
// Garde-fou ERP-101 : la 422 doit cibler `weight` (Callback validateManualWeight). // Garde-fou ERP-101 : la 422 doit cibler `weight` (Callback validateManualFields).
self::assertResponseStatusCodeSame(422); self::assertResponseStatusCodeSame(422);
self::assertViolationOnPath($response, 'weight'); self::assertViolationOnPath($response, 'weight');
} }
public function testManualWeighingRequiresDsd(): void
{
$client = $this->manageClientWithCurrentSite();
$response = $client->request('POST', '/api/weighbridge_readings', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['mode' => 'MANUAL', 'weight' => 23187],
]);
// En manuel, le DSD est saisi → obligatoire (Callback validateManualFields).
self::assertResponseStatusCodeSame(422);
self::assertViolationOnPath($response, 'dsd');
}
/** /**
* Garde-fou ERP-101 (miroir AbstractWeighingTicketApiTestCase) : une 422 doit * Garde-fou ERP-101 (miroir AbstractWeighingTicketApiTestCase) : une 422 doit
* porter une violation sur le `propertyPath` attendu, consommable inline par * porter une violation sur le `propertyPath` attendu, consommable inline par
@@ -72,8 +72,10 @@ final class WeighingTicketExportControllerTest extends AbstractApiTestCase
// 1re ligne = en-tetes attendus (ordre des colonnes § 4.5). // 1re ligne = en-tetes attendus (ordre des colonnes § 4.5).
$header = $this->gridFromResponse($response->getContent())[0]; $header = $this->gridFromResponse($response->getContent())[0];
self::assertSame('Numéro', $header[0]); self::assertSame('Numéro', $header[0]);
self::assertContains('Type contrepartie', $header); // Contrepartie eclatee en 3 colonnes (miroir liste, ERP-193).
self::assertContains('Contrepartie', $header); self::assertContains('Fournisseur', $header);
self::assertContains('Client', $header);
self::assertContains('Autre', $header);
self::assertContains('Date', $header); self::assertContains('Date', $header);
self::assertContains('Immatriculation', $header); self::assertContains('Immatriculation', $header);
self::assertContains('Poids vide (kg)', $header); self::assertContains('Poids vide (kg)', $header);
@@ -81,6 +83,7 @@ final class WeighingTicketExportControllerTest extends AbstractApiTestCase
self::assertContains('Poids net (kg)', $header); self::assertContains('Poids net (kg)', $header);
self::assertContains('DSD vide', $header); self::assertContains('DSD vide', $header);
self::assertContains('DSD plein', $header); self::assertContains('DSD plein', $header);
self::assertContains('Statut', $header);
} }
/** /**
@@ -99,8 +102,11 @@ final class WeighingTicketExportControllerTest extends AbstractApiTestCase
$cell = static fn (string $label) => $row[array_search($label, $header, true)] ?? null; $cell = static fn (string $label) => $row[array_search($label, $header, true)] ?? null;
self::assertSame('Client', $cell('Type contrepartie')); // Contrepartie Client → colonne « Client » renseignée, « Fournisseur » / « Autre » vides.
self::assertStringContainsString('BÉTON SA', (string) $cell('Contrepartie')); self::assertStringContainsString('BÉTON SA', (string) $cell('Client'));
self::assertSame('', (string) $cell('Fournisseur'));
self::assertSame('', (string) $cell('Autre'));
self::assertSame('Terminée', $cell('Statut'));
self::assertSame('AB-123-CD', $cell('Immatriculation')); self::assertSame('AB-123-CD', $cell('Immatriculation'));
self::assertSame(7150, (int) $cell('Poids vide (kg)')); self::assertSame(7150, (int) $cell('Poids vide (kg)'));
self::assertSame(14300, (int) $cell('Poids plein (kg)')); self::assertSame(14300, (int) $cell('Poids plein (kg)'));
@@ -184,6 +190,7 @@ final class WeighingTicketExportControllerTest extends AbstractApiTestCase
$ticket->setFullDsd(42); $ticket->setFullDsd(42);
$ticket->setFullMode('AUTO'); $ticket->setFullMode('AUTO');
$ticket->setNetWeight(7150); $ticket->setNetWeight(7150);
$ticket->setStatus(WeighingTicket::STATUS_VALIDATED);
$em->persist($ticket); $em->persist($ticket);
$em->flush(); $em->flush();
@@ -0,0 +1,92 @@
<?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 testValidateRequiresCounterparty(): void
{
$http = $this->authManageOnSite($this->siteByCode('86'));
// Brouillon complet cote pesees + immatriculation, mais SANS contrepartie.
$id = (int) $this->postTicket($http, [
'immatriculation' => 'AB-123-CD',
'emptyDate' => '2026-06-17T09:00:00+02:00',
'emptyWeight' => 7150,
'emptyMode' => 'AUTO',
'fullDate' => '2026-06-17T09:12:00+02:00',
'fullWeight' => 14300,
'fullMode' => 'AUTO',
])->toArray()['id'];
$response = $this->validateTicket($http, $id);
self::assertResponseStatusCodeSame(422);
self::assertViolationOnPath($response, 'counterpartyType');
}
public function testValidateRequiresBothWeighings(): void
{
$http = $this->authManageOnSite($this->siteByCode('86'));
$client = $this->seedTestClient('Lifecycle');
// Brouillon avec contrepartie + immat + UNE seule pesee (a vide).
$id = (int) $this->postTicket($http, [
'counterpartyType' => 'CLIENT',
'client' => $this->clientIri($client),
'immatriculation' => 'AB-123-CD',
'emptyDate' => '2026-06-17T09:00:00+02:00',
'emptyWeight' => 7150,
'emptyMode' => 'AUTO',
])->toArray()['id'];
$response = $this->validateTicket($http, $id);
self::assertResponseStatusCodeSame(422);
self::assertViolationOnPath($response, 'fullWeight');
}
public function testValidateAssignsNumberAndStatus(): void
{
$http = $this->authManageOnSite($this->siteByCode('86'));
$client = $this->seedTestClient('LifecycleOk');
$validated = $this->createValidatedTicket($http, $this->validClientTicketPayload($client));
self::assertSame('VALIDATED', $validated['status']);
self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', (string) $validated['number']);
self::assertSame(7150, $validated['netWeight']);
}
}
@@ -26,13 +26,12 @@ final class WeighingTicketNumberingTest extends AbstractWeighingTicketApiTestCas
$http = $this->authManageOnSite($site); $http = $this->authManageOnSite($site);
$client = $this->seedTestClient('Num'); $client = $this->seedTestClient('Num');
$first = $this->postTicket($http, $this->validClientTicketPayload($client)); // Le numero est attribue a la VALIDATION (brouillon -> valide, ERP-193).
self::assertResponseStatusCodeSame(201); $first = $this->createValidatedTicket($http, $this->validClientTicketPayload($client));
$second = $this->postTicket($http, $this->validClientTicketPayload($client)); $second = $this->createValidatedTicket($http, $this->validClientTicketPayload($client));
self::assertResponseStatusCodeSame(201);
$n1 = (string) $first->toArray()['number']; $n1 = (string) $first['number'];
$n2 = (string) $second->toArray()['number']; $n2 = (string) $second['number'];
self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n1); self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n1);
self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n2); self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n2);
@@ -49,8 +48,8 @@ final class WeighingTicketNumberingTest extends AbstractWeighingTicketApiTestCas
$http86 = $this->authManageOnSite($this->siteByCode('86')); $http86 = $this->authManageOnSite($this->siteByCode('86'));
$http17 = $this->authManageOnSite($this->siteByCode('17')); $http17 = $this->authManageOnSite($this->siteByCode('17'));
$n86 = (string) $this->postTicket($http86, $this->validClientTicketPayload($client))->toArray()['number']; $n86 = (string) $this->createValidatedTicket($http86, $this->validClientTicketPayload($client))['number'];
$n17 = (string) $this->postTicket($http17, $this->validClientTicketPayload($client))->toArray()['number']; $n17 = (string) $this->createValidatedTicket($http17, $this->validClientTicketPayload($client))['number'];
// Chaque site encode son propre code dans le numero ; sequences disjointes. // Chaque site encode son propre code dans le numero ; sequences disjointes.
self::assertStringStartsWith('86-TP-', $n86); self::assertStringStartsWith('86-TP-', $n86);
@@ -63,7 +62,8 @@ final class WeighingTicketNumberingTest extends AbstractWeighingTicketApiTestCas
$http = $this->authManageOnSite($site); $http = $this->authManageOnSite($site);
$client = $this->seedTestClient('Immutable'); $client = $this->seedTestClient('Immutable');
$created = $this->postTicket($http, $this->validClientTicketPayload($client))->toArray(); // Ticket valide (numero attribue) puis tentative de re-ecriture.
$created = $this->createValidatedTicket($http, $this->validClientTicketPayload($client));
$id = (int) $created['id']; $id = (int) $created['id'];
$number = (string) $created['number']; $number = (string) $created['number'];
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Api;
/**
* Tests fonctionnels de l'impression du bon de pesee PDF (M5, spec-back § 2.12 /
* § 4.6 — RG-5.08, ERP-192) : operation `GET /api/weighing_tickets/{id}/print.pdf`.
*
* Couvre la verification du ticket :
* - 200 + PDF non vide (Content-Type application/pdf, disposition inline,
* signature %PDF) pour un ticket existant et visible ;
* - 403 sans la permission `logistique.weighing_tickets.view` ;
* - 404 pour un ticket inexistant.
*
* @internal
*/
final class WeighingTicketPrintApiTest extends AbstractWeighingTicketApiTestCase
{
public function testPrintReturnsNonEmptyPdfForExistingTicket(): void
{
$site = $this->firstSite();
$http = $this->authManageOnSite($site);
$client = $this->seedTestClient('Print');
$created = $this->postTicket($http, $this->validClientTicketPayload($client));
self::assertResponseStatusCodeSame(201);
$ticketId = $created->toArray()['id'];
$response = $http->request('GET', sprintf('/api/weighing_tickets/%d/print.pdf', $ticketId));
self::assertResponseIsSuccessful();
$headers = $response->getHeaders(false);
self::assertStringContainsString('application/pdf', $headers['content-type'][0] ?? '');
self::assertStringContainsString('inline', $headers['content-disposition'][0] ?? '');
// PDF non vide + signature de fichier PDF (« %PDF-1.x »).
$binary = $response->getContent(false);
self::assertNotSame('', $binary, 'Le PDF du bon de pesée ne doit pas être vide.');
self::assertStringStartsWith('%PDF', $binary);
}
public function testForbiddenWithoutViewPermission(): void
{
// On seede un ticket reel via un user habilite, puis on tente l'impression
// avec un user depourvu de `logistique.weighing_tickets.view`.
$site = $this->firstSite();
$manager = $this->authManageOnSite($site);
$client = $this->seedTestClient('Forbidden');
$created = $this->postTicket($manager, $this->validClientTicketPayload($client));
self::assertResponseStatusCodeSame(201);
$ticketId = $created->toArray()['id'];
$creds = $this->createUserWithPermission('core.users.view');
$intrus = $this->authenticatedClient($creds['username'], $creds['password']);
$intrus->request('GET', sprintf('/api/weighing_tickets/%d/print.pdf', $ticketId));
self::assertResponseStatusCodeSame(403);
}
public function testNotFoundForUnknownTicket(): void
{
$http = $this->authManageOnSite($this->firstSite());
$http->request('GET', '/api/weighing_tickets/99999999/print.pdf');
self::assertResponseStatusCodeSame(404);
}
}
@@ -31,12 +31,12 @@ final class WeighingTicketSerializationContractTest extends AbstractWeighingTick
$http = $this->authManageOnSite($site); $http = $this->authManageOnSite($site);
$clientEntity = $this->seedTestClient('Negoce'); $clientEntity = $this->seedTestClient('Negoce');
$created = $this->postTicket($http, $this->validClientTicketPayload($clientEntity)); // Brouillon cree puis valide (numero attribue a la validation, ERP-193).
self::assertResponseStatusCodeSame(201); $createdBody = $this->createValidatedTicket($http, $this->validClientTicketPayload($clientEntity));
$createdBody = $created->toArray();
$id = (int) $createdBody['id']; $id = (int) $createdBody['id'];
$number = (string) $createdBody['number']; $number = (string) $createdBody['number'];
self::assertSame('VALIDATED', $createdBody['status']);
$detail = $http->request('GET', '/api/weighing_tickets/'.$id, ['headers' => ['Accept' => self::LD]])->toArray(); $detail = $http->request('GET', '/api/weighing_tickets/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
$list = $http->request('GET', '/api/weighing_tickets?search='.$number, ['headers' => ['Accept' => self::LD]])->toArray(); $list = $http->request('GET', '/api/weighing_tickets?search='.$number, ['headers' => ['Accept' => self::LD]])->toArray();
@@ -69,6 +69,9 @@ final class WeighingTicketSerializationContractTest extends AbstractWeighingTick
// displayDate (date du ticket = fullDate ?? emptyDate) expose en liste. // displayDate (date du ticket = fullDate ?? emptyDate) expose en liste.
self::assertArrayHasKey('displayDate', $row); self::assertArrayHasKey('displayDate', $row);
// Statut du cycle de vie expose en liste (colonne « En attente / Terminée »).
self::assertSame('VALIDATED', $row['status']);
// === DETAIL : site embarque (avec code), immatriculation, les 2 pesees === // === DETAIL : site embarque (avec code), immatriculation, les 2 pesees ===
self::assertIsArray($detail['site']); self::assertIsArray($detail['site']);
self::assertSame('86', $detail['site']['code']); self::assertSame('86', $detail['site']['code']);
@@ -95,9 +98,7 @@ final class WeighingTicketSerializationContractTest extends AbstractWeighingTick
$http = $this->authManageOnSite($site); $http = $this->authManageOnSite($site);
$supplierEntity = $this->seedTestSupplier('Ferraille'); $supplierEntity = $this->seedTestSupplier('Ferraille');
$created = $this->postTicket($http, $this->validSupplierTicketPayload($supplierEntity)); $createdBody = $this->createValidatedTicket($http, $this->validSupplierTicketPayload($supplierEntity));
self::assertResponseStatusCodeSame(201);
$createdBody = $created->toArray();
$id = (int) $createdBody['id']; $id = (int) $createdBody['id'];
$number = (string) $createdBody['number']; $number = (string) $createdBody['number'];
@@ -145,14 +145,17 @@ final class CounterpartyValidationTest extends TestCase
} }
/** /**
* Liste des propertyPath des violations de l'entite. * Liste des propertyPath des violations de l'entite, validee dans le groupe
* `finalize` (la coherence contrepartie ne joue qu'a la validation depuis
* ERP-193 ; un brouillon peut ne pas porter de contrepartie). Miroir du
* validationContext de l'operation `validate` (['Default', 'finalize']).
* *
* @return list<string> * @return list<string>
*/ */
private function violationPaths(WeighingTicket $ticket): array private function violationPaths(WeighingTicket $ticket): array
{ {
$paths = []; $paths = [];
foreach ($this->validator->validate($ticket) as $violation) { foreach ($this->validator->validate($ticket, null, ['Default', 'finalize']) as $violation) {
$paths[] = $violation->getPropertyPath(); $paths[] = $violation->getPropertyPath();
} }
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Tests\Module\Logistique\Infrastructure\ApiPlatform\State\Processor; namespace App\Tests\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
use App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface; use App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface;
use App\Module\Logistique\Domain\Exception\WeighbridgeUnavailableException; use App\Module\Logistique\Domain\Exception\WeighbridgeUnavailableException;
use App\Module\Logistique\Domain\Weighbridge\WeighbridgeReading; use App\Module\Logistique\Domain\Weighbridge\WeighbridgeReading;
@@ -21,8 +20,8 @@ use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
* Processor de l'action `POST /api/weighbridge_readings` (§ 4.2). * Processor de l'action `POST /api/weighbridge_readings` (§ 4.2).
* *
* Couvre les 4 chemins sans BDD ni HTTP (stubs purs) : AUTO (lecture pont), * Couvre les 4 chemins sans BDD ni HTTP (stubs purs) : AUTO (lecture pont),
* MANUAL (allocation DSD seule), indisponibilite → 503 (RG-5.06) et absence de * MANUAL (poids ET DSD saisis conserves tels quels, ERP-193), indisponibilite →
* site courant → 400. * 503 (RG-5.06) et absence de site courant → 400.
* *
* @internal * @internal
*/ */
@@ -30,8 +29,7 @@ final class WeighbridgeReadingProcessorTest extends TestCase
{ {
private function site(): Site private function site(): Site
{ {
// getId() reste null (non persiste) — sans incidence : reader et allocator // getId() reste null (non persiste) — sans incidence : reader stubbe.
// sont stubbes dans ces tests unitaires.
return new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233'); return new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233');
} }
@@ -43,11 +41,7 @@ final class WeighbridgeReadingProcessorTest extends TestCase
$reader = $this->createStub(WeighbridgeReaderInterface::class); $reader = $this->createStub(WeighbridgeReaderInterface::class);
$reader->method('read')->willReturn(new WeighbridgeReading(23000, 42)); $reader->method('read')->willReturn(new WeighbridgeReading(23000, 42));
$processor = new WeighbridgeReadingProcessor( $processor = new WeighbridgeReadingProcessor($siteProvider, $reader);
$siteProvider,
$reader,
$this->createStub(DsdAllocatorInterface::class),
);
$resource = new WeighbridgeReadingResource(); $resource = new WeighbridgeReadingResource();
$resource->mode = 'AUTO'; $resource->mode = 'AUTO';
@@ -56,34 +50,28 @@ final class WeighbridgeReadingProcessorTest extends TestCase
self::assertSame(23000, $result->weight); self::assertSame(23000, $result->weight);
self::assertSame(42, $result->dsd); self::assertSame(42, $result->dsd);
self::assertNull($result->manualNumber);
self::assertSame('AUTO', $result->mode); self::assertSame('AUTO', $result->mode);
} }
public function testManualModeKeepsWeightAndAllocatesDsd(): void public function testManualModeKeepsWeightAndDsdAsEntered(): void
{ {
$siteProvider = $this->createStub(CurrentSiteProviderInterface::class); $siteProvider = $this->createStub(CurrentSiteProviderInterface::class);
$siteProvider->method('get')->willReturn($this->site()); $siteProvider->method('get')->willReturn($this->site());
$allocator = $this->createStub(DsdAllocatorInterface::class);
$allocator->method('next')->willReturn(43);
$processor = new WeighbridgeReadingProcessor( $processor = new WeighbridgeReadingProcessor(
$siteProvider, $siteProvider,
$this->createStub(WeighbridgeReaderInterface::class), $this->createStub(WeighbridgeReaderInterface::class),
$allocator,
); );
$resource = new WeighbridgeReadingResource(); $resource = new WeighbridgeReadingResource();
$resource->mode = 'MANUAL'; $resource->mode = 'MANUAL';
$resource->weight = 23187; $resource->weight = 23187;
$resource->manualNumber = 'PAP-555'; $resource->dsd = 16619; // DSD saisi par l'operateur
$result = $processor->process($resource, new Post()); $result = $processor->process($resource, new Post());
self::assertSame(23187, $result->weight, 'Le poids saisi est conserve en manuel.'); self::assertSame(23187, $result->weight, 'Le poids saisi est conserve en manuel.');
self::assertSame(43, $result->dsd); self::assertSame(16619, $result->dsd, 'Le DSD saisi est conserve tel quel — pas d\'auto-increment (ERP-193).');
self::assertSame('PAP-555', $result->manualNumber);
self::assertSame('MANUAL', $result->mode); self::assertSame('MANUAL', $result->mode);
} }
@@ -95,11 +83,7 @@ final class WeighbridgeReadingProcessorTest extends TestCase
$reader = $this->createStub(WeighbridgeReaderInterface::class); $reader = $this->createStub(WeighbridgeReaderInterface::class);
$reader->method('read')->willThrowException(new WeighbridgeUnavailableException()); $reader->method('read')->willThrowException(new WeighbridgeUnavailableException());
$processor = new WeighbridgeReadingProcessor( $processor = new WeighbridgeReadingProcessor($siteProvider, $reader);
$siteProvider,
$reader,
$this->createStub(DsdAllocatorInterface::class),
);
$resource = new WeighbridgeReadingResource(); $resource = new WeighbridgeReadingResource();
$resource->mode = 'AUTO'; $resource->mode = 'AUTO';
@@ -121,7 +105,6 @@ final class WeighbridgeReadingProcessorTest extends TestCase
$processor = new WeighbridgeReadingProcessor( $processor = new WeighbridgeReadingProcessor(
$siteProvider, $siteProvider,
$this->createStub(WeighbridgeReaderInterface::class), $this->createStub(WeighbridgeReaderInterface::class),
$this->createStub(DsdAllocatorInterface::class),
); );
$resource = new WeighbridgeReadingResource(); $resource = new WeighbridgeReadingResource();