Files
Starseed/docs/specs/M4-transporteurs/spec-back.md
T
Matthieu 18c88156e5 test(transport) : couverture RG-4.01→4.14 + contrat + fixtures (ERP-163)
- CarrierListTest : anti-N+1 liste (fetch-join qualimat), tri name ASC,
  echappatoire ?pagination=false (regle n°13)
- CarrierAuditTest : POST/PATCH/archive -> audit_log entity_type='transport.Carrier'
- CarrierAddressApiTest : CP/ville incoherents acceptes (RG-4.06, pas de
  controle de coherence serveur)
- CarrierFixtures : fixtures dev completes et idempotentes (QUALIMAT validite
  passee, AUTRE+decharge, affrete, LIOT, complet prix CLIENT+FOURNISSEUR,
  archive) ; env-gated dev uniquement
- spec-back § 4.0.bis : JSON reel capture (liste + detail) via CarrierSerializationContractTest
2026-06-16 15:13:11 +02:00

74 KiB
Raw Blame History

module, nom, ecran, owner_spec, backup_spec, version, date_redaction, spec_front, maquette_figma, trace_fonctionnelle, lesstime_project_id, lesstime_taskgroup_id, statut_global, depend_de
module nom ecran owner_spec backup_spec version date_redaction spec_front maquette_figma trace_fonctionnelle lesstime_project_id lesstime_taskgroup_id statut_global depend_de
M4 Répertoire transporteurs repertoire-transporteurs Matthieu Tristan V0.1 2026-06-15 ./spec-front.md https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-45376&p=f&m=dev uploads/M4-repertoire-transporteurs-V0.pdf / .docx (V0, validé 27/05/2026) 6 31 pret_a_dev
Transport
Commercial
Sites
Core
Shared

Spec back — Module 4 : Répertoire transporteurs

1. Contexte

Cette spec complète et précise la spec front V0.1 (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).
  • CommercialClient (M1) + Supplier (M2) + leurs adresses (onglet Prix).
  • Sites → 3 sites Châtellerault (86) / Saint-Jean (17) / Pommevic (82).
  • SharedTimestampableBlamableTrait + 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/.

Carrierqualimat_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 globalaucun 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 :

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)).

-- =====================================================================
-- 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)

$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

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 namecarrier:read
Certification certificationTypecarrier:read
Date de validité (QUALIMAT) qualimatCarrier.validityDatecarrier:read (embed) qualimat:read (RG-4.04)
Dernière activité updatedAtcarrier:read

DÉTAIL — bloc → maillons :

Bloc / champ Propriété (a) Dans contexte détail (b) Imbriqué (c)
Scalaires principaux carrier:read
qualimatCarrier (statut/validité) qualimatCarriercarrier:read qualimat:read
addresses[] addressescarrier:item:read propriétés CarrierAddresscarrier:item:read
contacts[] contactscarrier:item:read propriétés CarrierContactcarrier:item:read
prices[] (scalaires) pricescarrier:item:read propriétés CarrierPricecarrier:item:read
prices[].client clientcarrier:item:read client:read
prices[].clientDeliveryAddress carrier:item:read client_address:read (entité ClientAddress)
prices[].supplier suppliercarrier: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) :

{
  "@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) :

{
  "@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 :
{
  "name": "TRANSPORTS GRELILLIER",
  "qualimatCarrier": "/api/qualimat_carriers/142",
  "certificationType": "QUALIMAT",
  "isChartered": false
}
  • Body — exemple non-QUALIMAT affrété :
{
  "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 []) :

['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.phpnouvelle section « Transport » (ou rattachement à une section « Logistique » existante — à confirmer) + item :
[
    'key'   => 'transport',
    'label' => 'sidebar.transport.section',
    'items' => [
        [
            'label'      => 'sidebar.transport.carriers',
            'to'         => '/carriers',
            'icon'       => 'mdi:truck-outline',
            'module'     => 'transport',
            'permission' => 'transport.carriers.view',
        ],
    ],
],
  1. 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)
  2. 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 NULL422 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 NULL422 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 qualimatCarriercertificationType=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 »)

  • 3 maillons de sérialisation documentés pour chaque champ liste + détail (§ 4.0)
  • 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) — produites par CarrierSerializationContractTest (ERP-163, dump CARRIER_DOD_DUMP=1)
  • Matrice RBAC rôle × permission + mode strict archive (§ 5.2 / RG-4.14)
  • Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit + i18n, routes à plat : rappelés
  • Réutilisations identifiées (référentiel QUALIMAT, Client/Supplier/Site partagés, usePaginatedList, blocs, archive, normalisation, useAddressAutocomplete)
  • Seed/fixtures démo planifiés (§ 8.4)
  • 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 M2 fournisseurs (pattern de référence) : ../M2-suppliers/spec-back.md
  • Spec M3 prestataires (pattern le plus proche) : ../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=AUTREdischargeDocument 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.