- 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
74 KiB
module, nom, ecran, owner_spec, backup_spec, version, date_redaction, spec_front, maquette_figma, trace_fonctionnelle, lesstime_project_id, lesstime_taskgroup_id, statut_global, depend_de
| module | nom | ecran | owner_spec | backup_spec | version | date_redaction | spec_front | maquette_figma | trace_fonctionnelle | lesstime_project_id | lesstime_taskgroup_id | statut_global | depend_de | |||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 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 |
|
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. branchefeat/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.mddes 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→ tablesqualimat_carrier/qualimat_sync_log/idtf_product/idtf_sync_log(migrationsVersion20260612150000/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_carrierest un référentiel en lecture seule alimenté par la synchro console (jamais édité par l'utilisateur).Carrierest l'entité métier éditable du répertoire. Elle peut référencer une lignequalimat_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 enINT. 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=truepermet de voir les archivés (permissiontransport.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_idET on copie au moment de la sélection :name, la certification (certification_type = QUALIMAT) et les champs adresse (pays / code postal / ville / voie) dans uneCarrierAddress. Les champs copiés restent éditables et survivent à une désync QUALIMAT (FKON DELETE SET NULL).
qualimat_carrier_id: FK nullable versqualimat_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 quequalimat_carrier_idest non nul. Les transporteurs non-QUALIMAT prennent une valeur de la listeGMP_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
LIOTreste soumis à l'unicité comme les autres (un seulCarriernommé 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(moduleShared/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()(jamaisgetClientMimeType()— 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 versuploaded_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]surCarrier,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 dansfrontend/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) :
Carriercréé en BDD dès validation du formulaire principal viaPOST /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-ressourcecarrier_address)carrier:write:contacts— onglet Contact (sous-ressourcecarrier_contact)carrier:write:prices— onglet Prix (sous-ressourcecarrier_price)carrier:write:archive— toggle archive (securitytransport.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 helperaddStandardTimestampableBlamableComments. 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/Siteappartiennent à d'autres modules — on consomme leurs read-groups (client:read,supplier:read,site:read), pas de logique inter-module (§ 2.1).
4. API REST (API Platform)
4.0 Contrat de sérialisation (RETEX M1 — section critique)
Leçon M1/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
normalizationContextde l'opération, (c) read-group de l'entité imbriquée présent dans le contexte parent.
Contexte par opération :
| Opération | normalizationContext (groupes) |
|---|---|
GetCollection (liste) |
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 :
prices[].client/.supplier/.departureSitedoivent sortir en objet embarqué, pas en IRI nu → vérifier les read-groupsclient:read/supplier:read/site:read.- Sérialisation booléen
isArchived(bug #3 M1) : clé présente dans le JSON réel.qualimatCarrierembarqué (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'envCARRIER_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 contratsShared/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 groupesupplier_address:reada été ajouté aux champs scalaires deSupplierAddress(M2) pour quesupplierSupplyAddresss'embarque commeclientDeliveryAddress(M1 avait déjàclient_address:read).QualimatCarrier= mapping ORM lecture seule sur la table référentielle existante (sortie duschema_filter, mapping aligné au DDL ERP-39 →schema:updateno-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) etPOST/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 teststests/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(defaultfalse)certificationType=<code>(filtre ; répétable)search=<text>(fuzzy surname)
- Tri par défaut :
name ASC - Pagination : standard Starseed (règle ABSOLUE n°13) — Hydra, 10/page,
?pagination=falsepour les selects.CarrierProviderbranché surApiPlatform\Doctrine\Orm\Paginator. - Pas de cloisonnement par site (§ 2.3) : tout user
viewvoit 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(avecclient/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/403409 Conflictsi 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→ exigetransport.carriers.archive(Admin seul). - mode strict (RG-4.14) : payload mélangeant un champ archive sans la permission → 403 sur tout le payload.
- payload contenant
- 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_addressdoit appartenir auclientchoisi ;supplier_supply_addressausupplierchoisi → 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/CarrierPriceExportControlleravec#[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 parname. Paginé (règle n°13). - Mapping ORM : nouvelle entité
QualimatCarrier(lecture seule) sur la table existantequalimat_carrier. Aucune opérationPost/Patch/Delete(alimentée parapp: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)
config/sidebar.php— nouvelle 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',
],
],
],
-
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)
- Admin :
-
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 dename+certificationType = QUALIMAT+ adresse (§ 2.5). FKqualimatCarrierconservé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 exactementLIOT, le champliotPlatesapparaît (immatriculations séparées par;) et les autres champs sont masqués (certification, affrètement, décharge…). Conséquences back : (a)certificationTypen'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/dischargeDocumentignoré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 leCarrierProcessor) :certificationType = 'AUTRE'etdischargeDocument IS NULL→ 422 surdischargeDocument. En base,discharge_document_idreste 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 champsindexationRate,containerType(Benne/Fond mouvant) etvolumeM3deviennent visibles et obligatoires. Validation server-side (#[Assert\Callback]) :isChartered = trueet l'un des troisNULL→ 422 sur le champ concerné.
Onglet Adresse
- RG-4.04 (date de validité QUALIMAT) : la
validityDateduqualimatCarrierlié, si antérieure à aujourd'hui, est affichée sur fond rouge (front). Donnée exposée viaqualimatCarrier.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\Callbackconditionnelle. - RG-4.06 (autocomplete BAN) :
citypréremplie depuispostalCodevia l'API BAN (api-adresse.data.gouv.fr), appel direct front viauseAddressAutocomplete()(réutilisé M1/M2/M3). Validation serveur :postalCodematche^[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 champsclient,clientDeliveryAddress(liste des adresses du client sélectionné),departureSite(86/17/82) sont affichés et obligatoires ; les champs fournisseur sont masqués/nuls. CHECKchk_carrier_price_client_branch+ validation Processor (clientDeliveryAddressappartient àclient→ sinon 422). - RG-4.11 (branche FOURNISSEUR) : si
direction = FOURNISSEUR, les champssupplier,supplierSupplyAddress(adresses du fournisseur),deliverySite(86/17/82) sont affichés et obligatoires ; les champs client masqués/nuls. CHECKchk_carrier_price_supplier_branch+ validation Processor (supplierSupplyAddressappartient à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) :
nameunique (case-insensitive) parmi les transporteurs non archivés ET non soft-deletés (index partieluq_carrier_name_active). Doublon → 409 « Un transporteur nommé "{name}" existe déjà. » - RG-4.13 (normalisation serveur) :
nameUPPERCASE ;firstName/lastName(surCarrierContact) Capitalize ; téléphones chiffres uniquement ;emaillowercase ;liotPlatesnormalisé (;-split, trim, UPPER). Formatage à l'affichage front. - RG-4.14 (archivage + mode strict) : PATCH
{ "isArchived": true }exigetransport.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=QUALIMATaccepté + FK persistée ;GET /api/qualimat_carriers?search=ne renvoie que les lignes actives - RG-4.02 : POST
certificationType=AUTREsansdischargeDocument→ 422 ; avec décharge → 201 ; certification ≠ AUTRE sans décharge → 201 - RG-4.03 : POST
isChartered=truesansindexationRate/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
postalCodeinvalide (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=CLIENTsansclient/clientDeliveryAddress/departureSite→ 422 ;clientDeliveryAddressn'appartenant pas auclient→ 422 ; complet → 201 - RG-4.11 : POST prix
direction=FOURNISSEURsymétrique ;supplierSupplyAddressétrangère ausupplier→ 422 - RG-4.12 : POST
namedé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 +archivedAtrempli ; 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/.deliverySiteobjets embarqués (pas IRI nu) ;qualimatCarrierembarqué (statut + validité) - 🔴 Sérialisation booléen (bug #3 M1) : GET détail expose la clé
isArchived - Liste / tri :
GET /api/carriersexclut archivés par défaut ;?includeArchived=trueinclut ; triname 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_documentsMIME hors whitelist → 422 ; MIME valide → IRI ; validation via$file->getMimeType()(pasgetClientMimeType()) - Audit : POST + PATCH + archive →
audit_logentity_type='Carrier',changescorrect - Pagination (règle n°13) : enveloppe Hydra (
totalItems/view) ;?pagination=falserenvoie tout (selects) - Migration :
make db-reset→ schéma OK ; namespace racine ; index partieluq_carrier_name_active; toutes les colonnes ont unCOMMENT ON COLUMN(ColumnsHaveSqlCommentTestvert) - i18n audit :
audit.entity.transport_carrier… présents (AuditableEntitiesHaveI18nLabelTestvert)
8.2 Cas à couvrir (front — Vitest)
usePaginatedList({url:'/carriers'}): exclusion archivés par défaut, envelope HydrauseCarrierForm(): 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_carrierseedée, adresse copiée,validityDatepassé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 dequalimat_carrieren 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, dumpCARRIER_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_atpré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.
- Infra upload générique
Shared(§ 2.7) — tableuploaded_document+FileUploader(MIME serveur) + endpointPOST /api/uploaded_documents. - Migration BDD M4 (tables
carrier+ sous-collections + index partiel + CHECK + COMMENT ON COLUMN). - Entité
QualimatCarrier(lecture seule) + endpointGET /api/qualimat_carriers?search=(RG-4.01). - Entités + Repositories (
Carrier,CarrierAddress,CarrierContact,CarrierPrice) + hydratation liste (§ 2.11). - CarrierProvider + CarrierProcessor (normalisation, archivage, champs conditionnels RG-4.02/4.03, cas LIOT, mode strict).
- Sous-ressources (Addresses / Contacts / Prices Processors) + validations branches Prix (RG-4.10/4.11).
- Export XLSX (répertoire + onglet Prix regroupé Benne/FM) — controllers
priority:1. - RBAC : sync 3 sources + tests personas.
- Tests PHPUnit : matrice RG-4.01 → RG-4.14 (§ 8.1) + capture JSON réel (§ 4.0.bis).
- Front : page Répertoire (
/carriers) +usePaginatedList. - Front : page Ajouter (
/carriers/new) + formulaire principal + saisie assistée QUALIMAT + champs conditionnels. - Front : onglets Adresse (BAN) / Contact / Prix.
- Front : pages Consultation + Modification.
- i18n + libellés audit (
audit.entity.transport_*).
Actions manuelles dans Lesstime (Matthieu)
- Créer le TaskGroup
M4 — Répertoire transporteurs(projet ERP / Starseed, projectId=6). - Créer les tickets ci-dessus avec dépendances séquentielles.
- Mettre à jour le frontmatter (
lesstime_taskgroup_id) avec l'id réel.
✅ Décisions tranchées (Matthieu, 15/06/2026)
- Modèle Prix (RG-4.10/4.11, § 3.2) — « Adresse de départ » / « Adresse de livraison » 86/17/82 = les 3
Site(FKsite) ; « Adresse de livraison du client » =ClientAddress(M1) ; « Adresse d'approvisionnement » =SupplierAddress(M2). ✅ - Lien QUALIMAT = FK + copie éditable (§ 2.5). ✅
- Pas de cloisonnement par site (§ 2.3). ✅
- Infra upload réutilisable
Shared(§ 2.7). ✅ - Décharge obligatoire côté serveur (RG-4.02) — si
certificationType=AUTRE⇒dischargeDocumentrequis (422 sinon). ✅ - Certification QUALIMAT = 5e valeur de l'enum
certification_type, en lecture seule (vient du référentiel), libellé affiché « QUALIMAT ». ✅ - Affrètement (RG-4.03) — indexation + benne/fond mouvant + volume obligatoires server-side si « Affréter » coché (fidèle au docx). ✅
- Cas LIOT (RG-4.01) — nom =
LIOT⇒ champliotPlatesseul affiché, autres champs masqués ;certificationTypenon requis en cas LIOT (nullable), obligatoire sinon. ✅ - Unicité = nom seul (§ 2.6). ✅
⚠️ Points purement techniques (pas de décision métier — défaut posé)
- Type de PK :
BIGINT(cohérence module Transport) — modifiable enINTsi homogénéité globale souhaitée (§ 2.2). - Section sidebar : « Transport » dédiée vs « Logistique » (route
/carriersretenue). Cosmétique.