--- # === IDENTITÉ === module: M4 nom: "Répertoire transporteurs" ecran: repertoire-transporteurs owner_spec: Matthieu backup_spec: Tristan version: V0.1 date_redaction: 2026-06-15 # Historique : # V0.1 (2026-06-15) — Spec back initiale. S'appuie sur le module `Transport` déjà créé # (ERP-150) et sur les référentiels synchronisés `qualimat_carrier` (ERP-39) et # `idtf_product` (ERP-149). Restitution + précisions back du docx fonctionnel # « M4-repertoire-transporteurs-V0 » (validé 27/05/2026) et de la maquette Figma. # Décisions Matthieu (15/06) : lien QUALIMAT = FK + copie éditable ; PAS de cloisonnement # par site ; infra d'upload réutilisable dans `Shared` (plusieurs usages à venir). # === LIENS === spec_front: ./spec-front.md maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-45376&p=f&m=dev" trace_fonctionnelle: "uploads/M4-repertoire-transporteurs-V0.pdf / .docx (V0, validé 27/05/2026)" # === LIEN LESSTIME === lesstime_project_id: 6 lesstime_taskgroup_id: 31 # M4 — Répertoire transporteurs (tickets ERP-153 → ERP-171) statut_global: pret_a_dev # === DÉPENDANCES AMONT === depend_de: - Transport # module créé (ERP-150) ; référentiels qualimat_carrier (ERP-39) + idtf_product (ERP-149) - Commercial # Client (M1) + Supplier (M2) + leurs adresses → onglet Prix - Sites # SitesModule + 3 sites (86 / 17 / 82) — adresses départ/livraison du Prix - Core # User, Role, Permission, Audit, JWT déjà en place - Shared # TimestampableBlamableTrait + Subscriber (ERP-52) + NOUVELLE infra upload (§ 2.7) --- # Spec back — Module 4 : Répertoire transporteurs ## 1. Contexte Cette spec **complète et précise** la [spec front V0.1](./spec-front.md) (docx `M4-repertoire-transporteurs-V0`, validé le 27/05/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-4.01 → RG-4.11 + précisions back), tests, hors-périmètre. **Module cible** : module **`Transport`** **déjà créé** (`src/Module/Transport/`, ERP-150). Le M4 lui **ajoute son premier périmètre fonctionnel exposé** : le **répertoire des transporteurs** (entité `Carrier` éditée par l'utilisateur), qui s'appuie sur les **référentiels déjà synchronisés par commandes console** : - **`qualimat_carrier`** (ERP-39) — transporteurs agréés QUALIMAT, synchro quotidienne depuis qualimat.org. Sert la **saisie assistée** du nom (RG-4.01). - **`idtf_product`** (ERP-149) — codes IDTF (régimes de nettoyage). **Pas utilisé par les écrans M4** (référentiel autonome, hors périmètre des écrans transporteurs — cf. § 9). > **À ce stade `TransportModule::permissions()` renvoie `[]`** (cf. branche `feat/erp-150-module-transport`). Le M4 le remplit (§ 5.1) et expose la première section sidebar du module. > **RETEX obligatoire** : le M4 réutilise le pattern de sérialisation éprouvé M1/M2/M3 (`spec-back.md` des modules clients/fournisseurs/prestataires). ~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 M4. **Dépendances déjà en place sur `develop`** : - `Transport` → tables `qualimat_carrier` / `qualimat_sync_log` / `idtf_product` / `idtf_sync_log` (migrations `Version20260612150000` / `Version20260612160000`). - `Commercial` → `Client` (M1) + `Supplier` (M2) + leurs adresses (onglet Prix). - `Sites` → 3 sites Châtellerault (86) / Saint-Jean (17) / Pommevic (82). - `Shared` → `TimestampableBlamableTrait` + `Subscriber` (ERP-52). - `Core` → User, Role, Permission, Audit, JWT. ## 2. Décisions d'archi ### 2.1 Entité `Carrier` dans le module `Transport` (pas de nouveau module) Le répertoire transporteurs vit dans le **module `Transport` existant**. On crée l'entité **`Carrier`** (transporteur saisi par l'utilisateur) + ses sous-collections `CarrierAddress`, `CarrierContact`, `CarrierPrice`, sous `src/Module/Transport/Domain/Entity/`. **`Carrier` ≠ `qualimat_carrier`** : - `qualimat_carrier` est un **référentiel en lecture seule** alimenté par la synchro console (jamais édité par l'utilisateur). - `Carrier` est l'**entité métier éditable** du répertoire. Elle **peut** référencer une ligne `qualimat_carrier` (lien QUALIMAT — § 2.5) mais existe aussi pour des transporteurs non-QUALIMAT (GMP+, OVOCOM, compte-propre, LIOT, autre). **Référentiels cross-module consommés en relation ORM partagée (PAS d'import de logique)** — exactement comme M2/M3 : l'onglet Prix référence `Client` / `Supplier` (module Commercial), leurs adresses, et `Site` (module 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é). Conforme à la tolérance déjà actée M1/M2/M3 (règle ABSOLUE n°1 vise les dépendances de **logique** métier). ### 2.2 IDs — cohérence avec le référentiel Transport Les tables référentielles du module Transport utilisent `BIGINT GENERATED BY DEFAULT AS IDENTITY` (cf. `qualimat_carrier`). Les **nouvelles** tables métier M4 (`carrier` et sous-collections) suivent la même convention **`BIGINT GENERATED BY DEFAULT AS IDENTITY`** pour rester homogène **dans le module Transport** (différence assumée vs `INT` des modules M1/M2/M3 — on s'aligne sur le module hôte). Horodatages en `TIMESTAMP(0) WITHOUT TIME ZONE` (le `TimestampableBlamableTrait` mappe `datetime_immutable`). > **Point de raffinement (non bloquant)** : si l'on préfère l'homogénéité globale Starseed (`INT`), basculer toutes les tables M4 en `INT`. Décision par défaut retenue ici : `BIGINT` (cohérence intra-module Transport). À confirmer au ticket migration. ### 2.3 Pas de cloisonnement par site (DÉCISION Matthieu, 15/06/2026) > **Décision** : le répertoire transporteurs est un **référentiel global** — **aucun cloisonnement par site** (contrairement au M3 prestataires). Tout rôle autorisé en consultation (Admin / Bureau / Commerciale) voit **tous** les transporteurs. Conforme à la colonne « Consultation = Tout » du docx pour ces rôles. Conséquence : **pas** de `ProviderSiteScopeExtension`, pas de `currentSite` dans le filtrage, pas de `sites.bypass_scope`. Le `Carrier` **ne porte pas** de relation `sites` au niveau de la fiche (les sites n'apparaissent que dans l'onglet Prix comme **adresse de départ/livraison**, en valeur, pas comme périmètre de visibilité). ### 2.4 Archive vs soft delete — deux mécanismes distincts (identique M1/M2/M3) | Mécanisme | Colonne | Visibilité défaut | Restauration | Utilisateur | |---|---|---|---|---| | **Archive** (fonctionnel) | `is_archived` (bool, default false) + `archived_at` | masqué | Oui (toggle UI) | **Admin seul** via `transport.carriers.archive` | | **Soft delete** (technique) | `deleted_at` (timestamptz nullable) | masqué | HP | Aucun rôle au M4 (HP) | Conséquences (miroir M3) : - `DELETE /api/carriers/{id}` **non exposé** au M4 (404 si appelé). - `GET /api/carriers?includeArchived=true` permet de voir les archivés (permission `transport.carriers.view`). - PATCH `{ "isArchived": true }` archive ; PATCH `{ "isArchived": false }` restaure. - L'unicité métier ignore les archivés ET les soft-deletés (§ 2.6). ### 2.5 Lien QUALIMAT — FK + copie éditable (DÉCISION Matthieu, 15/06/2026) > **Décision** : quand l'utilisateur sélectionne un transporteur dans l'onglet QUALIMAT (RG-4.01), on **conserve une FK** `carrier.qualimat_carrier_id` **ET** on **copie** au moment de la sélection : `name`, la certification (`certification_type = QUALIMAT`) et les champs adresse (pays / code postal / ville / voie) dans une `CarrierAddress`. Les champs copiés **restent éditables** et **survivent à une désync QUALIMAT** (FK `ON DELETE SET NULL`). - `qualimat_carrier_id` : FK nullable vers `qualimat_carrier(id)`, `ON DELETE SET NULL` (si la ligne QUALIMAT disparaît du référentiel, le transporteur du répertoire est conservé, lien rompu proprement). - **Pas de FK figée à la migration** vers le référentiel pour les autres champs : on copie les **valeurs** (snapshot éditable). Le lien sert à la traçabilité de la source + au statut/date de validité QUALIMAT affichés (`qualimat_carrier.status` / `validity_date`, RG-4.04). - **Certification d'un transporteur QUALIMAT** : `certification_type = 'QUALIMAT'`, **lecture seule** côté front tant que `qualimat_carrier_id` est non nul. Les transporteurs non-QUALIMAT prennent une valeur de la liste `GMP_PLUS` / `OVOCOM` / `COMPTE_PROPRE` / `AUTRE` (RG-4.02). - **Modal de confirmation** « Êtes-vous sûr de vouloir intégrer ce transporteur ? » : pur front (RG-4.01 / RG-4.03 du docx) — au back c'est un simple POST/PATCH portant `qualimatCarrier` + les valeurs copiées. ### 2.6 Unicité partielle Postgres — nom de transporteur > **Décision (alignée M1/M2/M3 § 2.6)** : l'unicité métier porte **uniquement sur le nom** (`carrier.name`). Pas d'unicité sur le SIRET (le référentiel QUALIMAT lui-même a des SIRET parfois incomplets) ni ailleurs. Index unique partiel (`WHERE is_archived = FALSE AND deleted_at IS NULL`) sur `LOWER(name)`. Doublon → `409 Conflict` géré par le `CarrierProcessor`. > **Cas LIOT (RG-4.01)** : « LIOT » est un transporteur compte-propre particulier (flotte interne). Le nom `LIOT` reste soumis à l'unicité comme les autres (un seul `Carrier` nommé LIOT actif). Voir § 2.9 pour le comportement de saisie. ### 2.7 Upload de fichiers — infra réutilisable dans `Shared` (DÉCISION Matthieu, 15/06/2026) Le champ **« Décharge »** (upload, visible si `certification_type = AUTRE` — RG-4.02) est le **premier** d'une **série d'uploads à venir** dans l'ERP (« il va y en avoir pas mal »). On **ne fait donc pas** un upload ad hoc sur `carrier` : on pose une **infra d'upload générique et réutilisable** dans `Shared`. **Proposition (à câbler au ticket dédié)** : - Table `uploaded_document` (module `Shared` / `Core`) : `id`, `original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum` (sha256), `created_at`, `created_by`. - Service `Shared\Infrastructure\Upload\FileUploader` : valide le MIME **côté serveur via `$file->getMimeType()`** (jamais `getClientMimeType()` — règle ABSOLUE backend), borne la taille, calcule le checksum, écrit sur disque (chemin configurable `%kernel.project_dir%/var/uploads/{yyyy}/{mm}/`), persiste la ligne, retourne l'IRI `/api/uploaded_documents/{id}`. - Endpoint `POST /api/uploaded_documents` (multipart, `#[ApiResource]` + Processor dédié) → renvoie l'IRI ; whitelist MIME (PDF + images au minimum pour la décharge). - `carrier.discharge_document_id` : FK nullable vers `uploaded_document(id)`, `ON DELETE SET NULL`. > **Périmètre M4** : livrer l'infra upload **minimale mais générique** (table + service + endpoint + 1 consommateur = la décharge). Les autres consommateurs (pièces jointes contrats, documents fournisseurs, etc.) la **réutiliseront** sans la réécrire. La conception détaillée de l'infra (antivirus, stockage objet S3, purge) est tracée HP-M4-… (§ 9). > **Garde-fou MIME** : valider serveur (`$file->getMimeType()`), whitelist explicite, refuser le reste → 422. ### 2.8 Audit & traces temporelles Pattern Starseed standard, miroir M1/M2/M3 : - `#[Auditable]` sur `Carrier`, `CarrierAddress`, `CarrierContact`, `CarrierPrice`. - **Tous les champs auditables** (pas de champ sensible type password/token ici → pas d'`#[AuditIgnore]`). - Audit des FK (`qualimatCarrier`, `client`, `supplier`, `departureSite`…) tracé automatiquement. - **Libellés i18n** (règle ABSOLUE backend — `AuditableEntitiesHaveI18nLabelTest`) : ajouter dans `frontend/i18n/locales/fr.json` (clé = `strtolower(module)` + `_` + `strtolower(Entity)`) : `audit.entity.transport_carrier`, `audit.entity.transport_carrieraddress`, `audit.entity.transport_carriercontact`, `audit.entity.transport_carrierprice`. ### 2.9 Workflow de saisie & champs conditionnels (formulaire principal) Le **formulaire principal** porte des champs **conditionnels** (RG-4.02 / RG-4.03 / cas LIOT). Le back **ne maintient pas de state machine** : il stocke ce qui est envoyé et **valide la cohérence** au POST/PATCH. Logique : | Déclencheur | Champs activés / obligatoires | RG | |---|---|---| | `qualimat_carrier_id` non nul (transporteur QUALIMAT) | `certification_type = QUALIMAT` (lecture seule) ; `name` + adresse copiés | RG-4.01 | | `name == 'LIOT'` (cas spécial) | `liot_plates` visible et seul champ pertinent ; les autres champs (certif/affrété/benne/volume) masqués | RG-4.01 | | `certification_type == AUTRE` | `discharge_document` (upload Décharge) visible | RG-4.02 | | `is_chartered == true` (« Affréter » coché) | `indexation_rate`, `container_type` (Benne/Fond mouvant), `volume_m3` visibles **et obligatoires** | RG-4.03 | > **Validation incrémentale par onglet (workflow front-driven, identique M2/M3)** : `Carrier` créé en BDD **dès validation du formulaire principal** via `POST /api/carriers`. Onglets suivants (Adresse / Contact / Prix) → **PATCH partiels** / **sous-ressources** avec groupes de sérialisation dédiés : > - `carrier:write:main` — formulaire principal (POST + PATCH) > - `carrier:write:addresses` — onglet Adresse (sous-ressource `carrier_address`) > - `carrier:write:contacts` — onglet Contact (sous-ressource `carrier_contact`) > - `carrier:write:prices` — onglet Prix (sous-ressource `carrier_price`) > - `carrier:write:archive` — toggle archive (security `transport.carriers.archive`) ### 2.10 Normalisation serveur des entrées texte (identique M1/M2/M3) `CarrierFieldNormalizer` (miroir `SupplierFieldNormalizer`/`ProviderFieldNormalizer`), service interne appelé par les Processors avant validation : ```php final class CarrierFieldNormalizer { public function normalizeName(?string $v): ?string // mb_strtoupper(trim) → RG-4.12 public function normalizePersonName(?string $v): ?string // mb_convert_case TITLE public function normalizeEmail(?string $v): ?string // mb_strtolower(trim) public function normalizePhone(?string $v): ?string // preg_replace('/\D+/', '') public function normalizeLiotPlates(?string $v): ?string // split ';', trim, UPPER, rejoin '; ' } ``` Le formatage `XX XX XX XX XX` (téléphones) est fait à l'affichage front. Le back stocke `0612345678` (chiffres seuls). ### 2.11 Liste : embed + hydratation anti-N+1 (cohérence M1/M2/M3) La **liste** `GET /api/carriers` **embarque** le minimum nécessaire au datatable (cf. § 4.0) : `name`, `certificationType`, statut/date de validité QUALIMAT (depuis `qualimatCarrier` embarqué, RG-4.04), `updatedAt`. Anti-N+1 : le `DoctrineCarrierRepository` ne fetch-joine PAS les to-many (contacts/adresses/prix) dans la requête de liste ; il fetch-joine au plus `qualimat_carrier` (ManyToOne, sûr). Le contrat de sérialisation (groupes dans le contexte) est posé **une seule fois** sur l'entité. ## 3. Modèle de données ### 3.1 Diagramme ``` +------------------------+ +------------------------+ | qualimat_carrier |<--n:1--| carrier | | (référentiel ERP-39, | (FK | id (PK) | | lecture seule) | nullable| name (UNIQUE actif) | +------------------------+ SET NULL)| certification_type | | is_chartered | +------------------------+ | indexation_rate | | uploaded_document |<--n:1-- discharge_document_id ---| container_type | | (Shared, § 2.7) | (FK nullable) | volume_m3 | +------------------------+ | liot_plates | | is_archived / deleted | carrier 1:n carrier_address +---------------------+ +------------------------+ carrier 1:n carrier_contact | carrier_price | | 1:n carrier 1:n carrier_price ------>| direction CLIENT/ | | | FOURNISSEUR | +------------------+ (Prix → relations ORM partagées) | client_id (M1) |-->| client (M1) | | client_delivery_addr| | supplier (M2) | | departure_site_id |-->| site (Sites) | | supplier_id (M2) | | client_address | | supplier_supply_addr| | supplier_address | | delivery_site_id | +------------------+ | container_type | | pricing_unit | | price / price_state | +---------------------+ ``` ### 3.2 Migration Doctrine — SQL Postgres Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrations/VersionYYYYMMDDHHMMSS.php` (à dater, **postérieur** à `Version20260612160000`). > **Même justification qu'aux M1/M2/M3** : la migration crée un schéma avec **FK cross-module** (`user`, `client`, `supplier`, `site`, `qualimat_carrier`, `uploaded_document`). 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* (style aligné module Transport : `BIGINT GENERATED BY DEFAULT AS IDENTITY`, `TIMESTAMP(0)`). ```sql -- ===================================================================== -- Infra upload générique (Shared) — § 2.7 -- ===================================================================== CREATE TABLE uploaded_document ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, original_filename VARCHAR(255) NOT NULL, stored_path VARCHAR(512) NOT NULL, mime_type VARCHAR(128) NOT NULL, size_bytes INT NOT NULL, checksum VARCHAR(64) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, created_by INT REFERENCES "user"(id) ON DELETE SET NULL ); -- ===================================================================== -- Table principale `carrier` (transporteur du répertoire) -- ===================================================================== CREATE TABLE carrier ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, -- Lien référentiel QUALIMAT (FK + copie éditable — § 2.5) qualimat_carrier_id BIGINT REFERENCES qualimat_carrier(id) ON DELETE SET NULL, -- Formulaire principal name VARCHAR(255) NOT NULL, certification_type VARCHAR(20), -- QUALIMAT|GMP_PLUS|OVOCOM|COMPTE_PROPRE|AUTRE ; null seulement en cas LIOT (RG-4.01). Requis sinon (Processor). is_chartered BOOLEAN NOT NULL DEFAULT FALSE, -- « Affréter » (RG-4.03) indexation_rate NUMERIC(5,2), -- % (si affrété — RG-4.03) container_type VARCHAR(12), -- BENNE|FOND_MOUVANT (si affrété — RG-4.03) volume_m3 NUMERIC(10,2), -- (si affrété — RG-4.03) discharge_document_id BIGINT REFERENCES uploaded_document(id) ON DELETE SET NULL, -- (si AUTRE — RG-4.02) liot_plates TEXT, -- immatriculations LIOT « ; » (cas LIOT — RG-4.01) -- Archive (exposé M4) is_archived BOOLEAN NOT NULL DEFAULT FALSE, archived_at TIMESTAMP(0) WITHOUT TIME ZONE, -- Soft delete (préparé, non exposé au M4) 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_carrier_certification_type CHECK (certification_type IS NULL OR certification_type IN ('QUALIMAT','GMP_PLUS','OVOCOM','COMPTE_PROPRE','AUTRE')), CONSTRAINT chk_carrier_container_type CHECK (container_type IS NULL OR container_type IN ('BENNE','FOND_MOUVANT')) ); CREATE INDEX idx_carrier_is_archived ON carrier(is_archived); CREATE INDEX idx_carrier_deleted_at ON carrier(deleted_at); CREATE INDEX idx_carrier_qualimat ON carrier(qualimat_carrier_id); CREATE INDEX idx_carrier_created_by ON carrier(created_by); CREATE INDEX idx_carrier_updated_by ON carrier(updated_by); -- Unicité métier (partielle : ignore archives + soft-delete) — nom seul (§ 2.6) CREATE UNIQUE INDEX uq_carrier_name_active ON carrier (LOWER(name)) WHERE is_archived = FALSE AND deleted_at IS NULL; -- ===================================================================== -- Sous-collection : Adresses (1:n) -- ===================================================================== CREATE TABLE carrier_address ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, carrier_id BIGINT NOT NULL REFERENCES carrier(id) ON DELETE CASCADE, country VARCHAR(80) NOT NULL DEFAULT 'France', postal_code VARCHAR(20), city VARCHAR(120), street VARCHAR(255), street_complement VARCHAR(255), position INT NOT NULL DEFAULT 0, 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 ); CREATE INDEX idx_carrier_address_carrier ON carrier_address(carrier_id); -- ===================================================================== -- Sous-collection : Contacts (1:n) -- ===================================================================== CREATE TABLE carrier_contact ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, carrier_id BIGINT NOT NULL REFERENCES carrier(id) ON DELETE CASCADE, first_name VARCHAR(120), last_name VARCHAR(120), job_title VARCHAR(120), phone_primary VARCHAR(20), phone_secondary VARCHAR(20), email VARCHAR(180), position INT NOT NULL DEFAULT 0, 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, -- RG-4.08 : au moins 1 champ rempli (garanti côté Processor ; CHECK = garde-fou minimal) CONSTRAINT chk_carrier_contact_filled CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL) ); CREATE INDEX idx_carrier_contact_carrier ON carrier_contact(carrier_id); -- ===================================================================== -- Sous-collection : Prix (1:n) — onglet Prix (RG-4.09 → RG-4.11) -- ===================================================================== CREATE TABLE carrier_price ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, carrier_id BIGINT NOT NULL REFERENCES carrier(id) ON DELETE CASCADE, direction VARCHAR(12) NOT NULL, -- CLIENT|FOURNISSEUR (RG-4.09) -- Branche CLIENT (RG-4.10) client_id INT REFERENCES client(id) ON DELETE RESTRICT, client_delivery_address_id INT REFERENCES client_address(id) ON DELETE RESTRICT, departure_site_id INT REFERENCES site(id) ON DELETE RESTRICT, -- adresse de départ (86/17/82) -- Branche FOURNISSEUR (RG-4.11) supplier_id INT REFERENCES supplier(id) ON DELETE RESTRICT, supplier_supply_address_id INT REFERENCES supplier_address(id) ON DELETE RESTRICT, -- adresse d'approvisionnement delivery_site_id INT REFERENCES site(id) ON DELETE RESTRICT, -- adresse de livraison (86/17/82) -- Commun container_type VARCHAR(12) NOT NULL, -- BENNE|FOND_MOUVANT pricing_unit VARCHAR(8) NOT NULL, -- FORFAIT|TONNE price NUMERIC(12,2) NOT NULL, price_state VARCHAR(12) NOT NULL, -- EN_COURS|VALIDE|NON_VALIDE position INT NOT NULL DEFAULT 0, 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_carrier_price_direction CHECK (direction IN ('CLIENT','FOURNISSEUR')), CONSTRAINT chk_carrier_price_container CHECK (container_type IN ('BENNE','FOND_MOUVANT')), CONSTRAINT chk_carrier_price_unit CHECK (pricing_unit IN ('FORFAIT','TONNE')), CONSTRAINT chk_carrier_price_state CHECK (price_state IN ('EN_COURS','VALIDE','NON_VALIDE')), -- RG-4.10 : si CLIENT, les colonnes client_* sont requises et les supplier_* nulles CONSTRAINT chk_carrier_price_client_branch CHECK ( direction <> 'CLIENT' OR (client_id IS NOT NULL AND supplier_id IS NULL) ), -- RG-4.11 : si FOURNISSEUR, les colonnes supplier_* sont requises et les client_* nulles CONSTRAINT chk_carrier_price_supplier_branch CHECK ( direction <> 'FOURNISSEUR' OR (supplier_id IS NOT NULL AND client_id IS NULL) ) ); CREATE INDEX idx_carrier_price_carrier ON carrier_price(carrier_id); CREATE INDEX idx_carrier_price_client ON carrier_price(client_id); CREATE INDEX idx_carrier_price_supplier ON carrier_price(supplier_id); ``` ### 3.2.bis Commentaires SQL obligatoires (échantillon) ```php $this->addSql("COMMENT ON TABLE carrier IS 'Répertoire transporteurs (M4 Transport) — entités éditables, archivables. Distinct du référentiel qualimat_carrier.'"); $this->addSql("COMMENT ON COLUMN carrier.name IS 'Raison sociale du transporteur — stockée en MAJUSCULES. Unique parmi non-archivés/non-supprimés (RG-4.12 / § 2.6).'"); $this->addSql("COMMENT ON COLUMN carrier.qualimat_carrier_id IS 'Lien vers le référentiel QUALIMAT (saisie assistée RG-4.01). FK nullable ON DELETE SET NULL : transporteur conservé si la ligne QUALIMAT disparaît.'"); $this->addSql("COMMENT ON COLUMN carrier.certification_type IS 'Type de certification : QUALIMAT (si lié, lecture seule) ou GMP_PLUS/OVOCOM/COMPTE_PROPRE/AUTRE. AUTRE déclenche le champ Décharge (RG-4.02).'"); $this->addSql("COMMENT ON COLUMN carrier.is_chartered IS '« Affréter » coché : déclenche indexation/benne-fond mouvant/volume, obligatoires (RG-4.03).'"); $this->addSql("COMMENT ON COLUMN carrier.liot_plates IS 'Immatriculations LIOT séparées par « ; » (cas spécial nom=LIOT, RG-4.01). Les autres champs sont masqués dans ce cas.'"); $this->addSql("COMMENT ON COLUMN carrier_price.direction IS 'Sens du prix : CLIENT ou FOURNISSEUR (RG-4.09). Pilote l''affichage et l''obligation des colonnes client_*/supplier_* (RG-4.10/4.11).'"); $this->addSql("COMMENT ON COLUMN carrier_price.departure_site_id IS 'Adresse de départ = un des 3 sites (86/17/82). FK -> site.id. Branche CLIENT (RG-4.10).'"); $this->addSql("COMMENT ON COLUMN carrier_price.price_state IS 'État du prix : EN_COURS, VALIDE ou NON_VALIDE. Affiché dans le tableau Prix (regroupement Benne/Fond mouvant).'"); // + COMMENT ON COLUMN sur TOUTES les autres colonnes métier (règle n°12) $this->addStandardTimestampableBlamableComments($schema, 'carrier'); $this->addStandardTimestampableBlamableComments($schema, 'carrier_address'); $this->addStandardTimestampableBlamableComments($schema, 'carrier_contact'); $this->addStandardTimestampableBlamableComments($schema, 'carrier_price'); ``` ### 3.3 Entité `Carrier` — squelette (extrait) Pattern jumeau de `Supplier`/`Provider` (`#[Auditable]`, `TimestampableBlamableTrait`, sous-collections embarquées au détail). **Chaque propriété affichée porte un read-group** (RETEX M1 maillon (a)). ```php ['carrier:read', 'qualimat:read', 'default:read']], provider: CarrierProvider::class, ), new Get( security: "is_granted('transport.carriers.view')", normalizationContext: ['groups' => [ 'carrier:read', 'carrier:item:read', 'qualimat:read', 'client:read', 'client_address:read', 'supplier:read', 'supplier_address:read', 'site:read', 'default:read', ]], provider: CarrierProvider::class, ), new Post( security: "is_granted('transport.carriers.manage')", normalizationContext: ['groups' => ['carrier:read', 'default:read']], denormalizationContext: ['groups' => ['carrier:write:main']], processor: CarrierProcessor::class, ), new Patch( security: "is_granted('transport.carriers.manage')", normalizationContext: ['groups' => ['carrier:read', 'default:read']], denormalizationContext: ['groups' => ['carrier:write:main', 'carrier:write:archive']], provider: CarrierProvider::class, processor: CarrierProcessor::class, ), // Pas de Delete au M4 (HP). Archivage via PATCH { isArchived: true }. ], )] #[ORM\Entity(repositoryClass: DoctrineCarrierRepository::class)] #[ORM\Table(name: 'carrier')] #[Auditable] class Carrier implements TimestampableInterface, BlamableInterface { use TimestampableBlamableTrait; #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'bigint')] #[Groups(['carrier:read'])] private ?int $id = null; #[ORM\Column(length: 255)] #[Assert\NotBlank(message: 'Le nom du transporteur est obligatoire.', normalizer: 'trim')] #[Assert\Length(min: 2, max: 255, normalizer: 'trim')] #[Groups(['carrier:read', 'carrier:write:main'])] private ?string $name = null; /** Lien référentiel QUALIMAT (saisie assistée RG-4.01). */ #[ORM\ManyToOne(targetEntity: QualimatCarrier::class)] #[ORM\JoinColumn(name: 'qualimat_carrier_id', nullable: true, onDelete: 'SET NULL')] #[Groups(['carrier:read', 'carrier:write:main'])] private ?QualimatCarrier $qualimatCarrier = null; #[ORM\Column(length: 20, nullable: true)] #[Assert\Choice(choices: ['QUALIMAT', 'GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'], message: 'Type de certification invalide.')] // Obligatoire SAUF en cas LIOT (champ masqué) — contrôle conditionnel via #[Assert\Callback] (RG-4.01). #[Groups(['carrier:read', 'carrier:write:main'])] private ?string $certificationType = null; #[ORM\Column(options: ['default' => false])] #[Groups(['carrier:read', 'carrier:write:main'])] private bool $isChartered = false; #[ORM\Column(type: 'decimal', precision: 5, scale: 2, nullable: true)] #[Groups(['carrier:read', 'carrier:write:main'])] private ?string $indexationRate = null; // % — obligatoire si isChartered (RG-4.03, Callback) #[ORM\Column(length: 12, nullable: true)] #[Assert\Choice(choices: ['BENNE', 'FOND_MOUVANT'], message: 'Type de contenant invalide.')] #[Groups(['carrier:read', 'carrier:write:main'])] private ?string $containerType = null; // obligatoire si isChartered (RG-4.03) #[ORM\Column(type: 'decimal', precision: 10, scale: 2, nullable: true)] #[Groups(['carrier:read', 'carrier:write:main'])] private ?string $volumeM3 = null; // obligatoire si isChartered (RG-4.03) /** Décharge (upload, visible si certificationType = AUTRE — RG-4.02). Infra upload Shared (§ 2.7). */ #[ORM\ManyToOne(targetEntity: \App\Shared\Domain\Entity\UploadedDocument::class)] #[ORM\JoinColumn(name: 'discharge_document_id', nullable: true, onDelete: 'SET NULL')] #[Groups(['carrier:read', 'carrier:write:main'])] private ?UploadedDocument $dischargeDocument = null; #[ORM\Column(type: 'text', nullable: true)] #[Groups(['carrier:read', 'carrier:write:main'])] private ?string $liotPlates = null; // cas LIOT (RG-4.01) // === Sous-collections — EMBARQUÉES dans le DÉTAIL === /** @var Collection */ #[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[Groups(['carrier:item:read'])] private Collection $addresses; /** @var Collection */ #[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[Groups(['carrier:item:read'])] private Collection $contacts; /** @var Collection */ #[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierPrice::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[Groups(['carrier:item:read'])] private Collection $prices; // === Archive / Soft delete === #[ORM\Column(name: 'is_archived', options: ['default' => false])] private bool $isArchived = false; // ⚠ PIÈGE BOOLÉEN (RETEX M1 bug #3) : #[Groups] + #[SerializedName('isArchived')] SUR LE GETTER. #[Groups(['carrier:read', 'carrier:write:archive'])] #[SerializedName('isArchived')] public function isArchived(): bool { return $this->isArchived; } // RG-4.02 / RG-4.03 / cas LIOT : cohérence inter-champs via #[Assert\Callback] (§ 7). // ... archivedAt, getters/setters, __construct (ArrayCollection) ... } ``` ### 3.4 Squelettes des autres entités **`CarrierAddress`** — propriétés dans `['carrier:item:read', 'carrier:write:addresses']` : `country`, `postalCode`, `city`, `street`, `streetComplement`, `id`. Saisie assistée BAN (RG-4.06). Pour un transporteur QUALIMAT, une adresse est **pré-remplie depuis la copie** (RG-4.05) et le bouton « Valider » de l'onglet est masqué (RG-4.07). **`CarrierContact`** — propriétés dans `['carrier:item:read', 'carrier:write:contacts']` : `firstName`, `lastName`, `jobTitle`, `phonePrimary`, `phoneSecondary`, `email`, `id`. **Max 2 téléphones** (`phonePrimary` + `phoneSecondary`). RG-4.08 (≥ 1 champ rempli). **`CarrierPrice`** — propriétés dans `['carrier:item:read', 'carrier:write:prices']` : `direction`, `client` (ManyToOne `Client`, embed `client:read`), `clientDeliveryAddress` (ManyToOne `ClientAddress`, embed `client_address:read`), `departureSite` (ManyToOne `Site`, `site:read`), `supplier` (ManyToOne `Supplier`, `supplier:read`), `supplierSupplyAddress` (ManyToOne `SupplierAddress`, embed `supplier_address:read`), `deliverySite` (`site:read`), `containerType`, `pricingUnit`, `price`, `priceState`, `id`. Relations cross-module **embarquées** (maillon (c) — read-groups `client:read`/`client_address:read`/`supplier:read`/`supplier_address:read`/`site:read` dans le contexte du `Get` racine). **`QualimatCarrier`** (NOUVEAU mapping ORM sur la table existante `qualimat_carrier`) — entité **lecture seule** exposée pour la saisie assistée (§ 4.7). Propriétés sous `qualimat:read` : `id`, `siret`, `name`, `address`, `postalCode`, `city`, `phone`, `department`, `status`, `validityDate`, `isActive`. **Aucune écriture exposée** (alimentée par la commande console `app:qualimat:sync`). > ⚠ `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/M2/M3** : ~80 % des frictions venaient du contrat de sérialisation. 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) | `carrier:read` + `qualimat:read` + `default:read` | | `Get` (détail) | `carrier:read` + `carrier:item:read` + `qualimat:read` + `client:read` + `client_address:read` + `supplier:read` + `supplier_address:read` + `site:read` + `default:read` | **LISTE — champ datatable → maillons** : | Champ affiché | Propriété (a) | Dans contexte liste (b) | Imbriqué (c) | |---|---|---|---| | Nom | `name` ∈ `carrier:read` | ✅ | — | | Certification | `certificationType` ∈ `carrier:read` | ✅ | — | | Date de validité (QUALIMAT) | `qualimatCarrier.validityDate` ∈ `carrier:read` (embed) | ✅ | `qualimat:read` ✅ (RG-4.04) | | Dernière activité | `updatedAt` ∈ `carrier:read` | ✅ | — | **DÉTAIL — bloc → maillons** : | Bloc / champ | Propriété (a) | Dans contexte détail (b) | Imbriqué (c) | |---|---|---|---| | Scalaires principaux | `carrier:read` | ✅ | — | | `qualimatCarrier` (statut/validité) | `qualimatCarrier` ∈ `carrier:read` | ✅ | `qualimat:read` ✅ | | `addresses[]` | `addresses` ∈ `carrier:item:read` | ✅ | propriétés `CarrierAddress` ∈ `carrier:item:read` ✅ | | `contacts[]` | `contacts` ∈ `carrier:item:read` | ✅ | propriétés `CarrierContact` ∈ `carrier:item:read` ✅ | | `prices[]` (scalaires) | `prices` ∈ `carrier:item:read` | ✅ | propriétés `CarrierPrice` ∈ `carrier:item:read` ✅ | | `prices[].client` | `client` ∈ `carrier:item:read` | ✅ | `client:read` ✅ | | `prices[].clientDeliveryAddress` | ∈ `carrier:item:read` | ✅ | `client_address:read` ✅ (entité `ClientAddress`) | | `prices[].supplier` | `supplier` ∈ `carrier:item:read` | ✅ | `supplier:read` ✅ | | `prices[].supplierSupplyAddress` | ∈ `carrier:item:read` | ✅ | `supplier_address:read` ✅ (entité `SupplierAddress`) | | `prices[].departureSite` / `.deliverySite` | ∈ `carrier:item:read` | ✅ | `site:read` ✅ | ### 4.0.bis Réponses JSON de référence (DoD — à CAPTURER sur l'API réelle) > **Definition of Done** (miroir M2/M3) : avant de démarrer les écrans front, **capturer les réponses RÉELLES** via un test PHPUnit (`CarrierSerializationContractTest`, transporteur complet seedé) et les coller ici. Toute donnée affichée par le front DOIT apparaître dans ce JSON. **Ne jamais déclarer un champ « embarqué » sans l'avoir vu dans un JSON réel.** > > **Pièges hérités à re-tester sur le M4** : > 1. `prices[].client` / `.supplier` / `.departureSite` doivent sortir en **objet embarqué**, pas en IRI nu → vérifier les read-groups `client:read`/`supplier:read`/`site:read`. > 2. Sérialisation booléen `isArchived` (bug #3 M1) : clé présente dans le JSON réel. > 3. `qualimatCarrier` embarqué (statut + validité) pour RG-4.04. > ✅ **CAPTURÉ (WT3, ERP-155/157)** — JSON réel produit par `CarrierSerializationContractTest` (transporteur complet seedé : lien QUALIMAT, 1 adresse, 1 contact, 2 prix CLIENT + FOURNISSEUR). Les 3 pièges sont vérifiés verts. **Le front peut démarrer sur ce contrat.** > > Contraintes d'architecture validées au passage : > - Relations cross-module des prix (`client`/`supplier`/adresses) câblées **sans import inter-module** (règle n°1) via des contrats `Shared/Domain/Contract/*Interface` + `resolve_target_entities`. L'embed JSON passe par les read-groups des entités concrètes (`client:read`, `client_address:read`, `supplier:read`, `supplier_address:read`, `site:read`). Un groupe `supplier_address:read` a été **ajouté aux champs scalaires de `SupplierAddress`** (M2) pour que `supplierSupplyAddress` s'embarque comme `clientDeliveryAddress` (M1 avait déjà `client_address:read`). > - `QualimatCarrier` = mapping ORM **lecture seule** sur la table référentielle existante (sortie du `schema_filter`, mapping aligné au DDL ERP-39 → `schema:update` no-op). **`GET /api/carriers?search=…` (LISTE)** — enveloppe Hydra AP4 (`member`/`totalItems`/`view` sans préfixe `hydra:`), archivés exclus par défaut (`?includeArchived=true` les réintègre) : ```jsonc { "@context": "/api/contexts/Carrier", "@id": "/api/carriers", "@type": "Collection", "totalItems": 1, "member": [ { "@id": "/api/carriers/12", "@type": "Carrier", "id": 12, "name": "TRANSPORTS GRELILLIER", "qualimatCarrier": { // embarqué (objet), pas IRI — RG-4.04 "@id": "/api/qualimat_carriers/8", "@type": "QualimatCarrier", "id": "8", "siret": "…", "name": "…", "address": "…", "postalCode": "86000", "city": "Poitiers", "status": "Valide", "validityDate": "2027-12-31T00:00:00+01:00" }, "certificationType": "QUALIMAT", "createdAt": "…", "updatedAt": "…", "isChartered": false, // bool présent (getter + SerializedName) "isArchived": false // bool présent (piège #3) } ], "view": { "@id": "/api/carriers?search=…", "@type": "PartialCollectionView" } } ``` **`GET /api/carriers/{id}` (DÉTAIL)** — `qualimatCarrier` + `addresses[]` + `contacts[]` + `prices[]` avec relations cross-module embarquées en objet : ```jsonc { "@id": "/api/carriers/12", "@type": "Carrier", "id": 12, "name": "TRANSPORTS GRELILLIER", "qualimatCarrier": { "@type": "QualimatCarrier", "status": "Valide", "validityDate": "…", "...": "…" }, "certificationType": "QUALIMAT", "addresses": [ { "@type": "CarrierAddress", "id": 4, "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "…", "createdAt": "…", "updatedAt": "…" } ], "contacts": [ { "@type": "CarrierContact", "id": 5, "firstName": "Marie", "lastName": "Martin", "phonePrimary": "0612345678", "email": "…", "createdAt": "…", "updatedAt": "…" } ], "prices": [ { "@type": "CarrierPrice", "id": 7, "direction": "CLIENT", "client": { "@type": "Client", "@id": "/api/clients/4", "id": 4, "companyName": "…", "isArchived": false, "...": "…" }, "clientDeliveryAddress": { "@type": "ClientAddress", "@id": "/api/client_addresses/4", "postalCode": "86000", "city": "Poitiers", "street": "…", "...": "…" }, "departureSite": { "@type": "Site", "@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "...": "…" }, "containerType": "BENNE", "pricingUnit": "TONNE", "price": "42.50", "priceState": "VALIDE", "createdAt": "…", "updatedAt": "…" }, { "@type": "CarrierPrice", "id": 8, "direction": "FOURNISSEUR", "supplier": { "@type": "Supplier", "@id": "/api/suppliers/4", "id": 4, "companyName": "…", "isArchived": false, "...": "…" }, "supplierSupplyAddress": { "@type": "SupplierAddress", "@id": "/api/supplier_addresses/38", "id": 38, "addressType": "DEPART", "country": "France", "postalCode": "17000", "city": "La Rochelle", "street": "…" }, "deliverySite": { "@type": "Site", "@id": "/api/sites/1", "name": "Chatellerault", "...": "…" }, "containerType": "FOND_MOUVANT", "pricingUnit": "FORFAIT", "price": "320.00", "priceState": "EN_COURS", "createdAt": "…", "updatedAt": "…" } ], "createdAt": "…", "updatedAt": "…", "isChartered": false, "isArchived": false } ``` > Note WT3 : opérations exposées = `GetCollection` + `Get` (lecture). `POST`/`PATCH` (+ `CarrierProcessor`, normalisation, RG-4.01→4.14, 409 doublon, gating archive) et les sous-ressources d'écriture (adresses/contacts/prix) arrivent aux worktrees suivants (WT4+). ### 4.1 `GET /api/carriers` — Liste - **Security** : `is_granted('transport.carriers.view')` - **Query params** (alimentent le panneau « Filtrer ») : - `includeArchived=true|false` (default `false`) - `certificationType=` (filtre ; répétable) - `search=` (fuzzy sur `name`) - **Tri par défaut** : `name ASC` - **Pagination** : standard Starseed (règle ABSOLUE n°13) — Hydra, 10/page, `?pagination=false` pour les selects. `CarrierProvider` branché sur `ApiPlatform\Doctrine\Orm\Paginator`. - **Pas de cloisonnement par site** (§ 2.3) : tout user `view` voit tous les transporteurs. - **Codes** : `200` / `401` / `403` ### 4.2 `GET /api/carriers/{id}` — Détail - **Security** : `is_granted('transport.carriers.view')` - **Comportement** : transporteur + `qualimatCarrier` + `addresses` + `contacts` + `prices` (avec `client`/`supplier`/sites embarqués). - **Codes** : `200` / `404` / `401` / `403` ### 4.3 `POST /api/carriers` — Création (formulaire principal) - **Security** : `is_granted('transport.carriers.manage')` - **Body** (groupe `carrier:write:main`) — exemple QUALIMAT : ```json { "name": "TRANSPORTS GRELILLIER", "qualimatCarrier": "/api/qualimat_carriers/142", "certificationType": "QUALIMAT", "isChartered": false } ``` - **Body** — exemple non-QUALIMAT affrété : ```json { "name": "TRANSPORTS PANDELE", "certificationType": "AUTRE", "isChartered": true, "indexationRate": "5.00", "containerType": "BENNE", "volumeM3": "90.00", "dischargeDocument": "/api/uploaded_documents/12" } ``` - **Réponse 201** : le transporteur créé avec son `id`. Le front enchaîne les PATCH / sous-ressources par onglet. - **Codes** : `201` / `400` / `401` / `403` - `409 Conflict` si doublon de nom (`name` — RG-4.12). - `422` : RG-4.02 (AUTRE sans décharge → obligatoire, voir § 7), RG-4.03 (affrété sans indexation/benne/volume), certification invalide, cas LIOT incohérent. ### 4.4 `PATCH /api/carriers/{id}` — Modification - **Security base** : `is_granted('transport.carriers.manage')` - **Security additionnelle** (dans le `CarrierProcessor`) : - payload contenant `isArchived` → exige `transport.carriers.archive` (Admin seul). - **mode strict** (RG-4.14) : payload mélangeant un champ archive sans la permission → 403 sur tout le payload. - **Body** : merge-patch+json, champs modifiés uniquement. - **Codes** : `200` / `400` / `401` / `403` / `404` / `409` / `422` ### 4.5 Sous-ressources **Adresses** : `POST /api/carriers/{id}/addresses`, `PATCH /api/carrier_addresses/{id}`, `DELETE /api/carrier_addresses/{id}`. - **Security** : `is_granted('transport.carriers.manage')` - RG-4.05 (pré-remplissage QUALIMAT), RG-4.06 (autocomplete BAN), RG-4.07 (pas de validation manuelle si QUALIMAT — front). **Contacts** : `POST /api/carriers/{id}/contacts`, `PATCH /api/carrier_contacts/{id}`, `DELETE /api/carrier_contacts/{id}`. - **Security** : `is_granted('transport.carriers.manage')` - RG-4.08 : ≥ 1 champ rempli (CHECK BDD + Processor). Max 2 téléphones. **Prix** : `POST /api/carriers/{id}/prices`, `PATCH /api/carrier_prices/{id}`, `DELETE /api/carrier_prices/{id}`. - **Security** : `is_granted('transport.carriers.manage')` - RG-4.09 → RG-4.11 : cohérence branche CLIENT vs FOURNISSEUR (Processor + CHECK). `client_delivery_address` doit appartenir au `client` choisi ; `supplier_supply_address` au `supplier` choisi → sinon 422. ### 4.6 Export **Répertoire** : `GET /api/carriers/export.xlsx` - **Security** : `is_granted('transport.carriers.view')` - **Comportement** : XLSX des transporteurs **affichés** (mêmes filtres que la liste, non archivés par défaut). - Colonnes : Nom, Certification, Statut QUALIMAT, Date de validité, Affrété, Volume m³, Date de création. **Onglet Prix** : `GET /api/carriers/{id}/prices/export.xlsx` - **Security** : `is_granted('transport.carriers.view')` - **Comportement** : le tableau Prix regroupé par type (Fond Mouvant / Benne) — colonnes du docx p.10 : Transporteurs, Adresse APRO ou Adresse Sites, Adresse livraisons, Forfait €, Tonne €, Indexation, État du prix. - **Implémentation** : controller custom `CarrierExportController` / `CarrierPriceExportController` avec `#[Route(priority: 1)]` (règle ABSOLUE — conflit API Platform `{id}`). Lib : PhpSpreadsheet (déjà présente). - **Réponse 200** : `Content-Disposition: attachment; filename="...-{YYYYMMDD}.xlsx"` ### 4.7 Référentiel QUALIMAT — endpoint de recherche (NOUVEAU, lecture seule) `GET /api/qualimat_carriers?search=` — alimente la **saisie assistée** du nom (RG-4.01). - **Security** : `is_granted('transport.carriers.view')` - **Comportement** : recherche fuzzy sur `name` (+ `siret`), **seulement les lignes actives** (`is_active = true`), triées par `name`. Paginé (règle n°13). - **Mapping ORM** : nouvelle entité `QualimatCarrier` (lecture seule) sur la table existante `qualimat_carrier`. **Aucune** opération `Post`/`Patch`/`Delete` (alimentée par `app:qualimat:sync`). - Réutilisé aussi par le front pour la copie des champs adresse à la sélection (RG-4.01 / RG-4.05). ### 4.8 Référentiels Prix (réutilisés M1/M2) `GET /api/clients`, `/api/suppliers`, leurs adresses (`/api/clients/{id}` embarque les adresses, ou endpoint adresses dédié), `GET /api/sites` (3 sites) : **existent déjà** (M1/M2). **Évolution M4** : élargir leur `security` pour autoriser aussi `transport.carriers.manage` (selects de l'onglet Prix), p.ex. `... or is_granted('transport.carriers.manage')`. Pas d'écriture exposée par le M4. ## 5. Autorisation ### 5.1 Déclaration des permissions Remplir `TransportModule::permissions()` (actuellement `[]`) : ```php ['code' => 'transport.carriers.view', 'label' => 'Voir les transporteurs'], ['code' => 'transport.carriers.manage', 'label' => 'Créer / modifier les transporteurs'], ['code' => 'transport.carriers.archive', 'label' => 'Archiver / restaurer un transporteur'], ``` Synchronisation : `php bin/console app:sync-permissions`. ### 5.2 Mapping rôles MALIO ↔ permissions (docx « Rôles & permissions ») | Permission | Admin | Bureau | Compta | Commerciale | Usine | |---|---|---|---|---|---| | `transport.carriers.view` | ✅ | ✅ | ❌ | ✅ | ❌ | | `transport.carriers.manage` | ✅ | ✅ | ❌ | ❌ | ❌ | | `transport.carriers.archive` | ✅ | ❌ | ❌ | ❌ | ❌ | - **Admin** : tout (view + manage + archive). - **Bureau** : view + manage (pas d'archive). - **Commerciale** : view seul (consultation « Tout », pas de création/modification). - **Compta / Usine** : aucun accès au module (ni view ni manage). ### 5.3 Synchronisation RBAC (3 sources OBLIGATOIRES — règle ABSOLUE Starseed n°8) 1. **`config/sidebar.php`** — **nouvelle section « Transport »** (ou rattachement à une section « Logistique » existante — *à confirmer*) + item : ```php [ 'key' => 'transport', 'label' => 'sidebar.transport.section', 'items' => [ [ 'label' => 'sidebar.transport.carriers', 'to' => '/carriers', 'icon' => 'mdi:truck-outline', 'module' => 'transport', 'permission' => 'transport.carriers.view', ], ], ], ``` 2. **`frontend/tests/e2e/_fixtures/personas.ts`** — étendre les personas existants : - Admin : `view` + `manage` + `archive` - Bureau : `view` + `manage` - Commerciale : `view` - Compta / Usine : **aucune** permission `transport.carriers.*` (vérifier 403) 3. **`src/Module/Core/Infrastructure/Console/SeedE2ECommand.php`** — miroir back des mêmes personas. > ⚠ Les 3 sources doivent être touchées dans le **même commit** (sinon drift / test cassé). ### 5.4 Vérification front - `usePermissions()` filtre l'item sidebar (`transport.carriers.view`). - Bouton « + Ajouter » / « Modifier » visibles si `transport.carriers.manage`. - Bouton « Archiver » visible si `transport.carriers.archive` (Admin seul). ## 6. Audit & dates - `Carrier`, `CarrierAddress`, `CarrierContact`, `CarrierPrice` : `#[Auditable]`, tous champs audités. - Timestampable + Blamable : pattern Shared standard. - `QualimatCarrier` / `UploadedDocument` : voir leur propre cycle (référentiel synchro / upload). - Libellés i18n `audit.entity.transport_*` (§ 2.8). ## 7. Règles de gestion (RG) > RG-4.01 → RG-4.11 reprennent le docx source. RG-4.12 → RG-4.14 sont des **précisions back** explicitement marquées. ### Formulaire principal - **RG-4.01** _(saisie assistée QUALIMAT + cas LIOT)_ : le nom est saisi par l'utilisateur, ce qui déclenche une recherche dans le référentiel QUALIMAT (`GET /api/qualimat_carriers?search=`). Sélection d'un transporteur → modal de confirmation (front) → copie de `name` + `certificationType = QUALIMAT` + adresse (§ 2.5). **FK** `qualimatCarrier` conservée. **Cas non trouvé** : pas QUALIMAT → l'utilisateur choisit une autre certification (RG-4.02). **Cas LIOT (décision Matthieu, 15/06)** : si le nom saisi est exactement `LIOT`, le champ `liotPlates` apparaît (immatriculations séparées par `;`) et **les autres champs sont masqués** (certification, affrètement, décharge…). Conséquences back : (a) `certificationType` n'est **pas requis** en cas LIOT (nullable — le select est masqué) et **reste obligatoire** pour tous les autres cas (contrôle conditionnel `#[Assert\Callback]`) ; (b) `isChartered`/`indexationRate`/`containerType`/`volumeM3`/`dischargeDocument` ignorés/laissés nuls ; (c) le back stocke ce qu'il reçoit, pas de 422 sur la présence résiduelle d'un autre champ (cohérence d'affichage portée par le front). - **RG-4.02** _(certification AUTRE → Décharge **obligatoire**)_ : si `certificationType = 'AUTRE'`, le champ Décharge (`dischargeDocument`) **apparaît et est obligatoire**. Validation server-side (`#[Assert\Callback]` dans le `CarrierProcessor`) : `certificationType = 'AUTRE'` et `dischargeDocument IS NULL` → **422** sur `dischargeDocument`. En base, `discharge_document_id` reste **nullable** (null pour les autres certifications) ; c'est la contrainte conditionnelle qui impose le fichier quand AUTRE. - **RG-4.03** _(Affréter)_ : si `isChartered = true`, les champs `indexationRate`, `containerType` (Benne/Fond mouvant) et `volumeM3` deviennent **visibles et obligatoires**. Validation server-side (`#[Assert\Callback]`) : `isChartered = true` et l'un des trois `NULL` → **422** sur le champ concerné. ### Onglet Adresse - **RG-4.04** _(date de validité QUALIMAT)_ : la `validityDate` du `qualimatCarrier` lié, si **antérieure à aujourd'hui**, est affichée **sur fond rouge** (front). Donnée exposée via `qualimatCarrier.validityDate` (§ 4.0). - **RG-4.05** _(pré-remplissage QUALIMAT)_ : les champs adresse sont déjà remplis si le transporteur est QUALIMAT (copie § 2.5). Si « Affréter » est coché, l'adresse devient obligatoire (Pays, Code postal, Ville, Adresse). Validation : `Assert\Callback` conditionnelle. - **RG-4.06** _(autocomplete BAN)_ : `city` préremplie depuis `postalCode` via l'API **BAN** (api-adresse.data.gouv.fr), appel **direct front** via `useAddressAutocomplete()` (réutilisé M1/M2/M3). Validation serveur : `postalCode` matche `^[0-9]{4,5}$` ; pas de contrôle strict CP/Ville. - **RG-4.07** _(pas de validation manuelle si QUALIMAT)_ : le bouton « Valider » de l'onglet Adresse n'apparaît pas pour un transporteur QUALIMAT (adresse remplie automatiquement). Règle **front** ; back accepte le PATCH adresse normalement. ### Onglet Contact - **RG-4.08** _(bloc Contact valide)_ : un bloc Contact est valide dès qu'**au moins 1 champ** est rempli. CHECK BDD `chk_carrier_contact_filled` (garde-fou). UI : « + Nouveau contact » bloqué tant que le bloc en cours n'a aucun champ rempli. **Max 2 téléphones** par contact. ### Onglet Prix - **RG-4.09** _(affichage conditionnel)_ : tous les champs masqués par défaut sauf le radio `direction` (Client / Fournisseur), qui déclenche l'affichage des bons champs. - **RG-4.10** _(branche CLIENT)_ : si `direction = CLIENT`, les champs `client`, `clientDeliveryAddress` (liste des adresses du client sélectionné), `departureSite` (86/17/82) sont **affichés et obligatoires** ; les champs fournisseur sont masqués/nuls. CHECK `chk_carrier_price_client_branch` + validation Processor (`clientDeliveryAddress` appartient à `client` → sinon 422). - **RG-4.11** _(branche FOURNISSEUR)_ : si `direction = FOURNISSEUR`, les champs `supplier`, `supplierSupplyAddress` (adresses du fournisseur), `deliverySite` (86/17/82) sont **affichés et obligatoires** ; les champs client masqués/nuls. CHECK `chk_carrier_price_supplier_branch` + validation Processor (`supplierSupplyAddress` appartient à `supplier`). - Champs communs **toujours obligatoires** : `containerType` (Benne/Fond mouvant), `pricingUnit` (Forfait/Tonne), `price` (monnaie), `priceState` (En cours / Validé / Non validé). ### Précisions back - **RG-4.12** _(unicité nom)_ : `name` unique (case-insensitive) parmi les transporteurs non archivés ET non soft-deletés (index partiel `uq_carrier_name_active`). Doublon → 409 « Un transporteur nommé "{name}" existe déjà. » - **RG-4.13** _(normalisation serveur)_ : `name` **UPPERCASE** ; `firstName`/`lastName` (sur `CarrierContact`) **Capitalize** ; téléphones **chiffres uniquement** ; `email` **lowercase** ; `liotPlates` normalisé (`;`-split, trim, UPPER). Formatage à l'affichage front. - **RG-4.14** _(archivage + mode strict)_ : PATCH `{ "isArchived": true }` exige `transport.carriers.archive` (**Admin seul**) → `isArchived = true` + `archivedAt = now()`. PATCH `{ "isArchived": false }` restaure (conflit d'unicité de nom → 409). Un PATCH mêlant archive sans permission → 403 sur tout le payload. ## 8. Tests à automatiser ### 8.1 Cas à couvrir (back — PHPUnit) - [ ] **RG-4.01** : POST avec `qualimatCarrier` → `certificationType=QUALIMAT` accepté + FK persistée ; `GET /api/qualimat_carriers?search=` ne renvoie que les lignes actives - [ ] **RG-4.02** : POST `certificationType=AUTRE` sans `dischargeDocument` → 422 ; avec décharge → 201 ; certification ≠ AUTRE sans décharge → 201 - [ ] **RG-4.03** : POST `isChartered=true` sans `indexationRate`/`containerType`/`volumeM3` → 422 ; complet → 201 - [ ] **RG-4.05** : POST adresse pour transporteur affrété sans Pays/CP/Ville/Adresse → 422 - [ ] **RG-4.06** : POST adresse `postalCode` invalide (3 chiffres) → 422 ; CP/ville incohérents → 200 - [ ] **RG-4.08** : POST contact totalement vide → 422 (CHECK) ; 1 champ rempli → 200 ; 3e téléphone → 422 - [ ] **RG-4.09/4.10** : POST prix `direction=CLIENT` sans `client`/`clientDeliveryAddress`/`departureSite` → 422 ; `clientDeliveryAddress` n'appartenant pas au `client` → 422 ; complet → 201 - [ ] **RG-4.11** : POST prix `direction=FOURNISSEUR` symétrique ; `supplierSupplyAddress` étrangère au `supplier` → 422 - [ ] **RG-4.12** : POST `name` déjà pris → 409 ; même nom après archivage de l'ancien → 201 - [ ] **RG-4.13** : POST `name="transports x"` → persiste `"TRANSPORTS X"` ; normalisation contact/phone/email ; `liotPlates="ab-123-cd ; ef-456-gh"` normalisé - [ ] **RG-4.14** : PATCH isArchived=true par Bureau (sans `archive`) → 403 ; par Admin → 200 + `archivedAt` rempli ; restauration en conflit de nom → 409 - [ ] **RBAC** : Admin/Bureau/Commerciale/Compta/Usine sur chaque permission (matrice § 5.2) — 200/403 selon le verbe (Compta + Usine : 403 sur view ET manage) - [ ] **🔴 Embed relations** : GET détail → `prices[].client`/`.supplier`/`.departureSite`/`.deliverySite` **objets embarqués** (pas IRI nu) ; `qualimatCarrier` embarqué (statut + validité) - [ ] **🔴 Sérialisation booléen (bug #3 M1)** : GET détail expose la clé `isArchived` - [ ] **Liste / tri** : `GET /api/carriers` exclut archivés par défaut ; `?includeArchived=true` inclut ; tri `name ASC` - [ ] **Anti N+1 liste (§ 2.11)** : nombre de requêtes SQL constant - [ ] **Export** : XLSX répertoire + XLSX onglet Prix (regroupé Benne/FM) — `Content-Disposition` - [ ] **Upload** (§ 2.7) : POST `/api/uploaded_documents` MIME hors whitelist → 422 ; MIME valide → IRI ; validation via `$file->getMimeType()` (pas `getClientMimeType()`) - [ ] **Audit** : POST + PATCH + archive → `audit_log` `entity_type='Carrier'`, `changes` correct - [ ] **Pagination** (règle n°13) : enveloppe Hydra (`totalItems`/`view`) ; `?pagination=false` renvoie tout (selects) - [ ] **Migration** : `make db-reset` → schéma OK ; namespace racine ; index partiel `uq_carrier_name_active` ; **toutes les colonnes ont un `COMMENT ON COLUMN`** (`ColumnsHaveSqlCommentTest` vert) - [ ] **i18n audit** : `audit.entity.transport_carrier`… présents (`AuditableEntitiesHaveI18nLabelTest` vert) ### 8.2 Cas à couvrir (front — Vitest) - [ ] `usePaginatedList({url:'/carriers'})` : exclusion archivés par défaut, envelope Hydra - [ ] `useCarrierForm()` : workflow par onglet (validation incrémentale, PATCH partiel) ; champs conditionnels (Affréter, AUTRE→Décharge, LIOT) - [ ] Saisie assistée QUALIMAT : recherche → modal → copie nom/certif/adresse + FK - [ ] `useAddressAutocomplete()` : réutilisation M1/M2/M3 (nominal + dégradé) - [ ] Onglet Prix : bascule Client/Fournisseur (RG-4.09→4.11) ; date de validité fond rouge (RG-4.04) - [ ] `useFormErrors` : mapping 422 inline par champ (formulaire principal + blocs) - [ ] Permissions : Commerciale en lecture seule (pas de « + Ajouter »/« Modifier ») ; bouton Archiver visible Admin seul ### 8.3 Tests E2E **Non prévus au M4** (règle ABSOLUE n°7). Extension des personas existants pour les permissions `transport.carriers.*` — cf. § 5.3. ### 8.4 Seed & fixtures démo (RETEX M1 §7 — prévu dès la spec) `CarrierFixtures` idempotent couvrant les RG : - ≥ 1 transporteur **QUALIMAT** (lié à une ligne `qualimat_carrier` seedée, adresse copiée, `validityDate` passée pour tester RG-4.04) ; - 1 transporteur **AUTRE + Décharge** (RG-4.02) ; 1 **affrété** (indexation/benne/volume — RG-4.03) ; 1 **LIOT** (immatriculations) ; - ≥ 1 transporteur avec **contacts**, **adresses**, et **prix** des deux branches (CLIENT + FOURNISSEUR) ; - 1 transporteur **archivé** (exclusion liste + restauration). - Réutiliser les comptes de rôles démo (`admin`, `bureau`, `commerciale`, `compta`, `usine`). - Le seed QUALIMAT s'appuie sur la commande `app:qualimat:sync` (ou un mini-seed de `qualimat_carrier` en fixture de test, idempotent). ### 8.5 Checklist RETEX (à cocher avant « spec prête ») - [x] 3 maillons de sérialisation documentés pour chaque champ liste + détail (§ 4.0) - [x] Décision embed vs GetCollection explicite (embed détail + sous-ressources write — § 3.3 / § 3.4 / § 4.5) - [ ] **Réponses JSON RÉELLES** capturées (§ 4.0.bis) — à produire au ticket tests (`CarrierSerializationContractTest`) - [x] Matrice RBAC rôle × permission + mode strict archive (§ 5.2 / RG-4.14) - [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit + i18n, routes à plat : rappelés - [x] Réutilisations identifiées (référentiel QUALIMAT, Client/Supplier/Site partagés, `usePaginatedList`, blocs, archive, normalisation, `useAddressAutocomplete`) - [x] Seed/fixtures démo planifiés (§ 8.4) - [x] **Décisions tranchées (Matthieu, 15/06)** : lien QUALIMAT FK+copie (§ 2.5) ✅ ; pas de cloisonnement site (§ 2.3) ✅ ; infra upload Shared réutilisable (§ 2.7) ✅ ; unicité nom seul (§ 2.6) ✅ ## 9. Hors-périmètre (HP) - **HP-M4-A** : **Exploitation du référentiel IDTF** (`idtf_product`, ERP-149) dans les écrans transporteurs (régimes de nettoyage par marchandise). Synchronisé mais non consommé par le M4. - **HP-M4-B** : **Infra upload avancée** — antivirus, stockage objet (S3/MinIO), purge/rétention, prévisualisation. Le M4 livre l'infra **minimale** (§ 2.7). - **HP-M4-C** : **DELETE / soft delete d'un transporteur** (colonne `deleted_at` préparée, non exposée). - **HP-M4-D** : **Liaison transporteur ↔ tournées / expéditions** (modules logistiques futurs consommant `carrier_id`). - **HP-M4-E** : **Historisation des prix** (versionnage des `carrier_price`) — au M4, état simple (En cours/Validé/Non validé). - **HP-M4-F** : **Validation stricte SIRET / IBAN** (non applicable ici : pas de comptabilité au M4). - **HP-M4-G** : **Export CSV** (XLSX uniquement au M4). - **HP-M4-H** : **Onglets « À venir »** non détaillés par le docx → placeholders si présents en maquette. ## 10. Liens & dépendances ### Liens - Spec front : [`./spec-front.md`](./spec-front.md) - Spec M2 fournisseurs (pattern de référence) : [`../M2-suppliers/spec-back.md`](../M2-suppliers/spec-back.md) - Spec M3 prestataires (pattern le plus proche) : [`../M3-prestataires/spec-back.md`](../M3-prestataires/spec-back.md) - Branches existantes : `feat/erp-150-module-transport` (module) · `feat/erp-39-qualimat-sync` (réf. QUALIMAT) · `feat/erp-149-idtf-sync` (réf. IDTF) - BAN api : `https://adresse.data.gouv.fr/api-doc/adresse` - Trace fonctionnelle : `M4-repertoire-transporteurs-V0.docx` / `.pdf` (V0, validé 27/05/2026) ### Dépendances amont (déjà en place dans Starseed) - Module `Transport` : `qualimat_carrier` (réf. QUALIMAT, ERP-39) + `idtf_product` (réf. IDTF, ERP-149) + `TransportModule` - Module `Commercial` : `Client` (M1) + `Supplier` (M2) + leurs adresses (onglet Prix, relation ORM partagée) - Module `Sites` : `Site` (3 sites 86/17/82) — adresses départ/livraison du Prix - Module `Core` : `User`, `Role`, `Permission`, `Audit`, JWT - `Shared` : `TimestampableBlamableTrait` + `Subscriber` (+ NOUVELLE infra upload — § 2.7) - API Platform 4 + Doctrine ORM + PostgreSQL 16 + PhpSpreadsheet (export) --- ## 📦 Tickets Lesstime (à découper) **TaskGroup Lesstime** : à créer — `M4 — Répertoire transporteurs` (projet `ERP / Starseed`, projectId=6). Ordre indicatif (back avant front, migration en tête) : 0. **Permissions Transport + sidebar** — remplir `TransportModule::permissions()` (3 permissions) + section sidebar « Transport »/« Logistique » + sync 3 sources RBAC. 1. **Infra upload générique `Shared`** (§ 2.7) — table `uploaded_document` + `FileUploader` (MIME serveur) + endpoint `POST /api/uploaded_documents`. 2. **Migration BDD M4** (tables `carrier` + sous-collections + index partiel + CHECK + COMMENT ON COLUMN). 3. **Entité `QualimatCarrier` (lecture seule)** + endpoint `GET /api/qualimat_carriers?search=` (RG-4.01). 4. **Entités + Repositories** (`Carrier`, `CarrierAddress`, `CarrierContact`, `CarrierPrice`) + hydratation liste (§ 2.11). 5. **CarrierProvider + CarrierProcessor** (normalisation, archivage, champs conditionnels RG-4.02/4.03, cas LIOT, mode strict). 6. **Sous-ressources** (Addresses / Contacts / Prices Processors) + validations branches Prix (RG-4.10/4.11). 7. **Export XLSX** (répertoire + onglet Prix regroupé Benne/FM) — controllers `priority:1`. 8. **RBAC** : sync 3 sources + tests personas. 9. **Tests PHPUnit** : matrice RG-4.01 → RG-4.14 (§ 8.1) + capture JSON réel (§ 4.0.bis). 10. **Front** : page Répertoire (`/carriers`) + `usePaginatedList`. 11. **Front** : page Ajouter (`/carriers/new`) + formulaire principal + saisie assistée QUALIMAT + champs conditionnels. 12. **Front** : onglets Adresse (BAN) / Contact / Prix. 13. **Front** : pages Consultation + Modification. 14. **i18n + libellés audit** (`audit.entity.transport_*`). ### Actions manuelles dans Lesstime (Matthieu) 1. Créer le TaskGroup `M4 — Répertoire transporteurs` (projet ERP / Starseed, projectId=6). 2. Créer les tickets ci-dessus avec dépendances séquentielles. 3. Mettre à jour le frontmatter (`lesstime_taskgroup_id`) avec l'id réel. ### ✅ Décisions tranchées (Matthieu, 15/06/2026) 1. **Modèle Prix** (RG-4.10/4.11, § 3.2) — « Adresse de départ » / « Adresse de livraison » 86/17/82 = les 3 `Site` (FK `site`) ; « Adresse de livraison du client » = `ClientAddress` (M1) ; « Adresse d'approvisionnement » = `SupplierAddress` (M2). ✅ 2. **Lien QUALIMAT** = FK + copie éditable (§ 2.5). ✅ 3. **Pas de cloisonnement par site** (§ 2.3). ✅ 4. **Infra upload réutilisable `Shared`** (§ 2.7). ✅ 5. **Décharge obligatoire côté serveur** (RG-4.02) — si `certificationType=AUTRE` ⇒ `dischargeDocument` requis (422 sinon). ✅ 6. **Certification QUALIMAT** = 5e valeur de l'enum `certification_type`, en **lecture seule** (vient du référentiel), libellé affiché « QUALIMAT ». ✅ 7. **Affrètement** (RG-4.03) — indexation + benne/fond mouvant + volume **obligatoires server-side** si « Affréter » coché (fidèle au docx). ✅ 8. **Cas LIOT** (RG-4.01) — nom = `LIOT` ⇒ champ `liotPlates` seul affiché, autres champs masqués ; `certificationType` **non requis** en cas LIOT (nullable), obligatoire sinon. ✅ 9. **Unicité = nom seul** (§ 2.6). ✅ ### ⚠️ Points purement techniques (pas de décision métier — défaut posé) 1. **Type de PK** : `BIGINT` (cohérence module Transport) — modifiable en `INT` si homogénéité globale souhaitée (§ 2.2). 2. **Section sidebar** : « Transport » dédiée vs « Logistique » (route `/carriers` retenue). Cosmétique.