Files
Starseed/docs/specs/M5-tickets-pesee/spec-back.md
T
Matthieu 4369c71706 feat(logistique) : entité WeighingTicket + dette site.code (ERP-183)
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).
2026-06-17 17:46:20 +02:00

47 KiB
Raw Blame History

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
Sites
Commercial
Core
Shared

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é via Sites\Application\Service\CurrentSiteProviderInterface ; SiteScopedQueryExtension (filtrage par site courant) ; SiteInterface (contrat partagé).
  • CommercialClient (M1) + Supplier (M2).
  • SharedTimestampableBlamableTrait + 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.phpID = '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 SiteScopedQueryExtension déjà existant (Sites). Un utilisateur voit les tickets du site actif.

  • Colonne site_id NOT NULL sur weighing_ticket (renseignée à la création depuis CurrentSiteProviderInterface).
  • GET /api/weighing_tickets filtré 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 si mode = 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_* ET full_* 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 site86-TP-0001 et 17-TP-0001 coexistent (cf. docx).

  • siteCode : le Site actuel n'a pas de colonne code. On ajoute site.code (VARCHAR court, ex. 86/17/82) — backfill par défaut = 2 premiers chiffres du postal_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 NULL ne peut PAS être posé dans la migration M5 seule. Sur base fraîche (make db-reset), les fixtures SitesFixtures font new Site(...) via l'ORM, qui ne connaît code que si la propriété est mappée sur l'entité Site.php (pas le cas avant ERP-183) → INSERT sans code → violation NOT NULL. Décision :
    • ERP-182 (migration) : créer site.code NULLABLE + backfill + index unique (les NULL multiples sont tolérés par l'index unique Postgres). make db-reset passe, aucun test cassé.
    • ERP-183 (entité) : mapper Site::code (propriété + getter/setter), le peupler dans SitesFixtures (86/17/82) + SeedE2ECommand, ajuster les tests Sites en collision d'unicité (ex. SiteApiTest créant un site CP 86000code 86 = collision avec Châtellerault), puis poser NOT NULL via une 2ᵉ petite migration.
  • Séquence par site : table dédiée weighing_ticket_counter (site_id PK, last_value INT). À la création : SELECT ... FOR UPDATE sur 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 UPDATE est 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\WeighbridgeReaderInterface
    interface 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\RandomWeighbridgeReaderweight = 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ève WeighbridgeUnavailableException, 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 ligne FOR 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 le WeighingTicketProcessor à chaque POST/PATCH dès que empty_weight ET full_weight sont renseignés (sinon null).
  • La colonne liste « Poids » = net_weight (cf. § 4.0). Le détail/ticket affiche vide + plein + net.
  • Exemple maquette : plein 14 300 vide 7 150 = net 7 150 kg.

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 masque XX-000-XX (plaque FR SIV). Si plate_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) : immatriculation et plate_free_format sont 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-formatage XX-000-XX ; rejet 422 si format invalide et plate_free_format = false.

2.11 Audit & traces temporelles

Pattern Starseed standard (miroir M1→M4) :

  • #[Auditable] sur WeighingTicket. 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) : ajouter audit.entity.logistique_weighingticket dans frontend/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'endpoint GET /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 helper addStandardTimestampableBlamableComments. SQL ci-dessous illustratif (convention INT 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 / Site appartiennent à 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 normalizationContext de 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 numberweighing_ticket:read
Client clientweighing_ticket:read (embed) client:read (RG-5.03)
Fournisseur supplierweighing_ticket:read (embed) supplier:read
Autre otherLabelweighing_ticket:read
Date fullDate ?? emptyDate (date du ticket) ∈ weighing_ticket:read
Poids netWeightweighing_ticket:read

Note « Date » liste : on expose une propriété calculée displayDate (getter) = fullDate ?? emptyDate, dans weighing_ticket:read (les empty/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 :

  1. client / supplier doivent sortir en objet embarqué, pas en IRI nu → read-groups client:read/supplier:read.
  2. Booléen plateFreeFormat : clé présente (piège #3 M1 → getter + SerializedName si besoin).
  3. number présent et formaté {siteCode}-TP-{NNNN}.
  4. netWeight cohé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) si WeighbridgeUnavailableException : { "title": "Pont bascule indisponible", "detail": "Passez en pesée manuelle." }.
  • 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).

Implémentation : #[ApiResource] non-Doctrine (DTO WeighbridgeReadingInput/Output) + Processor dédié, OU une ressource WeighbridgeReading virtuelle. Pas de controller Symfony (règle backend). Le Processor appelle WeighbridgeReaderInterface + le DsdAllocator (verrou FOR UPDATE).

Concurrence DSD : le dsd renvoyé ici est prévisionnel. L'attribution autoritaire du dsd (et du number) 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 :
    1. Résout le site courant (CurrentSiteProviderInterface) → site_id.
    2. Attribue le numéro {siteCode}-TP-{NNNN} (compteur verrouillé — RG-5.02).
    3. (Re)attribue les dsd autoritaires si nécessaire (verrou — RG-5.04).
    4. Normalise immatriculation (RG-5.01) ; valide la cohérence contrepartie (RG-5.03) et pesées.
    5. Calcule net_weight = full_weight - empty_weight si les deux poids sont présents (RG-5.05).
  • Réponse 201 avec 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_weight recalculé. 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-Type xlsx) — 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 :

  1. config/sidebar.php (item + permission ci-dessus),
  2. frontend/tests/e2e/_fixtures/personas.ts (ajuster un persona existant : Usine gagne weighing_tickets.view/manage + expectedAdminLinks),
  3. 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'erreur WeighbridgeUnavailableException → 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)