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