Files
Starseed/docs/specs/M5-tickets-pesee/spec-back.md
T
Matthieu ab15452459 test(logistique) : tests PHPUnit RG-5.01→5.10 + capture contrat JSON (ERP-187)
Couverture des règles de gestion du M5 (tickets de pesée) et capture de la
réponse JSON réelle (DoD § 4.0.bis) avant les écrans front.

Tests unitaires (Processor/Normalizer/Callback, sans BDD ni HTTP) :
- NetWeightTest (RG-5.05) : net = plein − vide, null si pesée manquante, recalcul PATCH.
- CounterpartyValidationTest (RG-5.03) : présence par branche (propertyPath) + exclusivité.
- ImmatriculationNormalizationTest (RG-5.01/5.10) : masque XX-000-XX, « Tout format », 422.

Tests fonctionnels (API réelle) :
- WeighingTicketNumberingTest (RG-5.02/5.09) : format {siteCode}-TP-{NNNN}, séquence
  par site, isolation inter-sites, immuabilité numéro/site au PATCH.
- WeighingTicketSerializationContractTest (DoD § 4.0.bis) : 4 pièges (client embarqué,
  plateFreeFormat présent, number formaté, netWeight = full − empty) + dump JSON.
- WeighingTicketRBACMatrixTest (§ 5.2) : admin/bureau/usine OK, compta/commerciale 403,
  anonyme 401.

DSD/stub/reading déjà couverts (ERP-184/185). spec-back.md § 4.0.bis : JSON réel collé.
2026-06-18 12:01:58 +02:00

50 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 Bon de pesée — PDF généré côté serveur via template Twig (RG-5.08)

DÉCISION Matthieu (17/06) : le bon de pesée est généré côté back par un template Twig → PDF (et non un gabarit imprimé par le navigateur). OWNER : Tristan (ticket back dédié, cf. § 10). Cette spec en pose le contrat (endpoint, contenu, données).

Contrat attendu :

  • Endpoint : GET /api/weighing_tickets/{id}/print.pdf (opération API Platform dédiée, pas de controller — provider renvoyant un binaire). Sécurité is_granted('logistique.weighing_tickets.view'). Réponse Content-Type: application/pdf (inline).
  • Rendu : un template Twig (templates/logistique/weighing_ticket_print.html.twig) hydraté avec le ticket → converti en PDF via le générateur PDF du projet (ex. Dompdf / wkhtmltopdf / Gotenberg — s'aligner sur l'existant ; sinon proposer une lib et la cadrer avec Matthieu).
  • Contenu 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. (En-tête / logo / mentions = à caler par Tristan.)
  • Données : toutes déjà disponibles sur le ticket (mêmes champs que GET /api/weighing_tickets/{id} § 4.0) — aucun champ API supplémentaire requis.
  • Déclencheurs front (RG-5.08) : à la validation (création), le front ouvre l'aperçu/PDF servi par cet endpoint ; en modification, le bouton « Imprimer » ouvre le même PDF (absent à l'ajout).

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 — CAPTURÉE sur l'API réelle )

Definition of Done (miroir M2/M3/M4) : FAIT (ERP-187). Le JSON ci-dessous est la réponse RÉELLE capturée par le test WeighingTicketSerializationContractTest::testListAndDetailSerializationContract (ticket créé via POST /api/weighing_tickets — numérotation serveur réelle — contrepartie Client, pesée vide + plein AUTO). Re-capturable : WEIGHING_TICKET_DOD_DUMP=1/tmp/weighing-ticket-dod-{list,detail}.json. Feu vert front. Toute donnée affichée par le front DOIT apparaître dans ce JSON.

Pièges re-testés — tous VERTS (assertions dans le test) :

  1. client sort en objet embarqué (client:read), pas en IRI nu ; supplier omis car null (skip_null_values — jamais un IRI nu). Sur une contrepartie Fournisseur, supplier sortirait symétriquement en objet (supplier:read).
  2. Booléen plateFreeFormat : clé présente (getter isPlateFreeFormat() + SerializedName('plateFreeFormat')).
  3. number présent et formaté {siteCode}-TP-{NNNN} (ici 86-TP-0001).
  4. netWeight cohérent = full - empty = 14300 - 7150 = 7150 (RG-5.05).

Note skip_null_values : les champs null sont omis du JSON (ex. supplier, otherLabel, emptyManualNumber, fullManualNumber absents quand null). Le front ne doit pas présumer leur présence — lire avec un défaut (?? null).

GET /api/weighing_tickets?search=86-TP-0001 (LISTE) — enveloppe Hydra AP4 (member/totalItems/view), filtrée site courant (§ 2.3). Capture réelle :

{
  "@context": "/api/contexts/WeighingTicket",
  "@id": "/api/weighing_tickets",
  "@type": "Collection",
  "totalItems": 1,
  "member": [
    {
      "@id": "/api/weighing_tickets/9",
      "@type": "WeighingTicket",
      "id": 9,
      "number": "86-TP-0001",
      "counterpartyType": "CLIENT",
      "client": {
        "@id": "/api/clients/629",
        "@type": "Client",
        "id": 629,
        "companyName": "NÉGOCE MÉTAUX ATLANTIQUE",
        "triageService": false,
        "categories": [],
        "createdAt": "2026-06-18T11:50:47+02:00",
        "updatedAt": "2026-06-18T11:50:47+02:00",
        "createdBy": "/api/me",
        "updatedBy": "/api/me",
        "sites": [],
        "isArchived": false
      },
      "plateFreeFormat": false,
      "netWeight": 7150,
      "createdAt": "2026-06-18T11:50:48+02:00",
      "updatedAt": "2026-06-18T11:50:48+02:00",
      "createdBy": "/api/me",
      "updatedBy": "/api/me",
      "displayDate": "2026-06-17T09:12:00+02:00"
      // supplier / otherLabel omis (null → skip_null_values)
    }
  ],
  "view": { "@id": "/api/weighing_tickets?search=86-TP-0001", "@type": "PartialCollectionView" }
}

GET /api/weighing_tickets/9 (DÉTAIL) — ajoute le site embarqué (avec code), l'immatriculation et les deux pesées. Capture réelle :

{
  "@context": "/api/contexts/WeighingTicket",
  "@id": "/api/weighing_tickets/9",
  "@type": "WeighingTicket",
  "id": 9,
  "number": "86-TP-0001",
  "site": {
    "@id": "/api/sites/1",
    "@type": "Site",
    "id": 1,
    "name": "Chatellerault",
    "code": "86",
    "street": "14 All. d'Argenson",
    "postalCode": "86100",
    "city": "Châtellerault",
    "color": "#056CF2",
    "createdAt": "2026-06-17T17:07:47+02:00",
    "updatedAt": "2026-06-17T17:07:47+02:00",
    "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
  },
  "counterpartyType": "CLIENT",
  "client": {
    "@id": "/api/clients/629",
    "@type": "Client",
    "id": 629,
    "companyName": "NÉGOCE MÉTAUX ATLANTIQUE",
    "triageService": false,
    "categories": [],
    "createdAt": "2026-06-18T11:50:47+02:00",
    "updatedAt": "2026-06-18T11:50:47+02:00",
    "createdBy": "/api/me",
    "updatedBy": "/api/me",
    "sites": [],
    "isArchived": false
  },
  "immatriculation": "AB-123-CD",
  "plateFreeFormat": false,
  "emptyDate": "2026-06-17T09:00:00+02:00",
  "emptyWeight": 7150,
  "emptyDsd": 1,
  "emptyMode": "AUTO",
  "fullDate": "2026-06-17T09:12:00+02:00",
  "fullWeight": 14300,
  "fullDsd": 2,
  "fullMode": "AUTO",
  "netWeight": 7150,
  "createdAt": "2026-06-18T11:50:48+02:00",
  "updatedAt": "2026-06-18T11:50:48+02:00",
  "createdBy": "/api/me",
  "updatedBy": "/api/me",
  "displayDate": "2026-06-17T09:12:00+02:00"
  // emptyManualNumber / fullManualNumber omis (null → skip_null_values)
}

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.

4.6 Impression — GET /api/weighing_tickets/{id}/print.pdf (bon de pesée, OWNER Tristan)

  • Opération API Platform dédiée (provider renvoyant un binaire PDF, pas de controller). Sécurité is_granted('logistique.weighing_tickets.view').
  • Rendu d'un template Twig (templates/logistique/weighing_ticket_print.html.twig) → PDF (cf. § 2.12). Content-Type: application/pdf, inline.
  • Contenu : cf. § 2.12. Données déjà portées par le ticket — aucun champ API supplémentaire.

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 le bon de pesée (PDF servi par le back). En modification : bouton « Valider » → « Enregistrer », bouton « Imprimer » disponible (absent à l'ajout) → ouvre le même PDF. Le bouton « Enregistré » du bloc pesée à vide disparaît en modification. Bon de pesée = PDF généré back via template Twig, OWNER Tristan (§ 2.12 / § 4.6).
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 Passé en périmètre : bon de pesée = PDF serveur via template Twig → ticket back dédié (OWNER Tristan, § 2.12 / § 4.6).
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
6.bis (ERP-192) Bon de pesée — PDF via template Twig (/print.pdf, § 2.12 / § 4.6) Backend (OWNER Tristan)
7 Page liste /weighing-tickets (usePaginatedList) + export Frontend
8 Écran Ajouter (formulaires vide + plein, pesée bascule/manuelle, masque immat) + ouverture PDF à la validation Frontend
9 Écran Modification + bouton « Imprimer » (ouvre le PDF back) Frontend
10 i18n + libellé audit + branchement site courant Frontend