---
# === 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
<?php

declare(strict_types=1);

namespace App\Module\Transport\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\Sites\Domain\Entity\Site;             // relation ORM partagée (§ 2.1) — via carrier_price
use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierProcessor;
use App\Module\Transport\Infrastructure\ApiPlatform\State\Provider\CarrierProvider;
use App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository;
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\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
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('transport.carriers.view')",
            normalizationContext: ['groups' => ['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<int, CarrierAddress> */
    #[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
    #[Groups(['carrier:item:read'])]
    private Collection $addresses;

    /** @var Collection<int, CarrierContact> */
    #[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
    #[Groups(['carrier:item:read'])]
    private Collection $contacts;

    /** @var Collection<int, CarrierPrice> */
    #[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É (ERP-163)** — JSON **RÉEL** produit par `CarrierSerializationContractTest::testDodReferenceJsonShape` (transporteur complet seedé : lien QUALIMAT, 1 adresse, 1 contact, 2 prix CLIENT + FOURNISSEUR), dumpé via la variable d'env `CARRIER_DOD_DUMP=1`. Les 3 pièges sont vérifiés verts. **Le front peut démarrer sur ce contrat.** Les valeurs cosmétiques (noms, SIRET) sont nettoyées du bruit de seed ; **toutes les clés ci-dessous sont présentes telles quelles dans la réponse réelle**.
>
> 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/26",
      "@type": "Carrier",
      "id": 26,
      "name": "TRANSPORTS GRELILLIER",
      "qualimatCarrier": {                       // embarqué (objet), pas IRI — RG-4.04
        "@id": "/api/qualimat_carriers/22",
        "@type": "QualimatCarrier",
        "id": "22",
        "siret": "80012345600017",
        "name": "TRANSPORTS GRELILLIER",
        "address": "12 rue des Acacias",
        "postalCode": "86000",
        "city": "Poitiers",
        "status": "Valide",
        "validityDate": "2027-12-31T00:00:00+01:00"
      },
      "certificationType": "QUALIMAT",
      "createdAt": "2026-06-15T19:12:39+02:00",
      "updatedAt": "2026-06-15T19:12:39+02:00",
      "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 (les `@id` des sous-collections sortent en `/.well-known/genid/…` : ce sont des IRI anonymes API Platform, normal pour des entités non exposées en ressource racine) :

```jsonc
{
  "@context": "/api/contexts/Carrier",
  "@id": "/api/carriers/26",
  "@type": "Carrier",
  "id": 26,
  "name": "TRANSPORTS GRELILLIER",
  "qualimatCarrier": {                           // embarqué (statut + validité) — RG-4.04
    "@id": "/api/qualimat_carriers/22",
    "@type": "QualimatCarrier",
    "id": "22",
    "siret": "80012345600017",
    "name": "TRANSPORTS GRELILLIER",
    "address": "12 rue des Acacias",
    "postalCode": "86000",
    "city": "Poitiers",
    "status": "Valide",
    "validityDate": "2027-12-31T00:00:00+01:00"
  },
  "certificationType": "QUALIMAT",
  "addresses": [
    {
      "@type": "CarrierAddress",
      "@id": "/api/.well-known/genid/9f597da33f73776f1c25",
      "id": 12,
      "country": "France",
      "postalCode": "86000",
      "city": "Poitiers",
      "street": "12 rue des Acacias",
      "createdAt": "2026-06-15T19:12:39+02:00",
      "updatedAt": "2026-06-15T19:12:39+02:00"
    }
  ],
  "contacts": [
    {
      "@type": "CarrierContact",
      "@id": "/api/.well-known/genid/6c6335ead4557062774f",
      "id": 13,
      "firstName": "Marie",
      "lastName": "Martin",
      "phonePrimary": "0612345678",
      "email": "marie.martin@grelillier.fr",
      "createdAt": "2026-06-15T19:12:39+02:00",
      "updatedAt": "2026-06-15T19:12:39+02:00"
    }
  ],
  "prices": [
    {
      "@type": "CarrierPrice",
      "@id": "/api/.well-known/genid/ac0305352bb3751a5b76",
      "id": 23,
      "direction": "CLIENT",
      "client": {                                // OBJET embarqué (client:read), pas IRI nu — piège #1
        "@type": "Client",
        "@id": "/api/clients/117",
        "id": 117,
        "companyName": "NÉGOCE MÉTAUX ATLANTIQUE",
        "triageService": false,
        "categories": [],
        "createdAt": "2026-06-15T19:12:39+02:00",
        "updatedAt": "2026-06-15T19:12:39+02:00",
        "sites": [],
        "isArchived": false
      },
      "clientDeliveryAddress": {                 // OBJET embarqué (client_address:read)
        "@type": "ClientAddress",
        "@id": "/api/client_addresses/32",
        "id": 32,
        "country": "France",
        "postalCode": "86000",
        "city": "Poitiers",
        "street": "1 rue de la Livraison",
        "position": 0,
        "sites": [],
        "contacts": [],
        "categories": [],
        "createdAt": "2026-06-15T19:12:39+02:00",
        "updatedAt": "2026-06-15T19:12:39+02:00",
        "isProspect": false,
        "isDelivery": true,
        "isBilling": false,
        "isBroker": false,
        "isDistributor": false
      },
      "departureSite": {                         // OBJET embarqué (site:read)
        "@type": "Site",
        "@id": "/api/sites/1",
        "id": 1,
        "name": "Chatellerault",
        "street": "14 All. d'Argenson",
        "postalCode": "86100",
        "city": "Châtellerault",
        "color": "#056CF2",
        "createdAt": "2026-06-15T18:57:56+02:00",
        "updatedAt": "2026-06-15T18:57:56+02:00",
        "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
      },
      "containerType": "BENNE",
      "pricingUnit": "TONNE",
      "price": "42.50",
      "priceState": "VALIDE",
      "createdAt": "2026-06-15T19:12:39+02:00",
      "updatedAt": "2026-06-15T19:12:39+02:00"
    },
    {
      "@type": "CarrierPrice",
      "@id": "/api/.well-known/genid/cfee3c4dda8fb899ff3e",
      "id": 24,
      "direction": "FOURNISSEUR",
      "supplier": {                              // OBJET embarqué (supplier:read), pas IRI nu — piège #1
        "@type": "Supplier",
        "@id": "/api/suppliers/102",
        "id": 102,
        "companyName": "FERRAILLEUR GRAND OUEST",
        "categories": [],
        "createdAt": "2026-06-15T19:12:39+02:00",
        "updatedAt": "2026-06-15T19:12:39+02:00",
        "sites": [],
        "isArchived": false
      },
      "supplierSupplyAddress": {                 // OBJET embarqué (supplier_address:read)
        "@type": "SupplierAddress",
        "@id": "/api/supplier_addresses/38",
        "id": 38,
        "addressType": "DEPART",
        "country": "France",
        "postalCode": "17000",
        "city": "La Rochelle",
        "street": "2 quai de l Appro",
        "createdAt": "2026-06-15T19:12:39+02:00",
        "updatedAt": "2026-06-15T19:12:39+02:00"
      },
      "deliverySite": {                          // OBJET embarqué (site:read)
        "@type": "Site",
        "@id": "/api/sites/1",
        "id": 1,
        "name": "Chatellerault",
        "street": "14 All. d'Argenson",
        "postalCode": "86100",
        "city": "Châtellerault",
        "color": "#056CF2",
        "createdAt": "2026-06-15T18:57:56+02:00",
        "updatedAt": "2026-06-15T18:57:56+02:00",
        "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
      },
      "containerType": "FOND_MOUVANT",
      "pricingUnit": "FORFAIT",
      "price": "320.00",
      "priceState": "EN_COURS",
      "createdAt": "2026-06-15T19:12:39+02:00",
      "updatedAt": "2026-06-15T19:12:39+02:00"
    }
  ],
  "createdAt": "2026-06-15T19:12:39+02:00",
  "updatedAt": "2026-06-15T19:12:39+02:00",
  "isChartered": false,                          // bool présent (getter + SerializedName)
  "isArchived": false                            // bool présent (piège #3)
}
```

> Note (ERP-163) : opérations exposées = `GetCollection` + `Get` (lecture) **et** `POST`/`PATCH` (`CarrierProcessor` : normalisation RG-4.13, RG-4.01→4.14, 409 doublon, gating archive mode strict) **et** les sous-ressources d'écriture adresses/contacts/prix (`Carrier*Processor`). La couverture RG-4.01→4.14 + RBAC + audit + anti-N+1 est portée par la matrice de tests `tests/Module/Transport/Api/` (ERP-163).

### 4.1 `GET /api/carriers` — Liste

- **Security** : `is_granted('transport.carriers.view')`
- **Query params** (alimentent le panneau « Filtrer ») :
  - `includeArchived=true|false` (default `false`)
  - `certificationType=<code>` (filtre ; répétable)
  - `search=<text>` (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=<texte>` — 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)
- [x] **Réponses JSON RÉELLES** capturées (§ 4.0.bis) — produites par `CarrierSerializationContractTest` (ERP-163, dump `CARRIER_DOD_DUMP=1`)
- [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.
