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