Entité WeighingTicket - Entité métier complète (#[Auditable], TimestampableBlamableTrait, relations ORM Client/Supplier/Site) + contrat de sérialisation à 3 maillons (weighing_ticket:read / :item:read + contextes par opération). - Getters calculés displayDate et plateFreeFormat (#[SerializedName]), sécurité view/manage, pas de Delete/archive. - Validation #[Assert\*] messages FR + #[Assert\Callback] RG-5.03 (->atPath()), libellé i18n audit.entity.logistique_weighingticket. - Repository : interface Domain + DoctrineWeighingTicketRepository (recherche + tri number DESC, deletedAt IS NULL). Dette site.code - Site.code mappé VARCHAR(8) (groupes read/write), dérivation auto au PrePersist (2 premiers chiffres du CP), UniqueConstraint uq_site_code. - Migration Version20260617160000 : ALTER COLUMN code SET NOT NULL + COMMENT. - Fixtures (codes 86/17/82) et SiteApiTest ajustés. Câblage - doctrine.yaml : mapping ORM du module Logistique (absent du scaffold ERP-181). - ColumnCommentsCatalog : site.code + table weighing_ticket. Specs M5 versionnées (spec-back / spec-front / prompts).
47 KiB
module, nom, ecran, owner_spec, backup_spec, version, date_redaction, spec_front, maquette_figma, trace_fonctionnelle, lesstime_project_id, lesstime_taskgroup_id, statut_global, depend_de
| module | nom | ecran | owner_spec | backup_spec | version | date_redaction | spec_front | maquette_figma | trace_fonctionnelle | lesstime_project_id | lesstime_taskgroup_id | statut_global | depend_de | ||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| M5 | Tickets de pesée | tickets-pesee | Matthieu | Tristan | V0.1 | 2026-06-17 | ./spec-front.md | https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1322-16774&p=f&m=dev | uploads/M5-ticket-de-pesee-V02.pdf (V0.2, 15/06/2026, validation client en attente) | 6 | 33 | pret_a_dev |
|
Spec back — Module 5 : Tickets de pesée
1. Contexte
Cette spec complète et précise la spec front V0.1 (docx M5-ticket-de-pesee-V02, V0.2 du 15/06/2026) avec tout ce qui touche au back : décisions d'archi, modèle de données, migration, API REST, RBAC, règles de gestion (RG-5.01 + précisions back RG-5.02 → RG-5.10), intégration pont bascule (stub), tests, hors-périmètre.
Module cible : NOUVEAU module Logistique (src/Module/Logistique/) — DÉCISION Matthieu (17/06). Le docx parle de « page d'entrée du Module Logistique » : on en fait un module à part entière (scaffolding via le skill create-module), distinct de Transport (M4). Son premier périmètre fonctionnel exposé est le ticket de pesée (entité WeighingTicket).
Distinction Transport (M4) vs Logistique (M5) :
Transport= référentiel des transporteurs (qui transporte).Logistique= opérations physiques sur site, à commencer par la pesée au pont bascule. Les deux peuvent à terme cohabiter dans une même section sidebar « Logistique » (cf. § 5.3), mais restent deux modules (activables/désactivables séparément).
RETEX obligatoire (M1→M4) : ~80 % des frictions venaient du contrat de sérialisation (groupes / sous-ressources / embed), pas du métier. La section § 4.0 applique ce RETEX au M5. On réutilise aussi le pattern Provider/Processor + normalisation serveur + Timestampable/Blamable + audit i18n posé aux modules précédents.
Dépendances déjà en place sur develop :
Sites→ 3 sites Châtellerault (86) / Saint-Jean (17) / Pommevic (82) ; sélecteur de site exposé viaSites\Application\Service\CurrentSiteProviderInterface;SiteScopedQueryExtension(filtrage par site courant) ;SiteInterface(contrat partagé).Commercial→Client(M1) +Supplier(M2).Shared→TimestampableBlamableTrait+Subscriber(ERP-52).Core→ User, Role, Permission, Audit, JWT.
2. Décisions d'archi
2.1 Nouveau module Logistique + entité WeighingTicket
Création du module Logistique :
src/Module/Logistique/LogistiqueModule.php—ID = 'logistique',LABEL = 'Logistique',REQUIRED = false,permissions()(§ 5.1).- Ajout dans
config/modules.php:LogistiqueModule::class. Domain/,Application/,Infrastructure/(arborescence DDD standard).- Layer front
frontend/modules/logistique/(kebab-case — règle naming).
Entité racine : WeighingTicket (ticket de pesée) sous src/Module/Logistique/Domain/Entity/, avec ses deux pesées (vide + plein) modélisées en colonnes plates (§ 2.4).
Référentiels cross-module consommés en relation ORM partagée (PAS d'import de logique) — exactement comme M2/M3/M4 : le ticket référence Client (M1), Supplier (M2) et Site (Sites) via des relations ORM (ManyToOne). Ce sont des données de référence partagées, pas de la logique inter-module (aucun service/repository d'un autre module appelé). La seule logique cross-module consommée est CurrentSiteProviderInterface (déjà un contrat exposé par Sites — autorisé par la règle ABSOLUE n°1).
2.2 IDs — convention INT (alignée Core/Commercial/Sites)
Le module Logistique est un nouveau module métier hors périmètre Transport : on s'aligne sur la convention INT GENERATED BY DEFAULT AS IDENTITY des modules historiques (Core / Commercial / Sites), et non sur le BIGINT du module Transport. Horodatages en TIMESTAMP(0) WITHOUT TIME ZONE (le TimestampableBlamableTrait mappe datetime_immutable).
2.3 Cloisonnement par site courant (DÉCISION par défaut — à confirmer)
Décision par défaut : les tickets de pesée sont des données opérationnelles rattachées à un site physique (le pont bascule est sur site). On cloisonne la liste par le site courant (sélecteur de site en haut de l'app) via le
SiteScopedQueryExtensiondéjà existant (Sites). Un utilisateur voit les tickets du site actif.
- Colonne
site_idNOT NULL surweighing_ticket(renseignée à la création depuisCurrentSiteProviderInterface). GET /api/weighing_ticketsfiltré sur le site courant (extension automatique).- Le numéro du ticket encode déjà le site (RG-5.02) → cohérent avec le cloisonnement.
À confirmer client : si le métier veut une vue multi-sites (tous sites confondus), retirer le cloisonnement et ajouter un filtre
?siteId=. Tracé HP-M5-01 (§ 9).
2.4 Modélisation des deux pesées — colonnes plates (pas de sous-entité)
Un ticket porte exactement deux pesées : une à vide (tare) et une à plein (brut). Plutôt qu'une sous-collection Weighing (1:n), on modélise deux jeux de colonnes plates sur weighing_ticket :
| Groupe | Colonnes |
|---|---|
| Pesée à vide | empty_date, empty_weight, empty_dsd, empty_mode (AUTO/MANUAL), empty_manual_number |
| Pesée à plein | full_date, full_weight, full_dsd, full_mode (AUTO/MANUAL), full_manual_number |
Justification : cardinalité fixe (toujours 1 vide + 1 plein), pas de tri/ajout dynamique, requêtes/exports plus simples, audit lisible. (Alternative sous-entité Weighing documentée mais non retenue — over-engineering pour 2 lignes figées.)
Champs
*_manual_number: « numéro de pesée » saisi en pesée manuelle (référence d'un ticket papier / autre bascule — distinct du DSD, cf. RG-5.04). Nullable (rempli seulement simode = MANUAL). Maquette (17/06) : les deux blocs (vide ET plein) portent les boutons « Pesée bascule » + « Pesée manuelle » — le modèle symétrique (empty_*ETfull_*avec mode AUTO/MANUAL) est donc bien utilisé des deux côtés. (Le texte du docx V0.2 ne mentionnait la manuelle que sur le bloc vide ; la maquette fait foi.)
2.5 Numérotation {siteCode}-TP-{NNNN} (RG-5.02)
Décision : chaque ticket reçoit un numéro unique par site au format
{siteCode}-TP-{NNNN}(ex.86-TP-0001). La séquence est propre à chaque site →86-TP-0001et17-TP-0001coexistent (cf. docx).
siteCode: leSiteactuel n'a pas de colonnecode. On ajoutesite.code(VARCHAR court, ex.86/17/82) — backfill par défaut = 2 premiers chiffres dupostal_code, valeur éditable ensuite côté admin Sites. Justification : un code explicite est plus robuste qu'une dérivation implicite du CP (collisions de département possibles). Petit débordement assumé sur le module Sites (1 colonne).- ⚠ Cadencement en 2 temps (RETEX dev ERP-182, 17/06) :
NOT NULLne peut PAS être posé dans la migration M5 seule. Sur base fraîche (make db-reset), les fixturesSitesFixturesfontnew Site(...)via l'ORM, qui ne connaîtcodeque si la propriété est mappée sur l'entitéSite.php(pas le cas avant ERP-183) →INSERTsanscode→ violationNOT NULL. Décision :- ERP-182 (migration) : créer
site.codeNULLABLE + backfill + index unique (lesNULLmultiples sont tolérés par l'index unique Postgres).make db-resetpasse, aucun test cassé. - ERP-183 (entité) : mapper
Site::code(propriété + getter/setter), le peupler dansSitesFixtures(86/17/82) +SeedE2ECommand, ajuster les tests Sites en collision d'unicité (ex.SiteApiTestcréant un site CP86000→code86 = collision avec Châtellerault), puis poserNOT NULLvia une 2ᵉ petite migration.
- ERP-182 (migration) : créer
- Séquence par site : table dédiée
weighing_ticket_counter (site_id PK, last_value INT). À la création :SELECT ... FOR UPDATEsur la ligne du site (verrou ligne) →last_value + 1, formaté%04d(zéro-padding 4 chiffres, débordement naturel au-delà de 9999). Garantit l'unicité même en concurrence. - Le numéro est immuable après création (pas modifiable à l'édition).
- Index unique
uq_weighing_ticket_number (site_id, number).
Alternative écartée : séquence Postgres par site (création dynamique de séquences) — moins portable, plus lourde à seeder. La table compteur +
FOR UPDATEest le pattern retenu.
2.6 Intégration pont bascule — stub au M5 (RG-5.06)
Décision Matthieu (17/06) : aucune liaison matérielle au M5. Le « pont bascule » est simulé : il renvoie un poids aléatoire ∈ [10000, 50000] kg.
- Contrat :
Logistique\Domain\Contract\WeighbridgeReaderInterfaceinterface WeighbridgeReaderInterface { /** @throws WeighbridgeUnavailableException si la bascule ne répond pas (→ bascule manuelle). */ public function read(SiteInterface $site): WeighbridgeReading; // {weight: int (kg), dsd: int} } - Implémentation livrée au M5 :
Infrastructure\Weighbridge\RandomWeighbridgeReader→weight = random_int(10000, 50000),dsd = nextDsd(site)(RG-5.04). - Driver matériel réel (protocole série/TCP de l'indicateur de pesage, parsing trame, reconnexion) = hors périmètre M5, tracé HP-M5-02 (§ 9). Le jour venu, on substitue l'implémentation derrière l'interface — zéro impact sur les écrans / l'API.
- Gestion d'erreur (RG-5.06) : si
read()lèveWeighbridgeUnavailableException, l'API renvoie un 422/503 explicite « Pont bascule indisponible — passez en pesée manuelle ». Le front affiche le message dans la modal et propose la pesée manuelle (le stub ne lève jamais l'exception au M5, mais le chemin d'erreur est implémenté et testé).
2.7 DSD — compteur de pesée du pont (RG-5.04)
Décision Matthieu (17/06) : le DSD est un compteur de pesée (index séquentiel des pesées du pont). Chaque pesée (vide OU plein) consomme une valeur DSD.
- Compteur par site (un pont par site) : table
weighbridge_dsd_counter (site_id PK, last_value INT)(verrou ligneFOR UPDATE, même pattern que le compteur de numéro). - Pesée bascule (AUTO) : la lecture incrémente le compteur du site et renvoie la nouvelle valeur (le stub fait pareil ; un vrai pont renverrait son propre index, qu'on persisterait).
- Pesée manuelle :
dsd = dernier dsd du site + 1(le docx : « le dsd est automatiquement calculé en fonction du dernier dsd en base de données »). - Un ticket complet (vide + plein en AUTO) consomme 2 incréments DSD (
empty_dsd,full_dsd).
2.8 Poids net — plein − vide, calculé serveur (RG-5.05)
Le docx ne définit pas le calcul du poids affiché en liste (colonne « Poids »). CONFIRMÉ Matthieu (17/06) : poids net = poids plein − poids vide.
- Stocké en colonne dérivée
net_weight(INT, kg), recalculé serveur par leWeighingTicketProcessorà chaque POST/PATCH dès queempty_weightETfull_weightsont renseignés (sinonnull). - La colonne liste « Poids » =
net_weight(cf. § 4.0). Le détail/ticket affiche vide + plein + net. - Exemple maquette : plein
14 300− vide7 150= net7 150kg.
2.9 Contrepartie CLIENT / FOURNISSEUR / AUTRE (RG-5.03)
Le formulaire principal porte un sélecteur « Fournisseur / Client / Autre » qui pilote des champs conditionnels (docx p.4). Le back ne maintient pas de state machine : il stocke et valide la cohérence au POST/PATCH.
counterparty_type |
Champs requis | Champs forcés nuls |
|---|---|---|
CLIENT |
client_id (FK Client) |
supplier_id, other_label |
FOURNISSEUR |
supplier_id (FK Supplier) |
client_id, other_label |
AUTRE |
other_label (texte libre) |
client_id, supplier_id |
Validation via #[Assert\Callback] + CHECK Postgres (garde-fous miroir M4 § 3.2).
2.10 Masque immatriculation & « Tout format » (RG-5.01)
immatriculation: par défaut masqueXX-000-XX(plaque FR SIV). Siplate_free_format = true(« Tout format » coché), le masque est désactivé (saisie libre — anciennes plaques, étranger, engins).- Champs connectés entre les deux formulaires (vide ⇄ plein) :
immatriculationetplate_free_formatsont portés par le ticket (une seule valeur, partagée par les 2 formulaires) — c'est le même véhicule. Pas de duplication. - Normalisation serveur :
immatriculation→ trim + UPPER + (si masque) re-formatageXX-000-XX; rejet 422 si format invalide etplate_free_format = false.
2.11 Audit & traces temporelles
Pattern Starseed standard (miroir M1→M4) :
#[Auditable]surWeighingTicket. Pas de champ sensible (password/token) → pas d'#[AuditIgnore].- Audit des FK (
client,supplier,site) tracé automatiquement. WeighingTicket implements TimestampableInterface, BlamableInterface+use TimestampableBlamableTrait(4 colonnes standard).- Libellé i18n (règle ABSOLUE backend —
AuditableEntitiesHaveI18nLabelTest) : ajouteraudit.entity.logistique_weighingticketdansfrontend/i18n/locales/fr.json(clé =strtolower(module)+_+strtolower(Entity)).
2.12 Impression du ticket / bon de pesée (RG-5.08)
OWNER : Tristan. La réalisation du bon d'impression (gabarit du ticket de pesée, mise en page, déclenchement de l'impression) est prise en charge par Tristan lui-même — hors de la découpe back/front standard du M5. Cette spec en pose le contrat attendu (déclencheur, contenu, données disponibles) pour qu'il puisse s'y brancher sans rétro-spec.
Contrat attendu :
- Déclencheur : à la validation (création), l'API renvoie le ticket complet ; le front ouvre une modal d'impression. En modification, un bouton « Imprimer » est disponible (absent à l'ajout — docx / RG-5.08).
- Contenu minimal du bon : numéro (
{siteCode}-TP-{NNNN}), site, contrepartie (Client / Fournisseur / Autre + libellé), immatriculation, pesée à vide (date/poids/DSD), pesée à plein (date/poids/DSD), poids net (= plein − vide), date d'édition. - Données : toutes disponibles dans la réponse
GET /api/weighing_tickets/{id}(§ 4.0) — aucun champ supplémentaire requis côté API. Si Tristan opte pour un PDF serveur, prévoir l'endpointGET /api/weighing_tickets/{id}/print.pdf(HP-M5-04) ; sinon impression navigateur d'un gabarit front.
2.13 Pas d'archive ; soft delete préparé non exposé
Le docx M5 ne prévoit pas d'archivage (contrairement au M4). On n'expose pas d'archive. On prépare néanmoins une colonne deleted_at (soft delete technique) non exposée au M5 (DELETE non exposé → 404). Cohérent avec le pattern projet.
3. Modèle de données
3.1 Diagramme
+------------------+
| site (Sites) | + NOUVELLE colonne `code` (86/17/82)
+------------------+
^ ^ ^
site_id | | site_id| site_id
+---------------+ | +------------------------+
| | |
+-----------------------+ +--------------------------+ +--------------------------+
| weighing_ticket_counter| | weighbridge_dsd_counter | | weighing_ticket |
| site_id PK | | site_id PK | | id (PK) |
| last_value INT | | last_value INT | | number (UNIQUE / site) |
+-----------------------+ +--------------------------+ | site_id (FK) |
(séquence n° ticket) (compteur DSD pont) | counterparty_type |
| client_id (FK M1, null) |--> client (M1)
| supplier_id (FK M2, null)|--> supplier (M2)
| other_label (null) |
| immatriculation |
| plate_free_format |
| empty_* (date/weight/dsd/mode/manual_number) |
| full_* (date/weight/dsd/mode/manual_number) |
| net_weight (dérivé) |
| deleted_at (soft, non exposé) |
+--------------------------+
3.2 Migration Doctrine — SQL Postgres
Namespace : DoctrineMigrations (racine migrations/) — fichier migrations/VersionYYYYMMDDHHMMSS.php (à dater, postérieur aux migrations existantes).
Même justification qu'aux M1→M4 : la migration crée un schéma avec FK cross-module (
user,client,supplier,site). Le namespace modulaire casserait l'ordre (make db-reset) — exception racine de la règle ABSOLUE n°11.
Rappel règle ABSOLUE n°12 : chaque colonne créée DOIT recevoir son
COMMENT ON COLUMN(FR, ≤ 200 car., sémantique + contrainte/RG). Les 4 colonnes Timestampable/Blamable passent par le helperaddStandardTimestampableBlamableComments. SQL ci-dessous illustratif (conventionINT GENERATED BY DEFAULT AS IDENTITY,TIMESTAMP(0)).
-- =====================================================================
-- Ajout d'un code de site (préfixe de numérotation TP) — § 2.5
-- =====================================================================
-- ⚠ NULLABLE au M5 (ERP-182). Le SET NOT NULL est posé en ERP-183, une fois Site::code
-- mappé sur l'entité et peuplé dans les fixtures (sinon db-reset casse — cf. § 2.5).
ALTER TABLE site ADD COLUMN code VARCHAR(8);
-- Backfill : 2 premiers chiffres du code postal (dépt) par défaut, éditable ensuite.
UPDATE site SET code = LEFT(postal_code, 2) WHERE code IS NULL;
-- Index unique tolérant les NULL (Postgres : plusieurs NULL autorisés) — OK tant que code nullable.
CREATE UNIQUE INDEX uq_site_code ON site (code);
-- ERP-183 (2ᵉ migration) : ALTER TABLE site ALTER COLUMN code SET NOT NULL;
-- =====================================================================
-- Compteur de numéro de ticket (séquence par site) — RG-5.02
-- =====================================================================
CREATE TABLE weighing_ticket_counter (
site_id INT PRIMARY KEY REFERENCES site(id) ON DELETE CASCADE,
last_value INT NOT NULL DEFAULT 0
);
-- =====================================================================
-- Compteur DSD (pesée du pont, par site) — RG-5.04
-- =====================================================================
CREATE TABLE weighbridge_dsd_counter (
site_id INT PRIMARY KEY REFERENCES site(id) ON DELETE CASCADE,
last_value INT NOT NULL DEFAULT 0
);
-- =====================================================================
-- Table principale `weighing_ticket`
-- =====================================================================
CREATE TABLE weighing_ticket (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT,
number VARCHAR(20) NOT NULL, -- {siteCode}-TP-{NNNN} (RG-5.02)
-- Contrepartie (RG-5.03)
counterparty_type VARCHAR(12) NOT NULL, -- CLIENT|FOURNISSEUR|AUTRE
client_id INT REFERENCES client(id) ON DELETE RESTRICT,
supplier_id INT REFERENCES supplier(id) ON DELETE RESTRICT,
other_label VARCHAR(255),
-- Véhicule (RG-5.01, partagé entre les 2 formulaires)
immatriculation VARCHAR(20) NOT NULL,
plate_free_format BOOLEAN NOT NULL DEFAULT FALSE,
-- Pesée à vide (§ 2.4)
empty_date TIMESTAMP(0) WITHOUT TIME ZONE,
empty_weight INT, -- kg
empty_dsd INT,
empty_mode VARCHAR(8), -- AUTO|MANUAL
empty_manual_number VARCHAR(50), -- numéro de pesée manuelle (RG-5.04)
-- Pesée à plein (§ 2.4)
full_date TIMESTAMP(0) WITHOUT TIME ZONE,
full_weight INT, -- kg
full_dsd INT,
full_mode VARCHAR(8), -- AUTO|MANUAL
full_manual_number VARCHAR(50),
-- Dérivé (RG-5.05)
net_weight INT, -- full_weight - empty_weight (RG-5.05)
-- Soft delete (préparé, non exposé au M5)
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE,
-- Timestampable + Blamable
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL,
CONSTRAINT chk_wt_counterparty_type
CHECK (counterparty_type IN ('CLIENT','FOURNISSEUR','AUTRE')),
CONSTRAINT chk_wt_empty_mode CHECK (empty_mode IS NULL OR empty_mode IN ('AUTO','MANUAL')),
CONSTRAINT chk_wt_full_mode CHECK (full_mode IS NULL OR full_mode IN ('AUTO','MANUAL')),
-- RG-5.03 : cohérence contrepartie
CONSTRAINT chk_wt_client_branch CHECK (
counterparty_type <> 'CLIENT' OR (client_id IS NOT NULL AND supplier_id IS NULL AND other_label IS NULL)
),
CONSTRAINT chk_wt_supplier_branch CHECK (
counterparty_type <> 'FOURNISSEUR' OR (supplier_id IS NOT NULL AND client_id IS NULL AND other_label IS NULL)
),
CONSTRAINT chk_wt_other_branch CHECK (
counterparty_type <> 'AUTRE' OR (other_label IS NOT NULL AND client_id IS NULL AND supplier_id IS NULL)
)
);
CREATE UNIQUE INDEX uq_weighing_ticket_number ON weighing_ticket (site_id, number);
CREATE INDEX idx_wt_site ON weighing_ticket (site_id);
CREATE INDEX idx_wt_client ON weighing_ticket (client_id);
CREATE INDEX idx_wt_supplier ON weighing_ticket (supplier_id);
CREATE INDEX idx_wt_deleted_at ON weighing_ticket (deleted_at);
CREATE INDEX idx_wt_created_by ON weighing_ticket (created_by);
CREATE INDEX idx_wt_updated_by ON weighing_ticket (updated_by);
3.2.bis Commentaires SQL obligatoires (échantillon)
$this->addSql("COMMENT ON TABLE weighing_ticket IS 'Tickets de pesée (M5 Logistique) — pesée à vide + à plein au pont bascule, contrepartie Client/Fournisseur/Autre.'");
$this->addSql("COMMENT ON COLUMN site.code IS 'Code court du site (ex. 86/17/82) — préfixe de numérotation des tickets de pesée (RG-5.02). Unique.'");
$this->addSql("COMMENT ON COLUMN weighing_ticket.number IS 'Numéro {siteCode}-TP-{NNNN}, unique par site, immuable. Séquence weighing_ticket_counter (RG-5.02).'");
$this->addSql("COMMENT ON COLUMN weighing_ticket.counterparty_type IS 'Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (RG-5.03). Pilote l''obligation client_id / supplier_id / other_label.'");
$this->addSql("COMMENT ON COLUMN weighing_ticket.immatriculation IS 'Plaque du véhicule, partagée entre pesée vide et plein. Masque XX-000-XX sauf si plate_free_format (RG-5.01).'");
$this->addSql("COMMENT ON COLUMN weighing_ticket.plate_free_format IS '« Tout format » : désactive le masque XX-000-XX de l''immatriculation (RG-5.01). Partagé entre les 2 formulaires.'");
$this->addSql("COMMENT ON COLUMN weighing_ticket.empty_dsd IS 'Compteur DSD du pont à la pesée à vide. AUTO=valeur du pont ; MANUAL=dernier dsd du site +1 (RG-5.04).'");
$this->addSql("COMMENT ON COLUMN weighing_ticket.empty_manual_number IS 'Numéro de pesée saisi en pesée manuelle (distinct du DSD) — formulaire à vide (RG-5.04).'");
$this->addSql("COMMENT ON COLUMN weighing_ticket.net_weight IS 'Poids net = full_weight - empty_weight (kg), calculé serveur (RG-5.05). Colonne Poids de la liste.'");
$this->addSql("COMMENT ON COLUMN weighbridge_dsd_counter.last_value IS 'Dernière valeur DSD attribuée pour le site (pont bascule). Incrément verrouillé FOR UPDATE (RG-5.04).'");
$this->addSql("COMMENT ON COLUMN weighing_ticket_counter.last_value IS 'Dernier numéro de ticket attribué pour le site. Incrément verrouillé FOR UPDATE (RG-5.02).'");
// + COMMENT ON COLUMN sur TOUTES les autres colonnes métier (règle n°12)
$this->addStandardTimestampableBlamableComments($schema, 'weighing_ticket');
3.3 Entité WeighingTicket — squelette (extrait)
Pattern jumeau de Carrier/Supplier (#[Auditable], TimestampableBlamableTrait). Chaque propriété affichée porte un read-group (RETEX M1).
<?php
declare(strict_types=1);
namespace App\Module\Logistique\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Commercial\Domain\Entity\Client; // relation ORM partagée (§ 2.1)
use App\Module\Commercial\Domain\Entity\Supplier; // relation ORM partagée (§ 2.1)
use App\Module\Sites\Domain\Entity\Site; // relation ORM partagée (§ 2.1)
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighingTicketProcessor;
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider\WeighingTicketProvider;
use App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('logistique.weighing_tickets.view')",
normalizationContext: ['groups' => ['weighing_ticket:read', 'client:read', 'supplier:read', 'site:read', 'default:read']],
provider: WeighingTicketProvider::class,
),
new Get(
security: "is_granted('logistique.weighing_tickets.view')",
normalizationContext: ['groups' => ['weighing_ticket:read', 'weighing_ticket:item:read', 'client:read', 'supplier:read', 'site:read', 'default:read']],
provider: WeighingTicketProvider::class,
),
new Post(
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']],
processor: WeighingTicketProcessor::class,
),
new Patch(
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']],
provider: WeighingTicketProvider::class,
processor: WeighingTicketProcessor::class,
),
// Pas de Delete au M5 (HP). Pas d'archive (hors docx).
],
)]
#[ORM\Entity(repositoryClass: DoctrineWeighingTicketRepository::class)]
#[ORM\Table(name: 'weighing_ticket')]
#[Auditable]
class WeighingTicket implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
#[Groups(['weighing_ticket:read'])]
private ?int $id = null;
/** Numéro {siteCode}-TP-{NNNN} — attribué serveur, lecture seule (RG-5.02). */
#[ORM\Column(length: 20)]
#[Groups(['weighing_ticket:read'])]
private ?string $number = null;
#[ORM\ManyToOne(targetEntity: Site::class)]
#[ORM\JoinColumn(name: 'site_id', nullable: false, onDelete: 'RESTRICT')]
#[Groups(['weighing_ticket:read'])] // renseigné serveur depuis le site courant (§ 2.3)
private ?Site $site = null;
#[ORM\Column(length: 12)]
#[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR', 'AUTRE'], message: 'Type de contrepartie invalide.')]
#[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.')]
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
private ?string $counterpartyType = null;
#[ORM\ManyToOne(targetEntity: Client::class)]
#[ORM\JoinColumn(name: 'client_id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
private ?Client $client = null; // requis si counterpartyType=CLIENT (Callback RG-5.03)
#[ORM\ManyToOne(targetEntity: Supplier::class)]
#[ORM\JoinColumn(name: 'supplier_id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
private ?Supplier $supplier = null; // requis si counterpartyType=FOURNISSEUR
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
private ?string $otherLabel = null; // requis si counterpartyType=AUTRE
#[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'L''immatriculation est obligatoire.')]
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
private ?string $immatriculation = null; // masque XX-000-XX sauf plateFreeFormat (RG-5.01)
#[ORM\Column(options: ['default' => false])]
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
private bool $plateFreeFormat = false;
// === Pesée à vide ===
#[ORM\Column(name: 'empty_date', type: 'datetime_immutable', nullable: true)]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?\DateTimeImmutable $emptyDate = null;
#[ORM\Column(name: 'empty_weight', nullable: true)]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?int $emptyWeight = null; // kg — readonly UI, rempli par la pesée (RG-5.07)
#[ORM\Column(name: 'empty_dsd', nullable: true)]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?int $emptyDsd = null;
#[ORM\Column(name: 'empty_mode', length: 8, nullable: true)]
#[Assert\Choice(choices: ['AUTO', 'MANUAL'], message: 'Mode de pesée invalide.')]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?string $emptyMode = null;
#[ORM\Column(name: 'empty_manual_number', length: 50, nullable: true)]
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
private ?string $emptyManualNumber = null;
// === Pesée à plein (mêmes colonnes, préfixe full*) ===
// fullDate / fullWeight / fullDsd / fullMode / fullManualNumber ...
/** Poids net dérivé — calculé serveur (RG-5.05). */
#[ORM\Column(name: 'net_weight', nullable: true)]
#[Groups(['weighing_ticket:read'])]
private ?int $netWeight = null;
// RG-5.03 (contrepartie) + RG-5.01 (immat) : cohérence via #[Assert\Callback] (§ 7).
// ... getters/setters ...
}
⚠
Client/Supplier/Siteappartiennent à d'autres modules — on consomme leurs read-groups (client:read,supplier:read,site:read), pas de logique inter-module (§ 2.1).
4. API REST (API Platform)
4.0 Contrat de sérialisation (RETEX M1 — section critique)
Leçon M1→M4 : pour chaque champ affiché (liste OU détail), les 3 maillons doivent être prouvés : (a) groupe sur la propriété, (b) groupe dans le
normalizationContextde l'opération, (c) read-group de l'entité imbriquée présent dans le contexte parent.
Contexte par opération :
| Opération | normalizationContext (groupes) |
|---|---|
GetCollection (liste) |
weighing_ticket:read + client:read + supplier:read + site:read + default:read |
Get / Post / Patch (détail) |
+ weighing_ticket:item:read |
LISTE — colonne datatable → maillons (docx p.3 : Numéro, Client, Fournisseur, Autre, Date, Poids) :
| Colonne affichée | Propriété (a) | Dans contexte liste (b) | Imbriqué (c) |
|---|---|---|---|
| Numéro | number ∈ weighing_ticket:read |
✅ | — |
| Client | client ∈ weighing_ticket:read (embed) |
✅ | client:read ✅ (RG-5.03) |
| Fournisseur | supplier ∈ weighing_ticket:read (embed) |
✅ | supplier:read ✅ |
| Autre | otherLabel ∈ weighing_ticket:read |
✅ | — |
| Date | fullDate ?? emptyDate (date du ticket) ∈ weighing_ticket:read |
✅ | — |
| Poids | netWeight ∈ weighing_ticket:read |
✅ | — |
Note « Date » liste : on expose une propriété calculée
displayDate(getter) =fullDate ?? emptyDate, dansweighing_ticket:read(lesempty/full*détaillées restent en:item:read).
DÉTAIL — maillons : scalaires + emptyDate/emptyWeight/emptyDsd/... + full* ∈ weighing_ticket:item:read ; client/supplier/site embarqués (client:read/supplier:read/site:read).
4.0.bis Réponse JSON de référence (DoD — à CAPTURER sur l'API réelle)
Definition of Done (miroir M2/M3/M4) : avant les écrans front, capturer la réponse RÉELLE via un test PHPUnit (
WeighingTicketSerializationContractTest, ticket complet seedé : contrepartie Client, pesée vide + plein) et la coller ici. Toute donnée affichée par le front DOIT apparaître dans ce JSON.Pièges à re-tester :
client/supplierdoivent sortir en objet embarqué, pas en IRI nu → read-groupsclient:read/supplier:read.- Booléen
plateFreeFormat: clé présente (piège #3 M1 → getter +SerializedNamesi besoin).numberprésent et formaté{siteCode}-TP-{NNNN}.netWeightcohérent =full - empty(plein − vide, RG-5.05).
GET /api/weighing_tickets (LISTE) — enveloppe Hydra AP4 (member/totalItems/view), filtrée site courant (§ 2.3) :
{
"@context": "/api/contexts/WeighingTicket",
"@id": "/api/weighing_tickets",
"@type": "Collection",
"totalItems": 1,
"member": [
{
"@id": "/api/weighing_tickets/1",
"@type": "WeighingTicket",
"id": 1,
"number": "86-TP-0001",
"counterpartyType": "CLIENT",
"client": { "@id": "/api/clients/117", "@type": "Client", "id": 117, "companyName": "NÉGOCE MÉTAUX ATLANTIQUE" },
"supplier": null,
"otherLabel": null,
"displayDate": "2026-06-17T09:12:00+02:00",
"netWeight": 12340,
"plateFreeFormat": false,
"createdAt": "2026-06-17T09:12:00+02:00",
"updatedAt": "2026-06-17T09:12:00+02:00"
}
],
"view": { "@id": "/api/weighing_tickets", "@type": "PartialCollectionView" }
}
GET /api/weighing_tickets/{id} (DÉTAIL) — ajoute les pesées :
{
"@id": "/api/weighing_tickets/1",
"@type": "WeighingTicket",
"id": 1,
"number": "86-TP-0001",
"site": { "@id": "/api/sites/1", "@type": "Site", "id": 1, "name": "Châtellerault", "code": "86" },
"counterpartyType": "CLIENT",
"client": { "@id": "/api/clients/117", "@type": "Client", "id": 117, "companyName": "NÉGOCE MÉTAUX ATLANTIQUE" },
"immatriculation": "AB-123-CD",
"plateFreeFormat": false,
"emptyDate": "2026-06-17T09:00:00+02:00", "emptyWeight": 14660, "emptyDsd": 41, "emptyMode": "AUTO", "emptyManualNumber": null,
"fullDate": "2026-06-17T09:12:00+02:00", "fullWeight": 27000, "fullDsd": 42, "fullMode": "AUTO", "fullManualNumber": null,
"netWeight": 12340
}
4.1 Query params (LISTE)
| Param | Effet |
|---|---|
?page / ?itemsPerPage |
pagination standard (10 / 25 / 50, défaut 10) |
?search= |
recherche sur number, nom client/fournisseur, other_label, immatriculation |
?order[displayDate]=desc |
tri par date (défaut : number DESC = plus récents en tête) |
| (site courant) | filtré automatiquement par SiteScopedQueryExtension (§ 2.3) |
Pagination obligatoire (règle ABSOLUE n°13) — provider ORM via ApiPlatform\Doctrine\Orm\Paginator, jamais d'array brut.
4.2 Endpoint pesée (pont bascule) — POST /api/weighbridge_readings
Action autonome (le ticket n'est pas encore créé quand on déclenche la pesée du formulaire principal).
- Sécurité :
is_granted('logistique.weighing_tickets.manage'). - AUTO (pesée bascule) — body
{ "mode": "AUTO" }→ le site courant est résolu serveur (CurrentSiteProviderInterface).- Réponse
200:{ "weight": 23187, "dsd": 42, "mode": "AUTO" }(stub :weight = random_int(10000,50000),dsd = nextDsd(site)). - Réponse
503(RG-5.06) siWeighbridgeUnavailableException:{ "title": "Pont bascule indisponible", "detail": "Passez en pesée manuelle." }.
- Réponse
- MANUAL (pesée manuelle) — body
{ "mode": "MANUAL", "weight": 23187, "manualNumber": "PAP-555" }.- Réponse
200:{ "weight": 23187, "dsd": 43, "manualNumber": "PAP-555", "mode": "MANUAL" }(dsd = dernier dsd du site + 1, RG-5.04).
- Réponse
Implémentation :
#[ApiResource]non-Doctrine (DTOWeighbridgeReadingInput/Output) + Processor dédié, OU une ressourceWeighbridgeReadingvirtuelle. Pas de controller Symfony (règle backend). Le Processor appelleWeighbridgeReaderInterface+ leDsdAllocator(verrouFOR UPDATE).Concurrence DSD : le
dsdrenvoyé ici est prévisionnel. L'attribution autoritaire dudsd(et dunumber) est refaite/verrouillée à la création du ticket (POST /api/weighing_tickets) pour éviter les collisions si deux postes pèsent en parallèle. Front : afficher le dsd renvoyé, mais c'est le ticket persisté qui fait foi.
4.3 POST /api/weighing_tickets (création)
- Le client envoie :
counterpartyType(+client/supplier/otherLabel),immatriculation,plateFreeFormat, et les pesées (emptyDate/Weight/Dsd/Mode/ManualNumber,full*). - Le Processor :
- Résout le site courant (
CurrentSiteProviderInterface) →site_id. - Attribue le numéro
{siteCode}-TP-{NNNN}(compteur verrouillé — RG-5.02). - (Re)attribue les
dsdautoritaires si nécessaire (verrou — RG-5.04). - Normalise
immatriculation(RG-5.01) ; valide la cohérence contrepartie (RG-5.03) et pesées. - Calcule
net_weight = full_weight - empty_weightsi les deux poids sont présents (RG-5.05).
- Résout le site courant (
- Réponse
201avec le ticket complet → le front ouvre la modal d'impression (RG-5.08).
4.4 PATCH /api/weighing_tickets/{id} (modification)
- Mise à jour partielle (mêmes règles). Le numéro et le site sont immuables (ignorés s'ils sont envoyés).
net_weightrecalculé. Le bouton d'impression est disponible (RG-5.08).
4.5 Export — GET /api/weighing_tickets/export.xlsx
- Exporte toute la liste des tickets (docx : bouton « Exporter » → « Exporte toute la liste des tickets de pesée »), filtrée par le site courant + filtres actifs.
- Colonnes : Numéro, Contrepartie (Client/Fournisseur/Autre + nom), Date, Immatriculation, Poids vide, Poids plein, Poids net, DSD vide/plein.
- Génération via le helper XLSX standard projet (skill
xlsx). Endpoint : provider dédié renvoyant un binaire (Content-Typexlsx) — whitelisté pagination (EXCLUDED) car export complet.
5. RBAC, module & sidebar
5.1 LogistiqueModule::permissions()
public static function permissions(): array
{
return [
['code' => 'logistique.weighing_tickets.view', 'label' => 'Voir les tickets de pesée'],
['code' => 'logistique.weighing_tickets.manage', 'label' => 'Créer / modifier les tickets de pesée'],
];
}
Synchronisation : app:sync-permissions.
5.2 Matrice rôle → permissions (docx p.3)
| Rôle | …view |
…manage |
|---|---|---|
| Admin | ✅ | ✅ |
| Bureau | ✅ | ✅ |
| Usine | ✅ | ✅ |
| Compta | ❌ | ❌ |
| Commerciale | ❌ | ❌ |
⚠ Changement vs M5 V0.1 : en V0.2 Usine = Tout / Tout (consultation + ajout/modif), alors que la V0.1 disait « Oui ». Compta et Commerciale = aucun accès (item sidebar masqué).
5.3 Sidebar (config/sidebar.php)
Nouvelle section « Logistique » (ou item rattaché à une section logistique mutualisée avec Transport — à confirmer). Item :
[
'label' => 'sidebar.logistique.weighing_tickets',
'to' => '/weighing-tickets',
'icon' => 'mdi-scale',
'module' => 'logistique',
'permission' => 'logistique.weighing_tickets.view',
],
5.4 Règle ABSOLUE n°8 — 3 miroirs RBAC
Toute permission logistique.* doit être posée simultanément dans :
config/sidebar.php(item + permission ci-dessus),frontend/tests/e2e/_fixtures/personas.ts(ajuster un persona existant : Usine gagneweighing_tickets.view/manage+expectedAdminLinks),src/Module/Core/Infrastructure/Console/SeedE2ECommand.php(miroir back du même persona).
6. Normalisation serveur (RG-5.01 / RG-5.10)
WeighingTicketFieldNormalizer (miroir CarrierFieldNormalizer), appelé par le Processor avant validation :
final class WeighingTicketFieldNormalizer
{
// RG-5.01 : trim + UPPER ; si !plateFreeFormat → reformate XX-000-XX (rejet 422 si invalide).
public function normalizeImmatriculation(?string $v, bool $freeFormat): ?string
public function normalizeOtherLabel(?string $v): ?string // trim
}
7. Règles de gestion (RG)
| RG | Source | Énoncé |
|---|---|---|
| RG-5.01 | docx | Immatriculation : masque par défaut XX-000-XX ; « Tout format » coché → masque désactivé (saisie libre). Les champs immatriculation et plateFreeFormat sont connectés entre les 2 formulaires (une seule valeur portée par le ticket — § 2.10). |
| RG-5.02 | back | Numéro {siteCode}-TP-{NNNN}, unique par site, attribué serveur à la création, immuable. Séquence verrouillée par site (§ 2.5). |
| RG-5.03 | docx+back | Contrepartie CLIENT/FOURNISSEUR/AUTRE → champ associé obligatoire, les autres forcés nuls (§ 2.9). |
| RG-5.04 | docx+back | DSD = compteur de pesée du pont, par site. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1. « Numéro de pesée » manuel = champ distinct (§ 2.7). |
| RG-5.05 | back | Poids net = poids plein − poids vide, calculé serveur, exposé en liste/détail (§ 2.8 — confirmé Matthieu 17/06). |
| RG-5.06 | docx+back | Pesée bascule indisponible → erreur explicite + bascule en pesée manuelle. Au M5, le pont est un stub (poids aléatoire ∈ [10000,50000] kg, § 2.6). |
| RG-5.07 | docx | Formulaire à vide : Date = date du jour par défaut ; Poids et DSD readonly (remplis par la pesée, pas saisis). |
| RG-5.08 | docx | « Valider » (création) → enregistre + ouvre la modal d'impression. En modification : bouton « Valider » → « Enregistrer », bouton d'impression disponible (absent à l'ajout). Le bouton « Enregistré » du bloc pesée à vide disparaît en modification. Le bon d'impression est réalisé par Tristan (§ 2.12). |
| RG-5.09 | back | Site & numéro immuables après création ; liste cloisonnée par site courant (§ 2.3, à confirmer). |
| RG-5.10 | back | Normalisation immatriculation (trim/UPPER/format) côté serveur (§ 6). |
Cohérence inter-champs (RG-5.03, RG-5.01) implémentée via #[Assert\Callback] portant des messages FR + CHECK Postgres en garde-fou (§ 3.2).
8. Tests (PHPUnit) — make test
WeighingTicketSerializationContractTest: capture JSON liste + détail (DoD § 4.0.bis), 4 pièges verts.WeighingTicketNumberingTest:{siteCode}-TP-{NNNN}, séquence par site, unicité, concurrence (FOR UPDATE).DsdAllocatorTest: AUTO incrémente ; MANUAL = dernier + 1 ; par site.WeighbridgeReaderStubTest: poids ∈ [10000,50000] ; chemin d'erreurWeighbridgeUnavailableException→ 503 (RG-5.06).NetWeightTest:plein − vide; null si une pesée manque (RG-5.05).CounterpartyValidationTest: RG-5.03 (chaque branche + rejets).ImmatriculationNormalizationTest: masque XX-000-XX, free format, 422 (RG-5.01).- RBAC : Usine/Bureau/Admin OK ; Compta/Commerciale 403.
- Architecture (déjà en place, ne pas casser) :
ColumnsHaveSqlCommentTest,EntitiesAreTimestampableBlamableTest,AuditableEntitiesHaveI18nLabelTest,CollectionsArePaginatedTest,EntityConstraintsHaveFrenchMessageTest.
9. Hors périmètre (HP)
| Réf | Sujet |
|---|---|
| HP-M5-01 | Vue multi-sites des tickets (retirer le cloisonnement + filtre ?siteId=) si demandé (§ 2.3). |
| HP-M5-02 | Driver matériel réel du pont bascule (protocole série/TCP, parsing trame, reconnexion) derrière WeighbridgeReaderInterface (§ 2.6). |
| HP-M5-03 | Sens réception-expédition explicite + contrôle de signe du net (le net reste plein − vide, § 2.8). |
| HP-M5-04 | Génération PDF serveur du ticket (/print.pdf) si l'impression navigateur ne suffit pas (§ 2.12). |
| HP-M5-05 | Archivage fonctionnel des tickets (non prévu au docx — § 2.13). |
10. Tickets Lesstime (à découper — back en tête)
| Ordre | Sujet | Tag |
|---|---|---|
| 0 | Scaffolding module Logistique (create-module) + config/modules.php + sidebar + 3 miroirs RBAC |
Backend |
| 1 | Migration : site.code + compteurs + weighing_ticket (+ index + COMMENT) |
Backend |
| 2 | Entité WeighingTicket + Repository + contrat sérialisation |
Backend |
| 3 | WeighbridgeReaderInterface + RandomWeighbridgeReader + DsdAllocator + endpoint weighbridge_readings |
Backend |
| 4 | WeighingTicketProvider + WeighingTicketProcessor (numérotation, RG-5.03/5.05, normalisation) |
Backend |
| 5 | Export XLSX | Backend |
| 6 | Tests PHPUnit RG-5.01→5.10 + capture contrat JSON | Backend |
| 7 | Page liste /weighing-tickets (usePaginatedList) + export |
Frontend |
| 8 | Écran Ajouter (formulaires vide + plein, pesée bascule/manuelle, masque immat) | Frontend |
| 9 | Écran Modification (la modal/bon d'impression = Tristan, § 2.12) | Frontend |
| 10 | i18n + libellé audit + branchement site courant | Frontend |
| — | Bon d'impression du ticket de pesée | Tristan (hors découpe M5) |