feat(transport) : schéma + entités Carrier + contrat lecture (ERP-155/157)

Schéma BDD du répertoire transporteurs (M4) + entités + contrat de lecture
(liste + détail), socle du front.

- Migration Version20260615150000 : tables carrier / carrier_address /
  carrier_contact / carrier_price (FK cross-module, CHECK enum, index partiel
  uq_carrier_name_active, COMMENT ON COLUMN). uploaded_document et
  qualimat_carrier réutilisées (non recréées).
- Entités Carrier* (#[Auditable], Timestampable/Blamable) + ApiResource
  LECTURE seule (GetCollection + Get via CarrierProvider, anti-N+1, exclusion
  archivés + ?includeArchived). Écriture (POST/PATCH + Processor) reportée WT4+.
- QualimatCarrier : mapping ORM lecture seule sur la table référentielle
  existante (sortie du schema_filter, mapping aligné DDL ERP-39, schema:update
  no-op) + endpoint de recherche read-only (§ 4.7).
- Relations cross-module des prix (Client/Supplier/adresses) via contrats
  Shared (ClientInterface, SupplierInterface, ClientAddressInterface,
  SupplierAddressInterface) + resolve_target_entities — sans import inter-module
  (règle n°1). Ajout du groupe supplier_address:read aux champs de
  SupplierAddress pour l'embed.
- Garde-fous : ColumnCommentsCatalog (carrier* + qualimat_carrier), makefile
  test-db-setup (index partiel carrier), i18n audit (transport_carrier*),
  EntitiesAreTimestampableBlamableTest (QualimatCarrier whitelisté).
- CarrierSerializationContractTest : contrat JSON liste + détail vérifié
  (embeds objet, booléens, enveloppe Hydra) ; JSON réel capturé dans
  spec-back § 4.0.bis.

make db-reset OK, make test vert (731), make nuxt-test vert (480),
php-cs-fixer OK.
This commit is contained in:
Matthieu
2026-06-15 19:15:12 +02:00
parent e607cccf08
commit d9313dbec8
39 changed files with 4696 additions and 16 deletions
@@ -0,0 +1,100 @@
# M4 — Plan maître worktrees (back, Matthieu)
> **Rôle de ce fichier** : vue d'ensemble que la *conversation maître* tient à jour.
> Chaque worktree = une conversation Claude isolée + une branche + une PR vers `develop`.
> Les prompts à coller sont dans `WT*.md`.
## Principe
- 1 worktree = 1 branche partant de `origin/develop` (à jour des deps).
- 1 ticket = 1 PR atomique vers **`develop`** (jamais `main`).
- Commit autorisé sur la branche du worktree (ces prompts SONT la demande explicite) ;
`git commit --no-verify` OK si `make test` est déjà vert (le hook relance toute la suite).
- **Chaque worktree ouvre SA PR** vers `develop` en fin de tâche (cf. bloc PR ci-dessous).
## Bloc PR standard (repris dans chaque prompt)
```bash
git push -u origin <branche>
tea pr create --base develop --head <branche> \
--title "<type>(<scope>) : <titre>" \
--description "Résumé + lien ticket Lesstime ERP-XXX"
```
Puis **labelliser la PR via l'API Gitea** (tea ne pose pas les labels en CLI — `gitea.malio.fr`).
Cible **`develop`**, jamais `main`. **Aucune mention de Claude/IA** dans titre ou description.
## Vagues & ordre de merge
```
VAGUE 0 (en parallèle, dès maintenant)
WT1 1.2 upload Shared base: origin/develop ──┐
WT2 1.1 RBAC + sidebar base: origin/develop (≥ERP-150) ──┤ indépendants
VAGUE 1 (critique, séquentiel) │
WT3 1.3 migration + 1.5 entités/resource/provider + i18n audit
base: origin/develop APRÈS merge WT1 (FK uploaded_document)
⭐ livre le CONTRAT JSON liste+détail → débloque le front (Tristan)
VAGUE 2 (fan-out, tous en parallèle dès WT3 mergé)
WT4 1.6 processor base: develop ≥ WT3
WT5 1.4 qualimat endpoint base: develop ≥ WT2 (perm) + ERP-39 (indépendant de WT3)
WT6 1.7 adresses base: develop ≥ WT3
WT7 1.8 contacts base: develop ≥ WT3
WT8 1.9 prix base: develop ≥ WT3
WT9 1.10 export XLSX base: develop ≥ WT3
VAGUE 3 (final)
WT10 1.11 tests + fixtures + contrat base: develop ≥ TOUT
```
**Parallélisme réel** : 2 worktrees en V0, puis 1 goulot (WT3), puis **jusqu'à 6 en V2**, puis 1 (WT10).
## Règle anti-conflit worktree (IMPORTANT)
Pour que WT4→WT9 tournent en parallèle sans conflit de merge :
| Fichier partagé | Qui le touche | Les autres |
|---|---|---|
| `CarrierFixtures` | **WT10 uniquement** | interdit (WT3 met un fixture minimal, WT6-9 n'y touchent pas) |
| Entité `Carrier` (ApiResource) | **WT3** crée, **WT4** ajoute le Processor | WT6-9 créent des **resources/processors dédiés** par sous-entité, ne modifient pas `Carrier` |
| `ColumnCommentsCatalog` | WT1 (`uploaded_document`), WT3 (`carrier*`) | personne d'autre |
| `fr.json` (clés audit) | **WT3** (clés `audit.entity.transport_*`) | personne d'autre côté back |
| `migrations/` | WT1 puis WT3 (ordre timestamp) | aucune autre migration |
## Mode retenu : STACK séquentiel, SANS worktree (repo principal)
Matthieu empile les MR, un ticket à la fois, **directement dans `/home/matthieu/dev_malio/Starseed`** (pas de worktree).
- **Ignorer les blocs `git worktree add` des `WT*.md`** → remplacés par une branche normale :
```bash
git fetch origin
git checkout -b feat/erp-XXX-... origin/<branche-précédente>
```
- **WT1 hors pile** (déjà mergé). Pile M4 — chaque branche basée sur la précédente :
`WT2 → WT3 → WT4 → WT5 → WT6 → WT7 → WT8 → WT9 → WT10`
- PR de chaque maillon : `--base <branche-précédente>` (bas de pile WT2 = `develop`). Au merge, les MR du dessus se recible auto.
- Docker tourne sur le repo principal → `make test`/`php-cs-fixer` OK sans rebind (le piège worktree-vs-mount ne s'applique plus).
- Worktrees créés pour WT1/WT2 à nettoyer : `git worktree remove ../sb-erp154-upload ../sb-erp153-rbac`.
- Garder les MR basses propres ; merger dans l'ordre.
## Suivi (tenu par la conv maître)
| WT | Ticket | ERP | État | PR | Notes |
|----|--------|-----|------|----|----|
| WT1 | 1.2 upload | 154 | ✅ MERGÉ | #108 | migration `Version20260615130000` |
| WT2 | 1.1 RBAC | 153 | ✅ PR ouverte | #111 | bas de pile (cible develop) |
| WT3 | 1.3+1.5 | 155+157 | ▶️ À LANCER | — | stack sur `feat/erp-153-rbac` ; gate contrat front |
| WT4 | 1.6 proc | 158 | ⛔ bloqué par WT3 | — | |
| WT5 | 1.4 qualimat | 156 | ⛔ bloqué par WT2+ERP-39 | — | |
| WT6 | 1.7 adresses | 159 | ⛔ bloqué par WT3 | — | |
| WT7 | 1.8 contacts | 160 | ⛔ bloqué par WT3 | — | |
| WT8 | 1.9 prix | 161 | ⛔ bloqué par WT3 | — | |
| WT9 | 1.10 export | 162 | ⛔ bloqué par WT3 | — | |
| WT10 | 1.11 tests | 163 | ⛔ bloqué par tout | — | |
## Cadre commun à tous les prompts (rappels projet)
- Carrier vit dans `src/Module/Transport/` (créé par ERP-150). **Miroir = `src/Module/Commercial/`** (Supplier).
- Tests sous `tests/Module/Transport/Api/` (miroir `tests/Module/Commercial/Api/`).
- `declare(strict_types=1);` partout ; commentaires **FR**, code EN.
- `make test` + `make php-cs-fixer-allow-risky` avant de dire « fini ».
- Ne jamais mentionner Claude/IA dans commit/PR.
@@ -0,0 +1,44 @@
# WT1 — Infra upload générique `Shared` (ticket 1.2 / ERP-154)
> Créer le worktree puis lancer Claude dedans :
> ```bash
> git fetch origin
> git worktree add ../sb-erp154-upload -b feat/erp-154-upload origin/develop
> cd ../sb-erp154-upload && claude
> ```
> **Base** : `origin/develop` (aucune dépendance — peut démarrer tout de suite, même avant le merge du socle Transport).
---
## Prompt à coller
Tu travailles sur le projet Starseed (modular monolith DDD, Symfony 8 / API Platform 4). Lis `CLAUDE.md` et `.claude/rules/backend.md` avant de coder. Charge le skill `backend-entity-conventions`.
**Mission** : poser une infra d'upload de fichiers **générique et réutilisable** dans `src/Shared/` (la « Décharge » du M4 en sera le 1er consommateur, mais ce ticket ne touche PAS au module Transport).
**Spec** : `docs/specs/M4-transporteurs/spec-back.md § 2.7`.
**À livrer** :
1. Table `uploaded_document` (migration namespace racine `DoctrineMigrations` dans `migrations/`, postérieure à la dernière présente — vérifie `ls migrations/`). Colonnes : `id`, `original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum`, `created_at`, `created_by`.
2. Service `Shared\Infrastructure\Upload\FileUploader` :
- validation MIME **server-side via `$file->getMimeType()`** (JAMAIS `getClientMimeType()`),
- whitelist MIME explicite (PDF + images),
- bornage taille, checksum sha256, écriture disque `var/uploads/{yyyy}/{mm}/`.
3. Endpoint `POST /api/uploaded_documents` (multipart) → renvoie l'IRI. MIME hors whitelist → **422**.
**Gardes-fous (cassent `make test` sinon)** :
- **`COMMENT ON COLUMN` sur TOUTES les colonnes** de `uploaded_document` (FR, ≤200 car., règle n°12) ET ajoute le bloc `'uploaded_document' => [...]` dans `src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php` — sinon `make test-db-setup` drope les COMMENT et `ColumnsHaveSqlCommentTest` casse.
- Pagination : si tu exposes une `GetCollection`, elle reste paginée (`CollectionsArePaginatedTest`).
**Scope STRICT** : uniquement `src/Shared/` + migration + catalog. Ne crée AUCUN fichier sous `src/Module/Transport/`. Pas d'antivirus/S3/purge (hors périmètre, § 9).
**Tests à écrire** (PHPUnit) : MIME hors whitelist → 422 ; MIME valide → IRI + ligne persistée + checksum calculé.
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky` propre. Commit (`--no-verify` OK si `make test` déjà vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-154-upload
tea pr create --base develop --head feat/erp-154-upload \
--title "feat(shared) : infra upload générique (ERP-154)" \
--description "Table uploaded_document + FileUploader + endpoint POST. Ticket ERP-154."
```
Puis labellise la PR via l'API Gitea (tea ne pose pas les labels en CLI). Cible **develop**. Aucune mention IA.
@@ -0,0 +1,37 @@
# WT10 — Tests PHPUnit + fixtures + contrat JSON (ticket 1.11 / ERP-163)
> ```bash
> git fetch origin
> git worktree add ../sb-erp163-tests -b feat/erp-163-carrier-tests origin/develop
> cd ../sb-erp163-tests && claude
> ```
> **Base** : `origin/develop` **après merge de TOUS les worktrees back** (WT1→WT9). C'est le filet final.
---
## Prompt à coller
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`, `.claude/rules/testing.md`. Charge le skill `backend-entity-conventions`. **Miroir** : `tests/Module/Commercial/Api/Supplier*Test.php`.
**Mission** : couverture complète des RG + capture du contrat de sérialisation + fixtures consolidées. C'est le DoD back avant intégration front.
**Spec** : `spec-back.md § 4.0.bis / 8.1 / 8.4`.
**À livrer** :
- Matrice **RG-4.01→4.14** couverte (§ 8.1) + RBAC par rôle (Compta/Usine → 403, Commerciale → 403 sur write, Admin → archive).
- `CarrierSerializationContractTest` : capture JSON réel **liste + détail** ; `prices[].client`/`.supplier`/sites **embarqués** (pas IRI) ; `qualimatCarrier` embarqué ; `isArchived` présent. Colle les JSON dans `spec-back.md § 4.0.bis`.
- Anti-N+1 liste ; pagination Hydra ; audit (`entity_type='Carrier'`) ; `AuditableEntitiesHaveI18nLabelTest` vert.
- **`CarrierFixtures` idempotent (§ 8.4)** — c'est ICI que les fixtures complètes vivent : transporteur QUALIMAT (validité passée → RG-4.04), AUTRE+décharge, affrété, LIOT, complet (contacts/adresses/prix CLIENT+FOURNISSEUR), 1 archivé.
**Piège CI (mémoire projet)** : la CI tourne `APP_DEBUG=0`. Les tests de **comptage de requêtes (anti-N+1)** passent en local mais cassent en CI (DoctrineDataHolder absent) → vérifie/active `profiling: true` dans la config Doctrine de l'environnement `test`. Sans ça le test anti-N+1 sera rouge en CI.
**Scope** : tests + `CarrierFixtures` + remplissage § 4.0.bis. Tu peux ajuster un test cassé hérité d'un autre WT mais signale-le à la conv maître (ne masque pas un vrai bug).
**Fini quand** : `make test` **intégralement vert** + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-163-carrier-tests
tea pr create --base develop --head feat/erp-163-carrier-tests \
--title "test(transport) : couverture RG-4.01→4.14 + contrat + fixtures (ERP-163)" \
--description "Matrice RG + CarrierSerializationContractTest + CarrierFixtures + § 4.0.bis. Ticket ERP-163."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,45 @@
# WT2 — Permissions `transport.carriers.*` + sidebar (ticket 1.1 / ERP-153)
> ```bash
> git fetch origin
> git worktree add ../sb-erp153-rbac -b feat/erp-153-rbac origin/develop
> cd ../sb-erp153-rbac && claude
> ```
> **Base** : `origin/develop` **après merge d'ERP-150** (le module `Transport` doit exister). Vérifie : `ls src/Module/Transport/`.
---
## Prompt à coller
Projet Starseed (modular monolith DDD). Lis `CLAUDE.md`, `.claude/rules/architecture.md` et `.claude/rules/testing.md` avant de coder.
**Mission** : poser le socle RBAC du module Transport et son entrée de menu. `TransportModule::permissions()` renvoie `[]` aujourd'hui.
**Spec** : `spec-back.md § 5` + `spec-front.md § Accès`.
**À livrer** :
1. `TransportModule::permissions()` déclare `transport.carriers.view`, `transport.carriers.manage`, `transport.carriers.archive`. `app:sync-permissions` les enregistre.
2. **Matrice § 5.2** : Admin (view+manage+archive), Bureau (view+manage), Commerciale (view), Compta + Usine (**aucune**).
3. **RÈGLE ABSOLUE n°8 — les 3 sources RBAC dans le MÊME commit** :
- `config/sidebar.php` : section « Transport » + item `/carriers` + `permission: transport.carriers.view`,
- `frontend/tests/e2e/_fixtures/personas.ts` : ajuster `permissions` + `expectedAdminLinks` des personas existants,
- `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` : miroir back des mêmes personas.
4. Item sidebar masqué pour Compta/Usine ; visible Admin/Bureau/Commerciale.
**Pièges** :
- Ne touche QUE le RBAC/sidebar — pas d'entité, pas de migration.
- Toute modif d'une seule des 3 sources sans les 2 autres = drift / test cassé.
- Section « Transport » vs « Logistique » : prends « Transport » (cosmétique, alignable plus tard).
**Tests à écrire/vérifier** : `app:sync-permissions` OK ; cohérence personas (pas de drift). Lance `make test`.
**Scope STRICT** : RBAC + sidebar + 3 miroirs. Rien d'autre.
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si test vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-153-rbac
tea pr create --base develop --head feat/erp-153-rbac \
--title "feat(transport) : permissions carriers + sidebar (ERP-153)" \
--description "RBAC transport.carriers.* + 3 sources RBAC alignées. Ticket ERP-153."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,54 @@
# WT3 ⭐ — Migration + entités Carrier* + ApiResource + Provider (tickets 1.3 + 1.5 / ERP-155 + ERP-157)
> **Worktree pivot : il livre le CONTRAT JSON qui débloque tout le front.**
> **Mode STACK, sans worktree** (repo principal) — base = branche de WT2 :
> ```bash
> cd /home/matthieu/dev_malio/Starseed && git fetch origin
> git checkout -b feat/erp-155-carrier-schema-entities origin/feat/erp-153-rbac
> ```
> **Base** : `feat/erp-153-rbac` (contient ERP-150 + WT1 + RBAC WT2). Quand #111 sera mergé dans develop, la PR de WT3 se recible automatiquement sur develop.
---
## Prompt à coller
Projet Starseed (modular monolith DDD, Symfony 8 / API Platform 4). Lis `CLAUDE.md`, `.claude/rules/backend.md`, `.claude/rules/architecture.md`. **Charge le skill `backend-entity-conventions`** (patterns entités/migrations complets).
**Mission** : créer le schéma BDD du répertoire transporteurs + les entités + le contrat de lecture (liste + détail). Tu poses le contrat JSON sur lequel le front s'appuiera — c'est le livrable critique.
**Spec** : `spec-back.md § 3.2 / 3.3 / 3.4 / 4.0 / 4.1 / 4.2`. **Miroir = le module Supplier** : `src/Module/Commercial/Domain/Entity/Supplier*.php`, `…/Infrastructure/ApiPlatform/State/Provider/SupplierProvider.php`, `…/Serializer/SupplierReadGroupContextBuilder.php`. Carrier vit dans `src/Module/Transport/`.
### Étape A — Migration (`migrations/`, namespace racine `DoctrineMigrations`)
- **PAS de migration modulaire** : même si la spec dit « modulaire », toute migration va dans `migrations/` namespace racine (tri FQCN cassant sinon). Postérieure à la dernière présente — vérifie `ls migrations/` (à ce jour `Version20260615120000`).
- Tables `carrier`, `carrier_address`, `carrier_contact`, `carrier_price` + FK : `qualimat_carrier`, `uploaded_document`, `client`, `client_address`, `supplier`, `supplier_address`, `site`, `user`.
- `certification_type` **nullable** (null en cas LIOT) + CHECK enum ; CHECK sur `container_type`, `direction`, `pricing_unit`, `price_state`, branches Prix client/fournisseur.
- Index partiel `uq_carrier_name_active` : `LOWER(name)` WHERE non archivé ET non supprimé.
- **`COMMENT ON COLUMN` sur TOUTES les colonnes** (FR, ≤200 car.) + helper standard pour les 4 colonnes Timestampable/Blamable. Bonus `COMMENT ON TABLE`.
### Étape B — Entités + repos
- `Carrier`, `CarrierAddress`, `CarrierContact`, `CarrierPrice` : `#[Auditable]`, `implements TimestampableInterface, BlamableInterface` + `use TimestampableBlamableTrait`. Repos `*RepositoryInterface` (Domain) + `Doctrine*Repository` (Infrastructure).
- `ApiResource` Carrier (attribut sur l'entité, comme Supplier) : `GetCollection` + `Get` + `Post` + `Patch` avec `security` (§ 3.3). **PAS de Delete**.
- Groupes : `carrier:read`, `carrier:item:read`, `qualimat:read`. **Embed au détail** (pas IRI) : `client:read`/`client_address:read`/`supplier:read`/`supplier_address:read`/`site:read` + `qualimatCarrier`. ⚠ les adresses de l'onglet Prix sont des `ClientAddress`/`SupplierAddress` distinctes.
- `CarrierProvider` paginé (`ApiPlatform\Doctrine\Orm\Paginator`), liste **sans cloisonnement site** (§ 2.3), **anti-N+1** (fetch joins, § 2.11), exclut les archivés par défaut + `?includeArchived=true`.
- Piège booléen : `#[SerializedName('isArchived')]` sur le getter.
### Gardes-fous qui CASSENT `make test` (à traiter dans CE worktree)
- `ColumnsHaveSqlCommentTest` → COMMENT partout **+ ajouter les blocs `carrier`, `carrier_address`, `carrier_contact`, `carrier_price` dans `src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php`** (sinon `test-db-setup` drope les COMMENT).
- `makefile test-db-setup` : l'index partiel `uq_carrier_name_active` n'est PAS exprimé par `schema:update`**ajoute-le à la ligne `dbal:run-sql` du target `test-db-setup`** du `makefile`, sinon `make test` casse.
- `AuditableEntitiesHaveI18nLabelTest` → ajoute dans `frontend/i18n/locales/fr.json` les clés `audit.entity.transport_carrier`, `transport_carrieraddress`, `transport_carriercontact`, `transport_carrierprice` (clé = strtolower(module)+'_'+strtolower(Entity)).
- `EntitiesAreTimestampableBlamableTest`, `EntityConstraintsHaveFrenchMessageTest` (messages FR + `Length.max` = longueur colonne), `CollectionsArePaginatedTest`.
**Scope STRICT** : schéma + entités + ApiResource lecture + Provider + i18n audit. **PAS** le Processor d'écriture (→ WT4), **PAS** les sous-ressources POST/PATCH adresses/contacts/prix (→ WT6/7/8), **PAS** l'export (→ WT9). Mets un `CarrierFixtures` **minimal** (1-2 lignes) juste pour faire tourner tes tests de lecture ; les fixtures complètes sont faites par WT10 — n'y investis pas.
**Tests à écrire** : liste exclut archivés / `?includeArchived=true` ; enveloppe Hydra (`member`/`totalItems`) ; `isArchived` présent dans le JSON ; embeds détail présents (pas IRI).
**LIVRABLE GATE** : une fois vert, **capture le JSON réel liste + détail** (`curl` ou test) et colle-le dans `spec-back.md § 4.0.bis`. C'est le signal pour démarrer le front. Préviens la conv maître.
**Fini quand** : `make db-reset` OK + `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si test vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-155-carrier-schema-entities
tea pr create --base feat/erp-153-rbac --head feat/erp-155-carrier-schema-entities \
--title "feat(transport) : schéma + entités Carrier + contrat lecture (ERP-155/157)" \
--description "Migration + entités Carrier* + ApiResource lecture + Provider + i18n audit + contrat JSON. Tickets ERP-155, ERP-157."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,41 @@
# WT4 — CarrierProcessor (ticket 1.6 / ERP-158)
> ```bash
> git fetch origin
> git worktree add ../sb-erp158-processor -b feat/erp-158-carrier-processor origin/develop
> cd ../sb-erp158-processor && claude
> ```
> **Base** : `origin/develop` **après merge de WT3** (entités Carrier) **et WT1** (upload, pour la décharge).
---
## Prompt à coller
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`.
**Mission** : logique d'écriture du formulaire principal Carrier (POST/PATCH) — normalisation, champs conditionnels, archivage. **Miroir** : `src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php` + `Application/Service/SupplierFieldNormalizer.php`.
**Spec** : `spec-back.md § 4.3 / 4.4 / 7`.
**Règles métier à implémenter (un test PHPUnit par RG)** :
- **RG-4.01** : POST avec `qualimatCarrier``certificationType=QUALIMAT` + FK persistée ; cas LIOT (`name='LIOT'`) ⇒ `certificationType` non requis, `liotPlates` accepté.
- **RG-4.02** : `certificationType='AUTRE'` sans `dischargeDocument`**422** (`#[Assert\Callback]`).
- **RG-4.03** : `isChartered=true` sans `indexationRate` / `containerType` / `volumeM3`**422**.
- **RG-4.13** : normalisation via `CarrierFieldNormalizer` (miroir Supplier) — `name` UPPER, contacts Capitalize, phones digits-only, email lower, `liotPlates` (`;`-split/trim/UPPER).
- **RG-4.12** : doublon `name` (parmi actifs) → **409** + `setError` ciblé.
- **RG-4.14** : PATCH `isArchived` exige `transport.carriers.archive` (Admin) ; mode strict → 403 sinon.
**Pièges** :
- Messages de validation **FR explicites** sur chaque contrainte (`EntityConstraintsHaveFrenchMessageTest`).
- Le back renvoie **toutes** les violations d'un coup avec `propertyPath` aligné sur les champs front.
**Scope STRICT** : `CarrierProcessor` + `CarrierFieldNormalizer` + contraintes sur l'entité `Carrier` (formulaire principal). **NE TOUCHE PAS** : les sous-ressources adresses/contacts/prix (WT6/7/8), `CarrierFixtures` (WT10), l'export (WT9). Ajoute tes contraintes sur `Carrier` sans réécrire l'ApiResource posée par WT3.
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-158-carrier-processor
tea pr create --base develop --head feat/erp-158-carrier-processor \
--title "feat(transport) : CarrierProcessor (RG-4.01→4.03/4.12→4.14) (ERP-158)" \
--description "Normalisation + champs conditionnels + archive. Ticket ERP-158."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,37 @@
# WT5 — Endpoint QualimatCarrier lecture seule (ticket 1.4 / ERP-156)
> ```bash
> git fetch origin
> git worktree add ../sb-erp156-qualimat -b feat/erp-156-qualimat-search origin/develop
> cd ../sb-erp156-qualimat && claude
> ```
> **Base** : `origin/develop` **après merge de WT2** (permission `transport.carriers.view`) **et ERP-39** (table `qualimat_carrier` peuplée). **Indépendant de WT3** — peut tourner en parallèle.
---
## Prompt à coller
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`.
**Mission** : exposer le référentiel QUALIMAT (table existante `qualimat_carrier`, alimentée par console) en **lecture seule** + endpoint de recherche pour la saisie assistée du nom (RG-4.01). **Ne touche pas** la commande de sync.
**Spec** : `spec-back.md § 4.7` + RG-4.01.
**À livrer** :
1. Entité `QualimatCarrier` (lecture seule) mappée sur la table existante `qualimat_carrier`. **Aucune écriture exposée** (pas de Post/Patch/Delete). Probablement pas `#[Auditable]` ni Timestampable (référentiel externe synchronisé) — vérifie le mapping existant.
2. `GET /api/qualimat_carriers?search=` : fuzzy sur `name` (+ `siret`), **seulement `is_active = true`**, tri `name`, **paginé** (règle n°13 — `CollectionsArePaginatedTest`).
3. **Security** `is_granted('transport.carriers.view')`.
4. Champs exposés : `id, siret, name, address, postalCode, city, phone, department, status, validityDate, isActive`.
**Tests à écrire** : recherche ne renvoie que les actifs ; pagination Hydra ; 403 sans permission ; tri `name`.
**Scope STRICT** : uniquement l'exposition lecture de `qualimat_carrier`. Ne crée rien autour de `Carrier` (autres worktrees). Si la table n'a pas de COMMENT (référentiel pré-existant), vérifie si elle est dans `EXCLUDED_TABLES` de `ColumnsHaveSqlCommentTest` — ne casse pas ce test.
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-156-qualimat-search
tea pr create --base develop --head feat/erp-156-qualimat-search \
--title "feat(transport) : endpoint recherche QualimatCarrier (ERP-156)" \
--description "Entité lecture seule + GET /api/qualimat_carriers?search=. Ticket ERP-156."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,37 @@
# WT6 — Sous-ressource Adresses (ticket 1.7 / ERP-159)
> ```bash
> git fetch origin
> git worktree add ../sb-erp159-adresses -b feat/erp-159-carrier-addresses origin/develop
> cd ../sb-erp159-adresses && claude
> ```
> **Base** : `origin/develop` **après merge de WT3** (entités `CarrierAddress`). Parallèle à WT5/WT7/WT8/WT9.
---
## Prompt à coller
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`. **Miroir** : `SupplierAddressProcessor.php` (`src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/`).
**Mission** : opérations d'écriture sur les adresses transporteur.
**Spec** : `spec-back.md § 4.5` + RG-4.05→4.07.
**À livrer** :
- `POST /api/carriers/{id}/addresses`, `PATCH`/`DELETE /api/carrier_addresses/{id}` (security `manage`) — **resource/processor dédiés à `CarrierAddress`**, ne modifie pas l'ApiResource `Carrier`.
- **RG-4.06** : `postalCode` matche `^[0-9]{4,5}$` (autocomplete ville = front). Message FR.
- **RG-4.05** : si affrété → adresse obligatoire (Pays/CP/Ville/Adresse) — validation conditionnelle.
- RG-4.07 (bouton Valider masqué si QUALIMAT) = front ; côté back, accepter le PATCH normalement.
**Tests à écrire** : CP invalide → 422 ; adresse affrété incomplète → 422 ; PATCH/DELETE OK avec `manage`, 403 sans.
**Scope STRICT** : uniquement `CarrierAddress` (resource + processor + tests). **NE TOUCHE PAS** `CarrierFixtures` (WT10), l'entité `Carrier`, les autres sous-ressources. Messages de validation FR (`EntityConstraintsHaveFrenchMessageTest`).
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-159-carrier-addresses
tea pr create --base develop --head feat/erp-159-carrier-addresses \
--title "feat(transport) : sous-ressource adresses transporteur (ERP-159)" \
--description "POST/PATCH/DELETE carrier_address + RG-4.05→4.07. Ticket ERP-159."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,35 @@
# WT7 — Sous-ressource Contacts (ticket 1.8 / ERP-160)
> ```bash
> git fetch origin
> git worktree add ../sb-erp160-contacts -b feat/erp-160-carrier-contacts origin/develop
> cd ../sb-erp160-contacts && claude
> ```
> **Base** : `origin/develop` **après merge de WT3**. Parallèle à WT5/WT6/WT8/WT9.
---
## Prompt à coller
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`. **Miroir** : `SupplierContactProcessor.php` (`src/Module/Commercial/…/State/Processor/`).
**Mission** : opérations d'écriture sur les contacts transporteur.
**Spec** : `spec-back.md § 4.5` + RG-4.08.
**À livrer** :
- `POST /api/carriers/{id}/contacts`, `PATCH`/`DELETE /api/carrier_contacts/{id}` (security `manage`) — resource/processor dédiés à `CarrierContact`.
- **RG-4.08** : bloc valide si **≥ 1 champ rempli** (CHECK `chk_carrier_contact_filled` côté migration WT3 + validation Processor) ; **max 2 téléphones**.
**Tests à écrire** : contact vide → 422 ; 1 champ → 200/201 ; 3ᵉ téléphone → 422.
**Scope STRICT** : uniquement `CarrierContact`. **NE TOUCHE PAS** `CarrierFixtures` (WT10), `Carrier`, les autres sous-ressources. Messages FR. Si le CHECK `chk_carrier_contact_filled` manque (WT3 ne l'a pas posé), valide côté Processor et signale-le à la conv maître.
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-160-carrier-contacts
tea pr create --base develop --head feat/erp-160-carrier-contacts \
--title "feat(transport) : sous-ressource contacts transporteur (ERP-160)" \
--description "POST/PATCH/DELETE carrier_contact + RG-4.08 (≥1 champ, max 2 tel). Ticket ERP-160."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,39 @@
# WT8 — Sous-ressource Prix + RG branches (ticket 1.9 / ERP-161)
> ```bash
> git fetch origin
> git worktree add ../sb-erp161-prix -b feat/erp-161-carrier-prices origin/develop
> cd ../sb-erp161-prix && claude
> ```
> **Base** : `origin/develop` **après merge de WT3**. Parallèle à WT5/WT6/WT7/WT9.
---
## Prompt à coller
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`.
**Mission** : opérations d'écriture sur les prix transporteur, avec branches Client / Fournisseur.
**Spec** : `spec-back.md § 4.5 / 7` + RG-4.09→4.11.
**À livrer** :
- `POST /api/carriers/{id}/prices`, `PATCH`/`DELETE /api/carrier_prices/{id}` (security `manage`) — resource/processor dédiés à `CarrierPrice`.
- **RG-4.10 (CLIENT)** : `client`, `clientDeliveryAddress`, `departureSite` requis ; `clientDeliveryAddress` **doit appartenir au `client`** → sinon 422.
- **RG-4.11 (FOURNISSEUR)** : `supplier`, `supplierSupplyAddress`, `deliverySite` requis ; `supplierSupplyAddress` appartient au `supplier` → sinon 422.
- Communs obligatoires : `containerType`, `pricingUnit`, `price`, `priceState`. CHECK branches respectés.
**Rappels FK** : « Adresse départ/livraison 86/17/82 » = `Site` (FK). Livraison client = `ClientAddress`, appro = `SupplierAddress` (relations ORM partagées — pas de M2M).
**Tests à écrire** : branche CLIENT/FOURNISSEUR incomplète → 422 ; adresse étrangère au client/supplier → 422 ; prix valide → 201.
**Scope STRICT** : uniquement `CarrierPrice`. **NE TOUCHE PAS** `CarrierFixtures` (WT10), `Carrier`, les autres sous-ressources. Messages FR.
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-161-carrier-prices
tea pr create --base develop --head feat/erp-161-carrier-prices \
--title "feat(transport) : sous-ressource prix transporteur (ERP-161)" \
--description "POST/PATCH/DELETE carrier_price + RG-4.09→4.11 (branches client/fournisseur). Ticket ERP-161."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,36 @@
# WT9 — Export XLSX (ticket 1.10 / ERP-162)
> ```bash
> git fetch origin
> git worktree add ../sb-erp162-export -b feat/erp-162-carrier-export origin/develop
> cd ../sb-erp162-export && claude
> ```
> **Base** : `origin/develop` **après merge de WT3** (lecture Carrier). Parallèle à WT5/WT6/WT7/WT8.
---
## Prompt à coller
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. **Miroir** : `src/Module/Commercial/Infrastructure/Controller/SupplierExportController.php` (PhpSpreadsheet déjà présent).
**Mission** : export Excel du répertoire et du tableau Prix regroupé.
**Spec** : `spec-back.md § 4.6`.
**À livrer** :
- `GET /api/carriers/export.xlsx` : transporteurs affichés (**mêmes filtres** que la liste) ; colonnes § 4.6.
- `GET /api/carriers/{id}/prices/export.xlsx` : tableau Prix regroupé Benne / Fond Mouvant (colonnes docx p.10).
- **Controllers custom** avec `#[Route(priority: 1)]` (sinon conflit API Platform `{id}`) ; en-tête `Content-Disposition`.
**Tests à écrire** : 200 + en-tête fichier (Content-Disposition + type XLSX) ; respect des filtres.
**Scope STRICT** : controllers d'export + service de génération. **NE TOUCHE PAS** entités, processors, `CarrierFixtures` (WT10). Réutilise le Provider/filtres de WT3 pour la cohérence des données exportées.
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-162-carrier-export
tea pr create --base develop --head feat/erp-162-carrier-export \
--title "feat(transport) : export XLSX répertoire + prix (ERP-162)" \
--description "GET /api/carriers/export.xlsx + /carriers/{id}/prices/export.xlsx. Ticket ERP-162."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
+994
View File
@@ -0,0 +1,994 @@
---
# === 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É (WT3, ERP-155/157)** — JSON réel produit par `CarrierSerializationContractTest` (transporteur complet seedé : lien QUALIMAT, 1 adresse, 1 contact, 2 prix CLIENT + FOURNISSEUR). Les 3 pièges sont vérifiés verts. **Le front peut démarrer sur ce contrat.**
>
> Contraintes d'architecture validées au passage :
> - Relations cross-module des prix (`client`/`supplier`/adresses) câblées **sans import inter-module** (règle n°1) via des contrats `Shared/Domain/Contract/*Interface` + `resolve_target_entities`. L'embed JSON passe par les read-groups des entités concrètes (`client:read`, `client_address:read`, `supplier:read`, `supplier_address:read`, `site:read`). Un groupe `supplier_address:read` a été **ajouté aux champs scalaires de `SupplierAddress`** (M2) pour que `supplierSupplyAddress` s'embarque comme `clientDeliveryAddress` (M1 avait déjà `client_address:read`).
> - `QualimatCarrier` = mapping ORM **lecture seule** sur la table référentielle existante (sortie du `schema_filter`, mapping aligné au DDL ERP-39 → `schema:update` no-op).
**`GET /api/carriers?search=…` (LISTE)** — enveloppe Hydra AP4 (`member`/`totalItems`/`view` sans préfixe `hydra:`), archivés exclus par défaut (`?includeArchived=true` les réintègre) :
```jsonc
{
"@context": "/api/contexts/Carrier", "@id": "/api/carriers", "@type": "Collection",
"totalItems": 1,
"member": [
{
"@id": "/api/carriers/12", "@type": "Carrier", "id": 12,
"name": "TRANSPORTS GRELILLIER",
"qualimatCarrier": { // embarqué (objet), pas IRI — RG-4.04
"@id": "/api/qualimat_carriers/8", "@type": "QualimatCarrier", "id": "8",
"siret": "…", "name": "…", "address": "…", "postalCode": "86000", "city": "Poitiers",
"status": "Valide", "validityDate": "2027-12-31T00:00:00+01:00"
},
"certificationType": "QUALIMAT",
"createdAt": "…", "updatedAt": "…",
"isChartered": false, // bool présent (getter + SerializedName)
"isArchived": false // bool présent (piège #3)
}
],
"view": { "@id": "/api/carriers?search=…", "@type": "PartialCollectionView" }
}
```
**`GET /api/carriers/{id}` (DÉTAIL)** — `qualimatCarrier` + `addresses[]` + `contacts[]` + `prices[]` avec relations cross-module embarquées en objet :
```jsonc
{
"@id": "/api/carriers/12", "@type": "Carrier", "id": 12,
"name": "TRANSPORTS GRELILLIER",
"qualimatCarrier": { "@type": "QualimatCarrier", "status": "Valide", "validityDate": "…", "...": "…" },
"certificationType": "QUALIMAT",
"addresses": [
{ "@type": "CarrierAddress", "id": 4, "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "…", "createdAt": "…", "updatedAt": "…" }
],
"contacts": [
{ "@type": "CarrierContact", "id": 5, "firstName": "Marie", "lastName": "Martin", "phonePrimary": "0612345678", "email": "…", "createdAt": "…", "updatedAt": "…" }
],
"prices": [
{
"@type": "CarrierPrice", "id": 7, "direction": "CLIENT",
"client": { "@type": "Client", "@id": "/api/clients/4", "id": 4, "companyName": "…", "isArchived": false, "...": "…" },
"clientDeliveryAddress": { "@type": "ClientAddress", "@id": "/api/client_addresses/4", "postalCode": "86000", "city": "Poitiers", "street": "…", "...": "…" },
"departureSite": { "@type": "Site", "@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "...": "…" },
"containerType": "BENNE", "pricingUnit": "TONNE", "price": "42.50", "priceState": "VALIDE",
"createdAt": "…", "updatedAt": "…"
},
{
"@type": "CarrierPrice", "id": 8, "direction": "FOURNISSEUR",
"supplier": { "@type": "Supplier", "@id": "/api/suppliers/4", "id": 4, "companyName": "…", "isArchived": false, "...": "…" },
"supplierSupplyAddress": { "@type": "SupplierAddress", "@id": "/api/supplier_addresses/38", "id": 38, "addressType": "DEPART", "country": "France", "postalCode": "17000", "city": "La Rochelle", "street": "…" },
"deliverySite": { "@type": "Site", "@id": "/api/sites/1", "name": "Chatellerault", "...": "…" },
"containerType": "FOND_MOUVANT", "pricingUnit": "FORFAIT", "price": "320.00", "priceState": "EN_COURS",
"createdAt": "…", "updatedAt": "…"
}
],
"createdAt": "…", "updatedAt": "…",
"isChartered": false, "isArchived": false
}
```
> Note WT3 : opérations exposées = `GetCollection` + `Get` (lecture). `POST`/`PATCH` (+ `CarrierProcessor`, normalisation, RG-4.01→4.14, 409 doublon, gating archive) et les sous-ressources d'écriture (adresses/contacts/prix) arrivent aux worktrees suivants (WT4+).
### 4.1 `GET /api/carriers` — Liste
- **Security** : `is_granted('transport.carriers.view')`
- **Query params** (alimentent le panneau « Filtrer ») :
- `includeArchived=true|false` (default `false`)
- `certificationType=<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)
- [ ] **Réponses JSON RÉELLES** capturées (§ 4.0.bis) — à produire au ticket tests (`CarrierSerializationContractTest`)
- [x] Matrice RBAC rôle × permission + mode strict archive (§ 5.2 / RG-4.14)
- [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit + i18n, routes à plat : rappelés
- [x] Réutilisations identifiées (référentiel QUALIMAT, Client/Supplier/Site partagés, `usePaginatedList`, blocs, archive, normalisation, `useAddressAutocomplete`)
- [x] Seed/fixtures démo planifiés (§ 8.4)
- [x] **Décisions tranchées (Matthieu, 15/06)** : lien QUALIMAT FK+copie (§ 2.5) ✅ ; pas de cloisonnement site (§ 2.3) ✅ ; infra upload Shared réutilisable (§ 2.7) ✅ ; unicité nom seul (§ 2.6) ✅
## 9. Hors-périmètre (HP)
- **HP-M4-A** : **Exploitation du référentiel IDTF** (`idtf_product`, ERP-149) dans les écrans transporteurs (régimes de nettoyage par marchandise). Synchronisé mais non consommé par le M4.
- **HP-M4-B** : **Infra upload avancée** — antivirus, stockage objet (S3/MinIO), purge/rétention, prévisualisation. Le M4 livre l'infra **minimale** (§ 2.7).
- **HP-M4-C** : **DELETE / soft delete d'un transporteur** (colonne `deleted_at` préparée, non exposée).
- **HP-M4-D** : **Liaison transporteur ↔ tournées / expéditions** (modules logistiques futurs consommant `carrier_id`).
- **HP-M4-E** : **Historisation des prix** (versionnage des `carrier_price`) — au M4, état simple (En cours/Validé/Non validé).
- **HP-M4-F** : **Validation stricte SIRET / IBAN** (non applicable ici : pas de comptabilité au M4).
- **HP-M4-G** : **Export CSV** (XLSX uniquement au M4).
- **HP-M4-H** : **Onglets « À venir »** non détaillés par le docx → placeholders si présents en maquette.
## 10. Liens & dépendances
### Liens
- Spec front : [`./spec-front.md`](./spec-front.md)
- Spec M2 fournisseurs (pattern de référence) : [`../M2-suppliers/spec-back.md`](../M2-suppliers/spec-back.md)
- Spec M3 prestataires (pattern le plus proche) : [`../M3-prestataires/spec-back.md`](../M3-prestataires/spec-back.md)
- Branches existantes : `feat/erp-150-module-transport` (module) · `feat/erp-39-qualimat-sync` (réf. QUALIMAT) · `feat/erp-149-idtf-sync` (réf. IDTF)
- BAN api : `https://adresse.data.gouv.fr/api-doc/adresse`
- Trace fonctionnelle : `M4-repertoire-transporteurs-V0.docx` / `.pdf` (V0, validé 27/05/2026)
### Dépendances amont (déjà en place dans Starseed)
- Module `Transport` : `qualimat_carrier` (réf. QUALIMAT, ERP-39) + `idtf_product` (réf. IDTF, ERP-149) + `TransportModule`
- Module `Commercial` : `Client` (M1) + `Supplier` (M2) + leurs adresses (onglet Prix, relation ORM partagée)
- Module `Sites` : `Site` (3 sites 86/17/82) — adresses départ/livraison du Prix
- Module `Core` : `User`, `Role`, `Permission`, `Audit`, JWT
- `Shared` : `TimestampableBlamableTrait` + `Subscriber` (+ NOUVELLE infra upload — § 2.7)
- API Platform 4 + Doctrine ORM + PostgreSQL 16 + PhpSpreadsheet (export)
---
## 📦 Tickets Lesstime (à découper)
**TaskGroup Lesstime** : à créer — `M4 — Répertoire transporteurs` (projet `ERP / Starseed`, projectId=6).
Ordre indicatif (back avant front, migration en tête) :
0. **Permissions Transport + sidebar** — remplir `TransportModule::permissions()` (3 permissions) + section sidebar « Transport »/« Logistique » + sync 3 sources RBAC.
1. **Infra upload générique `Shared`** (§ 2.7) — table `uploaded_document` + `FileUploader` (MIME serveur) + endpoint `POST /api/uploaded_documents`.
2. **Migration BDD M4** (tables `carrier` + sous-collections + index partiel + CHECK + COMMENT ON COLUMN).
3. **Entité `QualimatCarrier` (lecture seule)** + endpoint `GET /api/qualimat_carriers?search=` (RG-4.01).
4. **Entités + Repositories** (`Carrier`, `CarrierAddress`, `CarrierContact`, `CarrierPrice`) + hydratation liste (§ 2.11).
5. **CarrierProvider + CarrierProcessor** (normalisation, archivage, champs conditionnels RG-4.02/4.03, cas LIOT, mode strict).
6. **Sous-ressources** (Addresses / Contacts / Prices Processors) + validations branches Prix (RG-4.10/4.11).
7. **Export XLSX** (répertoire + onglet Prix regroupé Benne/FM) — controllers `priority:1`.
8. **RBAC** : sync 3 sources + tests personas.
9. **Tests PHPUnit** : matrice RG-4.01 → RG-4.14 (§ 8.1) + capture JSON réel (§ 4.0.bis).
10. **Front** : page Répertoire (`/carriers`) + `usePaginatedList`.
11. **Front** : page Ajouter (`/carriers/new`) + formulaire principal + saisie assistée QUALIMAT + champs conditionnels.
12. **Front** : onglets Adresse (BAN) / Contact / Prix.
13. **Front** : pages Consultation + Modification.
14. **i18n + libellés audit** (`audit.entity.transport_*`).
### Actions manuelles dans Lesstime (Matthieu)
1. Créer le TaskGroup `M4 — Répertoire transporteurs` (projet ERP / Starseed, projectId=6).
2. Créer les tickets ci-dessus avec dépendances séquentielles.
3. Mettre à jour le frontmatter (`lesstime_taskgroup_id`) avec l'id réel.
### ✅ Décisions tranchées (Matthieu, 15/06/2026)
1. **Modèle Prix** (RG-4.10/4.11, § 3.2) — « Adresse de départ » / « Adresse de livraison » 86/17/82 = les 3 `Site` (FK `site`) ; « Adresse de livraison du client » = `ClientAddress` (M1) ; « Adresse d'approvisionnement » = `SupplierAddress` (M2). ✅
2. **Lien QUALIMAT** = FK + copie éditable (§ 2.5). ✅
3. **Pas de cloisonnement par site** (§ 2.3). ✅
4. **Infra upload réutilisable `Shared`** (§ 2.7). ✅
5. **Décharge obligatoire côté serveur** (RG-4.02) — si `certificationType=AUTRE``dischargeDocument` requis (422 sinon). ✅
6. **Certification QUALIMAT** = 5e valeur de l'enum `certification_type`, en **lecture seule** (vient du référentiel), libellé affiché « QUALIMAT ». ✅
7. **Affrètement** (RG-4.03) — indexation + benne/fond mouvant + volume **obligatoires server-side** si « Affréter » coché (fidèle au docx). ✅
8. **Cas LIOT** (RG-4.01) — nom = `LIOT` ⇒ champ `liotPlates` seul affiché, autres champs masqués ; `certificationType` **non requis** en cas LIOT (nullable), obligatoire sinon. ✅
9. **Unicité = nom seul** (§ 2.6). ✅
### ⚠️ Points purement techniques (pas de décision métier — défaut posé)
1. **Type de PK** : `BIGINT` (cohérence module Transport) — modifiable en `INT` si homogénéité globale souhaitée (§ 2.2).
2. **Section sidebar** : « Transport » dédiée vs « Logistique » (route `/carriers` retenue). Cosmétique.
+354
View File
@@ -0,0 +1,354 @@
---
# === 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) — Restitution Markdown du docx « M4-repertoire-transporteurs-V0 »
# (validé 27/05/2026) + maquette Figma (node 1132-45376). Précisions techniques (back)
# dans spec-back.md. Réutilise le pattern et les composants M1/M2/M3.
# === LIENS ===
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-45376&p=f&m=dev"
regles_metier: [RG-4.01, RG-4.02, RG-4.03, RG-4.04, RG-4.05, RG-4.06, RG-4.07, RG-4.08, RG-4.09, RG-4.10, RG-4.11]
roles: [Admin, Bureau, Compta, Commerciale, Usine]
lien_spec_back: ./spec-back.md
# === VALIDATION CLIENT ===
client_validation_1:
statut: validee
date: 2026-05-27
version: V0
valide_par: "Matthieu (CP MALIO)"
# === LIEN LESSTIME ===
lesstime_project_id: 6
lesstime_taskgroup_id: 31 # M4 — Répertoire transporteurs (tickets ERP-153 → ERP-171)
statut_global: pret_a_dev
---
# Module 4 — Répertoire transporteurs (V0.1 front)
> **Origine** : spec fonctionnelle `M4-repertoire-transporteurs-V0` (validée le 27/05/2026) + maquette Figma. Restitution Markdown pour intégration au workflow MALIO. Toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M4 réutilise le pattern et les composants posés aux [M1 clients](../M1-clients/spec-front.md), [M2 fournisseurs](../M2-suppliers/spec-front.md) et [M3 prestataires](../M3-prestataires/spec-front.md).
> **Socle déjà en place** : le module back `Transport` existe (ERP-150) et porte deux référentiels **synchronisés par commandes console** : transporteurs **QUALIMAT** (`qualimat_carrier`, ERP-39) et codes **IDTF** (`idtf_product`, ERP-149). Le M4 ajoute le **répertoire éditable** (`Carrier`) **par-dessus** ces référentiels — la saisie assistée du nom interroge le référentiel QUALIMAT (RG-4.01). L'IDTF n'est **pas** utilisé par ces écrans.
> **Décisions Matthieu (15/06/2026)** : (1) lien QUALIMAT = FK + **copie éditable** des champs (nom / certification / adresse) ; (2) **pas de cloisonnement par site** (référentiel global) ; (3) le champ « Décharge » s'appuie sur une **infra d'upload réutilisable** (`Shared`), car d'autres uploads suivront. Détails : [`spec-back.md § 2.5 / § 2.3 / § 2.7`](./spec-back.md).
## But
Lister tous les transporteurs de l'organisation et accéder rapidement à leurs fiches : consultation, création, modification, archivage. Le nom est **relié à QUALIMAT** (saisie assistée) ; les transporteurs hors QUALIMAT (GMP+, OVOCOM, compte-propre, LIOT, autre) sont saisis manuellement.
## Accès
- **Depuis** : menu principal → section **Transport** (route `/carriers`). *(Section « Transport » dédiée ou rattachement à une section « Logistique » — à confirmer, cf. [`spec-back.md § 5.3`](./spec-back.md).)*
- **Rôles autorisés** (tableau « Rôles & permissions » du docx) :
| Rôle | Consultation | Ajout / Modification | Archive |
|---|---|---|---|
| **Admin** | ✅ Tout | ✅ Tout | ✅ |
| **Bureau** | ✅ Tout | ✅ Tout | ❌ |
| **Compta** | ❌ | ❌ | ❌ |
| **Commerciale** | ✅ Tout | ❌ | ❌ |
| **Usine** | ❌ | ❌ | ❌ |
> **Notes** :
> - RBAC transposée sur `transport.carriers.*` (cf. [`spec-back.md § 5`](./spec-back.md)). **Commerciale** = consultation seule (pas de « + Ajouter » ni « Modifier »). **Compta** et **Usine** n'ont **aucun** accès au module (item sidebar masqué).
> - **Pas de cloisonnement par site** (≠ M3) : tout rôle autorisé voit tous les transporteurs.
## Navigation
Page d'entrée du module **Transport** (route `/carriers`). Titre : « **Répertoire transporteurs** ».
- Affichage principal : un **datatable** listant tous les transporteurs **actifs** (les archivés sont masqués par défaut — filtre dédié).
- **Clic sur une ligne** → écran **Consultation transporteur** (page dédiée).
- **Bouton « + Ajouter »** (haut droite, si `manage`) → écran **Ajouter un transporteur**.
- **Bouton « Filtrer »** (haut droite) → panneau de filtres.
- **Bouton « Exporter »** (haut droite) → télécharge un **XLSX** des transporteurs **affichés** (cf. filtres actifs). Format dans [`spec-back.md § 4.6`](./spec-back.md).
### Panneau de filtres (bouton « Filtrer »)
Réutilise le pattern M1/M2/M3. Filtres branchés sur les query params de `GET /api/carriers` (cf. [`spec-back.md § 4.1`](./spec-back.md)) :
| Filtre | Composant | Query param back |
|---|---|---|
| **Recherche** (nom) | `<MalioInputText>` | `?search=` |
| **Certification** | `<MalioSelectCheckbox>` (QUALIMAT / GMP+ / OVOCOM / Compte-propre / Autre) | `?certificationType=` |
| **Inclure les archivés** | `<MalioCheckbox>` | `?includeArchived=true` |
- À l'application des filtres → `setFilters(...)` de `usePaginatedList` (retombe en **page 1**).
- **État 100 % local** (jamais dans l'URL — règle ABSOLUE n°6).
## Datatable du Répertoire
Composant : `<MalioDataTable>` branché sur `usePaginatedList<Carrier>({ url: '/carriers' })` (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes :
| Colonne | Source | Tri |
|---|---|---|
| **Nom** | `carrier.name` | ASC par défaut |
| **Certification** | `carrier.certificationType` (libellé i18n) | Non |
| **Date de validité** | `carrier.qualimatCarrier.validityDate` (format `JJ-MM-AAAA`) — **fond rouge si < aujourd'hui** (RG-4.04) | Non |
| **Dernière activité** | `carrier.updatedAt` (format `JJ-MM-AAAA`) | Oui |
> **Clic sur une ligne** → écran Consultation. **Pagination** : standard Starseed 10 / 25 / 50 (défaut 10). Tri serveur `name ASC` par défaut.
## Écran « Ajouter un transporteur »
Création par **onglets successifs avec validation incrémentale** : pour passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant** ; les champs validés passent en lecture seule. **L'onglet Adresses n'est accessible qu'une fois le formulaire principal validé.** Cf. [`spec-back.md § 2.9`](./spec-back.md) (PATCH partiels par groupe de sérialisation).
**Accès** : bouton « + Ajouter » du Répertoire. **Rôles** : Admin, Bureau.
**Barre d'onglets** : `Qualimat` · `Adresses` · `Contacts` · `Prix`.
### Formulaire principal (pré-onglets)
1er bloc à remplir. Sans validation, les onglets ne sont pas accessibles. Une fois validé → POST `/api/carriers`, puis bascule sur l'onglet Qualimat/Adresses ; les champs passent en readonly.
| Champ | Type composant | Obligatoire | Règle |
|---|---|---|---|
| **Nom** (saisie assistée reliée à QUALIMAT) | `<MalioInputText>` (autocomplete) | Oui | RG-4.01 ; RG-4.13 (UPPERCASE serveur) ; RG-4.12 (unicité) |
| **Liste certification transport** | `<MalioSelect>` (GMP+ / OVOCOM / Compte-propre / Autre) | Oui | RG-4.02 ; auto = `QUALIMAT` (lecture seule) si transporteur QUALIMAT sélectionné |
| **Affréter** | `<MalioCheckbox>` | Non | RG-4.03 |
| **Indexation %** | `<MalioInputNumber>` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché |
| **Benne / Fond mouvant** | `<MalioRadioButton>` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché |
| **Volume m³** | `<MalioInputNumber>` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché |
| **Décharge** | `<MalioInputUpload>` *(cf. note)* | Conditionnel (**obligatoire si AUTRE**) | RG-4.02 — visible **et obligatoire** si certification = `AUTRE`. Upload via infra Shared ([`spec-back.md § 2.7`](./spec-back.md)) |
| **Liste immatriculation LIOT** | `<MalioInputText>` (ou TextArea) | Cas LIOT | RG-4.01 — visible **uniquement** si nom = `LIOT` ; les autres champs disparaissent. Immatriculations séparées par `;` |
> **Comportement RG-4.01 (saisie assistée)** : à la saisie du nom, recherche dans le référentiel QUALIMAT via `GET /api/qualimat_carriers?search=`. Sélection d'un résultat → **modal de confirmation** « Êtes-vous sûr de vouloir intégrer ce transporteur ? ». Si confirmé : le **Nom** et la **certification** (= `QUALIMAT`, lecture seule) se remplissent automatiquement, **ainsi que l'onglet Adresse** (copie pays/CP/ville/voie depuis le référentiel). La FK QUALIMAT est conservée (traçabilité + date de validité RG-4.04).
> - **Cas transporteur non trouvé** (pas QUALIMAT) : l'utilisateur choisit une autre certification (RG-4.02) → affichage des champs associés.
> - **Cas LIOT** : si le nom saisi est exactement `LIOT`, seul le champ « Liste immatriculation LIOT » s'affiche, les autres champs sont masqués.
> **Note `<MalioInputUpload>`** : si le composant ne couvre pas le drag & drop / type fichier requis, exception autorisée documentée (`// TODO migrer quand Malio couvre`) — cf. exceptions @.claude/rules/frontend.md.
**Action** : « Valider » (`<MalioButton>`) → POST `/api/carriers` ([`spec-back.md § 4.3`](./spec-back.md)). Succès → onglet « Qualimat » / « Adresses ».
### Onglet « Qualimat »
Sélectionner un transporteur de la liste QUALIMAT afin de mettre à jour les informations du transporteur (saisie assistée — voir RG-4.01).
**Colonnes du tableau de sélection** :
| Colonne | Règle |
|---|---|
| **Sélection** (bouton / clic ligne) | RG-4.03 *(docx)* — clic → modal « Êtes-vous sûr de vouloir intégrer ce transporteur ? » → remplit Nom + certification + onglet adresse |
| **Nom** | — |
| **Adresse** | — |
| **Date de validité** | RG-4.04 — **fond rouge si < date du jour** |
> Cet onglet alimente le formulaire principal et l'onglet Adresse par copie (RG-4.01 / RG-4.05). Source : `GET /api/qualimat_carriers?search=` (lecture seule, lignes actives uniquement).
### Onglet « Adresses »
Saisir l'adresse du transporteur (un bloc par adresse).
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Pays** | `<MalioSelect>` (préremplie « France ») | Conditionnel | RG-4.05 |
| **Code postal** | `<MalioInputText>` (saisie assistée) | Conditionnel | RG-4.06, RG-4.05 — déclenche autocomplete ville (BAN) |
| **Ville** | `<MalioSelect>` (saisie assistée) | Conditionnel | RG-4.06, RG-4.05 — alimentée par api-adresse.data.gouv.fr |
| **Adresse** | `<MalioInputText>` (saisie assistée) | Conditionnel | RG-4.05 |
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
> **RG-4.05** : les champs sont **déjà remplis** si le transporteur est QUALIMAT (copie). Si « Affréter » est coché, l'adresse devient **obligatoire** (Pays, Code postal, Ville, Adresse).
> **RG-4.06** : la ville est préremplie automatiquement à partir du code postal via l'API BAN (`useAddressAutocomplete()`, réutilisé M1/M2/M3). Si plusieurs villes → choix dans le select. L'adresse est une saisie assistée basée sur le CP et la ville.
> **RG-4.07** : le bouton « Valider » **n'apparaît pas** pour un transporteur QUALIMAT (adresse remplie automatiquement).
**Actions** : « Valider » → PATCH `/api/carriers/{id}/addresses` (sauf QUALIMAT, RG-4.07).
### Onglet « Contacts »
Saisir un ou plusieurs contacts associés au transporteur.
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Nom** | `<MalioInputText>` | Non | RG-4.08 + RG-4.13 (Capitalize) |
| **Prénom** | `<MalioInputText>` | Non | RG-4.08 + RG-4.13 (Capitalize) |
| **Fonction** | `<MalioInputText>` | Non | RG-4.08 |
| **Téléphone** (x1, +1 possible, **max 2**) | `<MalioInputText>` | Non | RG-4.08 + RG-4.13 (format) |
| **Email** | `<MalioInputText>` type email | Non | RG-4.08 + RG-4.13 (lowercase) |
**RG-4.08** : un bloc Contact est valide dès qu'au moins 1 champ est rempli. Impossible d'ajouter un nouveau bloc tant que le précédent n'est pas valide.
**Actions** :
- « + Nouveau contact » : ajoute un bloc. **Désactivé tant que le bloc précédent n'a aucun champ rempli** (RG-4.08).
- « Supprimer » (icône) : modal de confirmation, puis suppression du bloc.
- « Valider » → PATCH `/api/carriers/{id}/contacts`.
### Onglet « Prix »
Saisir un suivi de prix du transporteur (un bloc par prix). Tous les champs sont masqués par défaut sauf le radio « Client / Fournisseur » (RG-4.09).
**Bloc Prix** :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Client / Fournisseur** | `<MalioRadioButton>` | Oui | RG-4.09 |
| **Client** | `<MalioSelect>` (liste des clients) | Conditionnel | RG-4.10 — si Client |
| **Adresse de livraison** | `<MalioSelect>` (adresses du client sélectionné) | Conditionnel | RG-4.10 — si Client |
| **Adresse de départ** | `<MalioSelect>` (86 / 17 / 82) | Conditionnel | RG-4.10 — si Client ; = un des 3 sites |
| **Fournisseur** | `<MalioSelect>` (liste des fournisseurs) | Conditionnel | RG-4.11 — si Fournisseur |
| **Adresse d'approvisionnement** | `<MalioSelect>` (adresses du fournisseur) | Conditionnel | RG-4.11 — si Fournisseur |
| **Adresse de livraison** | `<MalioSelect>` (86 / 17 / 82) | Conditionnel | RG-4.11 — si Fournisseur ; = un des 3 sites |
| **Benne / Fond mouvant (FM)** | `<MalioRadioButton>` | Oui | — |
| **Forfait / Tonne** | `<MalioRadioButton>` | Oui | — |
| **Prix** | `<MalioInputAmount>` (monnaie) | Oui | — |
| **État du prix** | `<MalioSelect>` (En cours / Validé / Non validé) | Oui | — |
> **RG-4.10** : si **Client** sélectionné → champs liés au client affichés et obligatoires ; champs fournisseur masqués et non obligatoires.
> **RG-4.11** : si **Fournisseur** sélectionné → champs liés au fournisseur affichés et obligatoires ; champs client masqués et non obligatoires.
> **Adresse de départ / livraison « 86 / 17 / 82 »** = les 3 `Site` fixes (cf. switcher de site Châtellerault / Saint-Jean / Pommevic en haut de l'app). La sélection stocke un **ID de Site** ([`spec-back.md § 3.2`](./spec-back.md)).
**Actions** :
- « + Nouveau prix » : ajoute un bloc. Bloqué tant que le précédent n'est pas valide.
- « Supprimer » (icône) : modal de confirmation puis suppression.
- « Valider » → PATCH `/api/carriers/{id}/prices`.
## Écran « Consultation d'un transporteur »
Consulter en **lecture seule** la fiche complète. Affiche en haut du bloc les infos principales du transporteur (comme l'écran d'ajout) ainsi que les onglets Adresses, Contacts, Prix. **Tous les champs sont en lecture seule.**
**Accès** : clic sur une ligne du Répertoire. La page s'ouvre par défaut sur l'onglet **Adresses**. Icône « flèche » à gauche pour revenir au répertoire. Deux boutons à droite :
- **« Modifier »** (visible si `transport.carriers.manage` → Admin, Bureau).
- **« Archiver »** (visible **uniquement Admin** via `transport.carriers.archive`) → modal de confirmation, puis PATCH `/api/carriers/{id}` `{ "isArchived": true }`.
> Un transporteur archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé.
### Onglet Adresses (consultation)
Un bloc par adresse du transporteur. Chaque bloc, 5 champs en lecture seule : Pays / Code postal / Ville / Adresse / Adresse complémentaire.
### Onglet Contacts (consultation)
Un bloc par contact. 5 champs en lecture seule : Nom / Prénom / Fonction / Téléphone (x1 ou x2) / Email.
### Onglet Prix (consultation)
Un tableau regroupant les prix par type (**Fond Mouvant / Benne**) :
| Colonne | Description |
|---|---|
| **Colonne de regroupement** | « Fond Mouvant » / « Benne » |
| **Transporteurs** | Nom du transporteur |
| **Adresse APRO ou Adresse Sites** | Si prix « Client » → Adresse APRO sinon Adresse Sites |
| **Adresse livraisons** | — |
| **Forfait €** | Prix |
| **Tonne €** | Prix |
| **Indexation** | Pourcentage d'indexation (vide si non rempli) |
| **État du prix** | Validé / Non Validé / En cours |
**Action** : « Exporter » → exporte le tableau au **format Excel** (`GET /api/carriers/{id}/prices/export.xlsx`).
## Écran « Modification d'un transporteur »
Modifier les informations d'un transporteur existant. **Identique à l'écran « Ajouter un transporteur »** — mêmes formulaires, mêmes règles métier (RG-4.01 à RG-4.11) — sauf :
- Les champs sont **pré-remplis** avec les valeurs actuelles.
- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel).
- **Accès** : depuis l'écran Consultation, bouton « Modifier » (Admin, Bureau).
## Composants UI à utiliser (`@malio/layer-ui`)
- **Datatable** : `<MalioDataTable>` (+ `usePaginatedList`)
- **Input texte** : `<MalioInputText>`
- **Input nombre / montant** : `<MalioInputNumber>` (indexation, volume), `<MalioInputAmount>` (prix)
- **Select simple** : `<MalioSelect>` (certification, pays, ville, client, fournisseur, adresses, sites, état du prix)
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (filtres certification)
- **Radio** : `<MalioRadioButton>` (Benne/Fond mouvant, Forfait/Tonne, Client/Fournisseur)
- **Checkbox** : `<MalioCheckbox>` (Affréter, inclure archivés)
- **Upload** : `<MalioInputUpload>` (Décharge — exception documentée si type non couvert)
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
- **Toasts** : standards via `useApi()`
- **Validation par champ** : `useFormErrors` (mapping 422 inline — règle frontend obligatoire)
**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) :
- Modal de confirmation : wrapper partagé dans `frontend/shared/` (réutiliser celui du M1/M2/M3).
- `<MalioInputUpload>` si le type fichier / drag & drop n'est pas couvert.
## Composables & appels API
- `usePaginatedList<Carrier>({ url: '/carriers' })` — liste paginée (obligatoire). Consomme `name`, `certificationType`, `qualimatCarrier.validityDate` (RG-4.04), `updatedAt` (cf. [`spec-back.md § 2.11 / § 4.0`](./spec-back.md)).
- `useCarrier(id)` — charge le détail via `GET /api/carriers/{id}`, qui **embarque** `addresses`, `contacts`, `prices` (avec `client`/`supplier`/sites imbriqués) + `qualimatCarrier`. Écrans Consultation et Modification peuplés depuis cette seule réponse. **DoD avant intégration** : vérifier le JSON réel (cf. [`spec-back.md § 4.0.bis`](./spec-back.md)).
- `useCarrierForm()` — workflow par onglet (POST principal + PATCH partiels par groupe), miroir de `useSupplierForm()`/`useProviderForm()` + gestion des **champs conditionnels** (Affréter, AUTRE→Décharge, cas LIOT).
- `useQualimatSearch()` — saisie assistée du nom : `GET /api/qualimat_carriers?search=`, modal de confirmation, copie des champs + FK (RG-4.01).
- `useAddressAutocomplete()`**réutilisé** du M1/M2/M3 (BAN), pas de réécriture (RG-4.06).
- `useUpload()` (NOUVEAU, infra Shared) — POST multipart `/api/uploaded_documents` → renvoie l'IRI à poser sur `carrier.dischargeDocument` (RG-4.02).
- `usePermissions()` — masque l'item sidebar et les boutons selon les permissions.
- Tous les appels passent par `useApi()` (jamais `$fetch` direct — règle ABSOLUE n°4).
- Filter `formatPhoneFR()`**réutilisé** pour l'affichage `XX XX XX XX XX`.
## Règles de formatage et normalisation
Le serveur normalise systématiquement (RG-4.13 — cf. [`spec-back.md`](./spec-back.md)) :
| Champ | Normalisation serveur | Affichage front |
|---|---|---|
| Nom transporteur (`name`) | UPPERCASE intégral | UPPERCASE |
| Nom + Prénom contact | Capitalize | identique |
| Téléphones (`CarrierContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` (filter Vue) |
| Email | lowercase intégral | identique |
| Immatriculations LIOT | `;`-split, trim, UPPER | listées |
> Le front **ne normalise pas** : il envoie la valeur saisie, le serveur normalise et renvoie la valeur normalisée que l'UI affiche.
## API adresse postale
Code postal + Ville + Adresse branchés sur **api-adresse.data.gouv.fr** (BAN) via le composable `useAddressAutocomplete()` **déjà créé au M1/M2/M3** (réutilisé tel quel) :
- À la saisie du CP (5 chiffres) : `GET https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville (RG-4.06 : si plusieurs villes, choix dans le select).
- À la saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions.
- Cas dégradé (timeout / offline) : Ville en `<MalioInputText>` libre + toast d'avertissement.
## Différences notables avec les modules précédents
| Zone | M2/M3 | M4 transporteurs |
|---|---|---|
| Source du nom | saisie libre | **saisie assistée reliée à QUALIMAT** (référentiel synchronisé) |
| Onglet Comptabilité / RIB | présent (M2/M3) | **Absent** |
| Cloisonnement par site | M3 : oui | **Non** (référentiel global) |
| Champs conditionnels formulaire principal | peu | **Nombreux** (Affréter, AUTRE→Décharge, cas LIOT) |
| Onglet Prix | absent | **Présent** (Client/Fournisseur, sites départ/livraison) |
| Upload de fichier | aucun | **Décharge** (infra upload Shared, réutilisable) |
| Module | Commercial / Technique | **Transport** (existant, ERP-150) |
## Points résolus côté back
| # | Zone d'ombre | Résolution (cf. `spec-back.md`) |
|---|---|---|
| 1 | Lien QUALIMAT | FK `qualimatCarrier` + **copie éditable** des champs (§ 2.5) |
| 2 | Cas LIOT | Champ `liotPlates` (`;`-séparé), autres champs masqués (RG-4.01) |
| 3 | Certification QUALIMAT | Valeur `QUALIMAT` lecture seule si lié (§ 2.5) |
| 4 | Décharge (upload) | Infra upload générique `Shared` réutilisable (§ 2.7) |
| 5 | Onglet Prix — branches | M2M absentes : FK Client/Supplier + adresses + sites (RG-4.10/4.11, § 3.2) |
| 6 | Adresse de départ/livraison 86/17/82 | = les 3 `Site` fixes (FK Site) |
| 7 | Workflow par onglet | Sauvegarde incrémentale (POST principal + PATCH partiels) — pas d'état « draft » |
| 8 | Archive vs delete | Flag `is_archived` séparé ; archivage Admin seul ; soft delete = HP |
| 9 | Unicité métier | Nom seul (§ 2.6) |
| 10 | Référentiel QUALIMAT | Endpoint lecture seule `GET /api/qualimat_carriers?search=` (§ 4.7) |
| 11 | Format export | XLSX (répertoire + onglet Prix regroupé Benne/FM) |
| 12 | RBAC | `transport.carriers.view/manage/archive` ; Compta + Usine sans accès (§ 5.2) |
---
## 📦 Tickets Lesstime
**TaskGroup Lesstime** : à créer — `M4 — Répertoire transporteurs` (projet `ERP / Starseed`, projectId=6). Découpe détaillée (back en tête) → [`spec-back.md § Tickets Lesstime`](./spec-back.md#-tickets-lesstime-à-découper).
| Ordre | Sujet | Tag |
|---|---|---|
| 0 | Permissions `transport.carriers.*` + sidebar + 3 sources RBAC | Backend |
| 1 | Infra upload générique `Shared` (uploaded_document + FileUploader + endpoint) | Backend |
| 2 | Migration BDD M4 (carrier + sous-collections + index + COMMENT) | Backend |
| 3 | Entité `QualimatCarrier` (lecture seule) + endpoint recherche | Backend |
| 4 | Entités + Repositories Carrier* | Backend |
| 5 | CarrierProvider + CarrierProcessor (champs conditionnels, archive, LIOT) | Backend |
| 6 | Sous-ressources Adresses / Contacts / Prix (RG-4.10/4.11) | Backend |
| 7 | Export XLSX (répertoire + onglet Prix) | Backend |
| 8 | Tests PHPUnit RG-4.01→4.14 + capture contrat JSON | Backend |
| 9 | Page Répertoire (`/carriers`) + usePaginatedList | Frontend |
| 10 | Page Ajouter + formulaire principal + saisie assistée QUALIMAT | Frontend |
| 11 | Onglets Adresses (BAN) / Contacts / Prix | Frontend |
| 12 | Pages Consultation + Modification | Frontend |
| 13 | i18n + libellés audit + upload front (useUpload) | Frontend |
@@ -0,0 +1,307 @@
# M4 — Répertoire transporteurs · Découpe en tickets Lesstime
> **Statut** : ✅ **poussé dans Lesstime** — TaskGroup **#31 « M4 — Répertoire transporteurs »** (projet STARSEED), 19 tickets **ERP-153 → ERP-171** au statut **Prêt à dev**.
> **Assignation** : tickets **Backend (1.1→1.11, ERP-153→163) → Matthieu** · tickets **Frontend (1.12→1.19, ERP-164→171) → Tristan**.
>
> | Pos | Ticket | Réf |
> |---|---|---|
> | 1.1 | Permissions transport.carriers.* + sidebar | ERP-153 |
> | 1.2 | Infra upload générique Shared | ERP-154 |
> | 1.3 | Migration BDD M4 | ERP-155 |
> | 1.4 | QualimatCarrier + endpoint recherche | ERP-156 |
> | 1.5 | Entités Carrier* + ApiResource + Provider | ERP-157 |
> | 1.6 | CarrierProcessor (RG-4.01/02/03 + LIOT) | ERP-158 |
> | 1.7 | Sous-ressource Adresses | ERP-159 |
> | 1.8 | Sous-ressource Contacts | ERP-160 |
> | 1.9 | Sous-ressource Prix + branches | ERP-161 |
> | 1.10 | Export XLSX | ERP-162 |
> | 1.11 | Tests PHPUnit + contrat JSON | ERP-163 |
> | 1.12 | Page Répertoire /carriers | ERP-164 |
> | 1.13 | Page Ajouter (layout + formulaire) | ERP-165 |
> | 1.14 | Saisie assistée QUALIMAT + conditionnels | ERP-166 |
> | 1.15 | Onglet Adresses (BAN) | ERP-167 |
> | 1.16 | Onglet Contacts | ERP-168 |
> | 1.17 | Onglet Prix | ERP-169 |
> | 1.18 | Consultation + Modification | ERP-170 |
> | 1.19 | Upload front + i18n + audit | ERP-171 |
> **Specs sources** : [`spec-back.md`](./spec-back.md) · [`spec-front.md`](./spec-front.md) — validées (docx V0 du 27/05/2026).
> **Maquette Figma** : node `1132-45376` ([lien](https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-45376&p=f&m=dev)).
## ⚠️ Dépendance amont (socle Tristan — en cours de merge)
Le M4 s'appuie sur le module `Transport` et le référentiel QUALIMAT, livrés par les PR de Tristan **en cours de merge** dans `develop` :
- **ERP-150** (PR #97) — module `Transport` (`TransportModule`, layer front, `config/modules.php`). **Requis** par tout le M4.
- **ERP-39** (PR #99) — sync QUALIMAT (`qualimat_carrier` + commande `app:qualimat:sync`). **Requis** par la saisie assistée (ticket 1.4).
- **ERP-149** (PR #101) — sync IDTF (`idtf_product`). **NON requis** par le M4 (référentiel autonome, hors écrans transporteurs).
> Les 3 PR sont **empilées** (`develop → ERP-150 → ERP-39 → ERP-149`). Démarrer le M4 une fois **ERP-150 + ERP-39 dans `develop`** (DoR des tickets 1.1 et 1.4). Brancher le M4 sur `develop` post-merge.
## Vue d'ensemble (ordre d'exécution)
| # | Ticket | Tag | Effort | RG / dépend |
|---|---|---|---|---|
| 1.1 | Déclarer permissions `transport.carriers.*` + sidebar | Backend | S | DoR : ERP-150 mergé |
| 1.2 | Créer l'infra d'upload générique `Shared` | Backend | M | § 2.7 |
| 1.3 | Migrer le schéma BDD M4 (carrier + sous-tables) | Backend | M | § 3.2 |
| 1.4 | Exposer `QualimatCarrier` (lecture seule) + endpoint recherche | Backend | S | RG-4.01 · DoR : ERP-39 mergé |
| 1.5 | Créer entités `Carrier*` + repos + `ApiResource` + `CarrierProvider` | Backend | M | § 3.3 / 4.0 |
| 1.6 | Implémenter `CarrierProcessor` (RG-4.01/4.02/4.03 + LIOT + normalisation + archive) | Backend | M | RG-4.01→4.03, 4.13, 4.14 |
| 1.7 | Sous-ressource Adresses (`carrier_address`) | Backend | S | RG-4.05→4.07 |
| 1.8 | Sous-ressource Contacts (`carrier_contact`) | Backend | S | RG-4.08 |
| 1.9 | Sous-ressource Prix (`carrier_price`) + RG branches | Backend | M | RG-4.09→4.11 |
| 1.10 | Export XLSX (répertoire + onglet Prix regroupé) | Backend | M | § 4.6 |
| 1.11 | Tests PHPUnit RG-4.01→4.14 + capture contrat JSON (DoD) | Backend | M | § 4.0.bis / 8.1 |
| 1.12 | Page Répertoire `/carriers` (datatable, filtres, export) | Frontend | M | RG-4.04 |
| 1.13 | Page Ajouter `/carriers/new` (layout, onglets, formulaire principal POST) | Frontend | M | RG-4.12 |
| 1.14 | Saisie assistée QUALIMAT + champs conditionnels (Affréter / AUTRE→Décharge / LIOT) | Frontend | M | RG-4.01→4.03 |
| 1.15 | Onglet Adresses (autocomplete BAN) | Frontend | M | RG-4.05→4.07 |
| 1.16 | Onglet Contacts | Frontend | S | RG-4.08 |
| 1.17 | Onglet Prix (Client/Fournisseur, sites) | Frontend | M | RG-4.09→4.11 |
| 1.18 | Pages Consultation + Modification | Frontend | M | — |
| 1.19 | Upload front (`useUpload`) + i18n + libellés audit | Frontend | S | § 2.8 |
**Total** : 19 tickets · ~11 back / 8 front · mini-MR de 1 à 4h.
---
## Tickets — détail
### 1.1 — Déclarer permissions `transport.carriers.*` + sidebar
**Position** : 1.1 • Suit : — • Précède : Migrer le schéma BDD M4
**Tag** : Backend • **Effort** : S
**Contexte** : `TransportModule::permissions()` renvoie aujourd'hui `[]`. Ce ticket pose le socle RBAC du module et son entrée de menu, prérequis de toute opération sécurisée.
**Spec liée** : [`spec-back.md § 5`](./spec-back.md) · [`spec-front.md § Accès`](./spec-front.md)
**Critères d'acceptation** :
- [ ] `TransportModule::permissions()` déclare `transport.carriers.view`, `transport.carriers.manage`, `transport.carriers.archive` ; `app:sync-permissions` les enregistre.
- [ ] **Matrice § 5.2** : Admin (view+manage+archive), Bureau (view+manage), Commerciale (view), Compta + Usine (aucune).
- [ ] **3 sources RBAC alignées dans le même commit** (règle ABSOLUE n°8) : `config/sidebar.php` (section Transport + item `/carriers` + permission), `personas.ts`, `SeedE2ECommand.php`.
- [ ] Item sidebar masqué pour Compta/Usine ; visible Admin/Bureau/Commerciale.
**Tests à prévoir** : permissions sync OK ; personas e2e cohérents (pas de drift).
**Tips** : DoR — ERP-150 mergé (module Transport présent). Section sidebar « Transport » (ou « Logistique » — à trancher, cosmétique).
### 1.2 — Créer l'infra d'upload générique `Shared`
**Position** : 1.2 • Suit : permissions • Précède : Migration M4
**Tag** : Backend • **Effort** : M
**Contexte** : la « Décharge » (RG-4.02) est le 1er d'une série d'uploads à venir. On pose une infra réutilisable, pas un upload ad hoc.
**Spec liée** : [`spec-back.md § 2.7`](./spec-back.md)
**Critères d'acceptation** :
- [ ] Table `uploaded_document` (`original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum`, `created_at`, `created_by`) + COMMENT ON COLUMN.
- [ ] Service `Shared\Infrastructure\Upload\FileUploader` : validation MIME **server-side via `$file->getMimeType()`** (jamais `getClientMimeType()`), bornage taille, checksum sha256, écriture disque (`var/uploads/{yyyy}/{mm}/`).
- [ ] Endpoint `POST /api/uploaded_documents` (multipart) → renvoie l'IRI ; whitelist MIME explicite (PDF + images) ; hors whitelist → 422.
**Tests à prévoir** : PHPUnit — MIME hors whitelist → 422 ; MIME valide → IRI + ligne persistée ; checksum calculé.
**Tips** : générique et réutilisable (autres modules la consommeront). Antivirus / S3 / purge = HP (§ 9).
### 1.3 — Migrer le schéma BDD M4 (carrier + sous-tables)
**Position** : 1.3 • Suit : infra upload • Précède : QualimatCarrier
**Tag** : Backend • **Effort** : M
**Contexte** : créer le schéma du répertoire (entité éditable distincte du référentiel `qualimat_carrier`).
**Spec liée** : [`spec-back.md § 3.2`](./spec-back.md)
**Critères d'acceptation** :
- [ ] Migration namespace racine `DoctrineMigrations`, **postérieure** à `Version20260612160000`.
- [ ] Tables `carrier`, `carrier_address`, `carrier_contact`, `carrier_price` + FK (`qualimat_carrier`, `uploaded_document`, `client`, `client_address`, `supplier`, `supplier_address`, `site`, `user`).
- [ ] `certification_type` **nullable** (null seulement en cas LIOT) + CHECK enum ; CHECK `container_type`, `direction`, `pricing_unit`, `price_state`, branches Prix client/fournisseur.
- [ ] Index partiel `uq_carrier_name_active` (LOWER(name), WHERE non archivé & non supprimé).
- [ ] **`COMMENT ON COLUMN` sur TOUTES les colonnes** (règle n°12) + helper Timestampable/Blamable. `ColumnsHaveSqlCommentTest` vert.
- [ ] `make db-reset` passe ; schéma conforme.
**Tests à prévoir** : `make db-reset` OK ; `ColumnsHaveSqlCommentTest` vert ; index partiel présent.
**Tips** : PK `BIGINT` (cohérence module Transport) — à confirmer vs `INT`.
### 1.4 — Exposer `QualimatCarrier` (lecture seule) + endpoint recherche
**Position** : 1.4 • Suit : migration • Précède : entités Carrier*
**Tag** : Backend • **Effort** : S
**Contexte** : la saisie assistée du nom (RG-4.01) a besoin d'un endpoint de recherche sur le référentiel QUALIMAT, aujourd'hui alimenté en console mais non exposé.
**Spec liée** : [`spec-back.md § 4.7`](./spec-back.md) · RG-4.01
**Critères d'acceptation** :
- [ ] Entité `QualimatCarrier` (lecture seule) mappée sur la table existante `qualimat_carrier` (aucune écriture exposée).
- [ ] `GET /api/qualimat_carriers?search=` : fuzzy sur `name` (+ `siret`), **seulement `is_active = true`**, tri `name`, paginé (règle n°13).
- [ ] **Security** `is_granted('transport.carriers.view')`. Champs exposés : `id, siret, name, address, postalCode, city, phone, department, status, validityDate, isActive`.
**Tests à prévoir** : PHPUnit — recherche ne renvoie que les actifs ; pagination Hydra ; 403 sans permission.
**Tips** : DoR — ERP-39 mergé. Ne pas toucher la commande de sync.
### 1.5 — Créer entités `Carrier*` + repos + `ApiResource` + `CarrierProvider`
**Position** : 1.5 • Suit : QualimatCarrier • Précède : CarrierProcessor
**Tag** : Backend • **Effort** : M
**Contexte** : poser les entités, le contrat de sérialisation (groupes) et la lecture (liste + détail).
**Spec liée** : [`spec-back.md § 3.3 / 3.4 / 4.0 / 4.1 / 4.2`](./spec-back.md)
**Critères d'acceptation** :
- [ ] Entités `Carrier`, `CarrierAddress`, `CarrierContact`, `CarrierPrice` (`#[Auditable]`, `TimestampableBlamableTrait`), repos Doctrine.
- [ ] `ApiResource` Carrier : `GetCollection` + `Get` + `Post` + `Patch` avec `security` (§ 3.3) ; **pas de Delete**.
- [ ] Groupes de sérialisation : `carrier:read`, `carrier:item:read`, `qualimat:read`, embed `client:read`/`client_address:read`/`supplier:read`/`supplier_address:read`/`site:read` au détail (3 maillons § 4.0 — ⚠ les adresses de l'onglet Prix sont des entités `ClientAddress`/`SupplierAddress` distinctes).
- [ ] `CarrierProvider` paginé (`ApiPlatform\Doctrine\Orm\Paginator`) ; liste **sans cloisonnement site** (§ 2.3) ; anti-N+1 (§ 2.11).
- [ ] Piège booléen `isArchived` : `#[SerializedName('isArchived')]` sur le getter.
**Tests à prévoir** : liste exclut archivés par défaut ; `?includeArchived=true` ; enveloppe Hydra ; `isArchived` présent dans le JSON.
**Tips** : miroir `Supplier`/`Provider`. Pas d'onglet Comptabilité (≠ M2/M3).
### 1.6 — Implémenter `CarrierProcessor`
**Position** : 1.6 • Suit : entités • Précède : sous-ressource Adresses
**Tag** : Backend • **Effort** : M
**Contexte** : logique d'écriture du formulaire principal (POST/PATCH) : normalisation, champs conditionnels, archivage.
**Spec liée** : [`spec-back.md § 4.3 / 4.4 / 7`](./spec-back.md)
**Critères d'acceptation** :
- [ ] **RG-4.01** : POST avec `qualimatCarrier``certificationType=QUALIMAT` + FK persistée ; cas LIOT : `name='LIOT'``certificationType` non requis, `liotPlates` accepté.
- [ ] **RG-4.02** : `certificationType='AUTRE'` sans `dischargeDocument`**422** (`#[Assert\Callback]`).
- [ ] **RG-4.03** : `isChartered=true` sans `indexationRate`/`containerType`/`volumeM3`**422**.
- [ ] **RG-4.13** : normalisation (`name` UPPER, contacts Capitalize, phones digits, email lower, `liotPlates`).
- [ ] **RG-4.12** : doublon `name` (actifs) → **409**.
- [ ] **RG-4.14** : PATCH `isArchived` exige `transport.carriers.archive` (Admin) ; mode strict (403 sinon).
**Tests à prévoir** : PHPUnit sur chaque RG ci-dessus (cf. § 8.1).
**Tips** : `CarrierFieldNormalizer` miroir `SupplierFieldNormalizer`.
### 1.7 — Sous-ressource Adresses (`carrier_address`)
**Position** : 1.7 • Suit : CarrierProcessor • Précède : Contacts
**Tag** : Backend • **Effort** : S
**Spec liée** : [`spec-back.md § 4.5`](./spec-back.md) · RG-4.05→4.07
**Critères d'acceptation** :
- [ ] `POST /api/carriers/{id}/addresses`, `PATCH`/`DELETE /api/carrier_addresses/{id}` (security `manage`).
- [ ] **RG-4.06** : `postalCode` matche `^[0-9]{4,5}$` (autocomplete ville = front).
- [ ] **RG-4.05** : si affrété, adresse obligatoire (Pays/CP/Ville/Adresse) — validation conditionnelle.
**Tests à prévoir** : PHPUnit — CP invalide → 422 ; adresse affrété incomplète → 422.
**Tips** : RG-4.07 (bouton Valider masqué si QUALIMAT) = front, back accepte le PATCH.
### 1.8 — Sous-ressource Contacts (`carrier_contact`)
**Position** : 1.8 • Suit : Adresses • Précède : Prix
**Tag** : Backend • **Effort** : S
**Spec liée** : [`spec-back.md § 4.5`](./spec-back.md) · RG-4.08
**Critères d'acceptation** :
- [ ] `POST /api/carriers/{id}/contacts`, `PATCH`/`DELETE /api/carrier_contacts/{id}` (security `manage`).
- [ ] **RG-4.08** : bloc valide si ≥ 1 champ rempli (CHECK `chk_carrier_contact_filled` + Processor) ; **max 2 téléphones**.
**Tests à prévoir** : PHPUnit — contact vide → 422 ; 1 champ → 200.
**Tips** : miroir contacts M2/M3.
### 1.9 — Sous-ressource Prix (`carrier_price`) + RG branches
**Position** : 1.9 • Suit : Contacts • Précède : Export
**Tag** : Backend • **Effort** : M
**Spec liée** : [`spec-back.md § 4.5 / 7`](./spec-back.md) · RG-4.09→4.11
**Critères d'acceptation** :
- [ ] `POST /api/carriers/{id}/prices`, `PATCH`/`DELETE /api/carrier_prices/{id}` (security `manage`).
- [ ] **RG-4.10** (CLIENT) : `client`, `clientDeliveryAddress`, `departureSite` requis ; `clientDeliveryAddress` doit appartenir au `client` → sinon 422.
- [ ] **RG-4.11** (FOURNISSEUR) : `supplier`, `supplierSupplyAddress`, `deliverySite` requis ; `supplierSupplyAddress` appartient au `supplier` → sinon 422.
- [ ] Communs obligatoires : `containerType`, `pricingUnit`, `price`, `priceState` ; CHECK branches respectées.
**Tests à prévoir** : PHPUnit — branche CLIENT/FOURNISSEUR incomplète → 422 ; adresse étrangère → 422.
**Tips** : « Adresse départ/livraison 86/17/82 » = `Site` (FK) ; livraison client = `ClientAddress`, appro = `SupplierAddress` (relations ORM partagées).
### 1.10 — Export XLSX (répertoire + onglet Prix regroupé)
**Position** : 1.10 • Suit : Prix • Précède : Tests PHPUnit
**Tag** : Backend • **Effort** : M
**Spec liée** : [`spec-back.md § 4.6`](./spec-back.md)
**Critères d'acceptation** :
- [ ] `GET /api/carriers/export.xlsx` : transporteurs affichés (mêmes filtres) ; colonnes § 4.6.
- [ ] `GET /api/carriers/{id}/prices/export.xlsx` : tableau Prix regroupé Benne / Fond Mouvant (colonnes docx p.10).
- [ ] Controllers custom `#[Route(priority: 1)]` (conflit API Platform `{id}`) ; `Content-Disposition`.
**Tests à prévoir** : PHPUnit — 200 + en-tête fichier ; respect des filtres.
**Tips** : PhpSpreadsheet déjà présent.
### 1.11 — Tests PHPUnit RG-4.01→4.14 + capture contrat JSON (DoD)
**Position** : 1.11 • Suit : Export • Précède : Page Répertoire
**Tag** : Backend • **Effort** : M
**Spec liée** : [`spec-back.md § 4.0.bis / 8.1`](./spec-back.md)
**Critères d'acceptation** :
- [ ] Matrice RG-4.01→4.14 couverte (§ 8.1) + RBAC par rôle (Compta/Usine → 403).
- [ ] `CarrierSerializationContractTest` : capture JSON réel **liste + détail** ; `prices[].client`/`.supplier`/sites **embarqués** (pas IRI) ; `qualimatCarrier` embarqué ; `isArchived` présent.
- [ ] Anti-N+1 liste ; pagination Hydra ; audit (`entity_type='Carrier'`) ; `AuditableEntitiesHaveI18nLabelTest` vert.
- [ ] `CarrierFixtures` idempotent (§ 8.4) : transporteur QUALIMAT (validité passée), AUTRE+décharge, affrété, LIOT, complet (contacts/adresses/prix CLIENT+FOURNISSEUR), 1 archivé.
**Tests à prévoir** : suite complète `make test` verte.
**Tips** : coller les JSON capturés dans § 4.0.bis (DoD avant front).
### 1.12 — Page Répertoire `/carriers` (datatable, filtres, export)
**Position** : 1.12 • Suit : Tests back • Précède : Page Ajouter
**Tag** : Frontend • **Effort** : M
**Spec liée** : [`spec-front.md § Datatable / Filtres`](./spec-front.md) · Figma `1132-45377`
**Critères d'acceptation** :
- [ ] `<MalioDataTable>` + `usePaginatedList<Carrier>({url:'/carriers'})` ; colonnes Nom / Certification / Date de validité / Dernière activité.
- [ ] **RG-4.04** : date de validité QUALIMAT < aujourd'hui → **fond rouge**.
- [ ] Filtres (`search`, `certificationType`, `includeArchived`) → `setFilters` (page 1) ; **état 100 % local** (règle n°6).
- [ ] Boutons « + Ajouter » (si `manage`) / « Filtrer » / « Exporter » (XLSX) ; clic ligne → Consultation.
**Tests à prévoir** : Vitest — `usePaginatedList` (Hydra, exclusion archivés).
**Tips** : `useApi()` obligatoire ; pas de persistance URL.
### 1.13 — Page Ajouter `/carriers/new` (layout, onglets, formulaire principal POST)
**Position** : 1.13 • Suit : Répertoire • Précède : Saisie assistée QUALIMAT
**Tag** : Frontend • **Effort** : M
**Spec liée** : [`spec-front.md § Écran Ajouter / Formulaire principal`](./spec-front.md) · Figma node `1132-45382` (Ajouter Qualimat)
**Critères d'acceptation** :
- [ ] Layout + barre d'onglets `Qualimat · Adresses · Contacts · Prix` ; validation incrémentale (onglet suivant accessible après validation).
- [ ] Formulaire principal (Nom, Liste certification, Affréter, …) → `POST /api/carriers` ; succès → bascule onglet + champs readonly.
- [ ] `useFormErrors` : mapping 422 inline par champ ; `{ toast:false }`.
**Tests à prévoir** : Vitest — `useCarrierForm` (workflow par onglet, POST principal).
**Tips** : miroir `useSupplierForm`/`useProviderForm`.
### 1.14 — Saisie assistée QUALIMAT + champs conditionnels
**Position** : 1.14 • Suit : Page Ajouter • Précède : Onglet Adresses
**Tag** : Frontend • **Effort** : M
**Spec liée** : [`spec-front.md § Formulaire principal / Onglet Qualimat`](./spec-front.md) · RG-4.01→4.03 · Figma nodes `1132-50717` (Affréter), `1132-50982` (AUTRE→Décharge), `1132-45593` (LIOT)
**Critères d'acceptation** :
- [ ] **RG-4.01** : saisie du nom → `GET /api/qualimat_carriers?search=` → modal « Êtes-vous sûr… » → copie Nom + certification (`QUALIMAT`, readonly) + adresse + FK conservée.
- [ ] **Cas LIOT** : nom `LIOT` → champ immatriculations seul, autres masqués.
- [ ] **RG-4.02** : certification `AUTRE` → champ Décharge visible **et obligatoire** (upload).
- [ ] **RG-4.03** : « Affréter » coché → indexation / benne-fond mouvant / volume visibles et obligatoires.
**Tests à prévoir** : Vitest — affichage conditionnel (Affréter, AUTRE, LIOT) ; copie QUALIMAT.
**Tips** : `useQualimatSearch()` ; `useUpload()` (ticket 1.19) pour la décharge.
### 1.15 — Onglet Adresses (autocomplete BAN)
**Position** : 1.15 • Suit : Saisie QUALIMAT • Précède : Onglet Contacts
**Tag** : Frontend • **Effort** : M
**Spec liée** : [`spec-front.md § Onglet Adresses`](./spec-front.md) · RG-4.05→4.07 · Figma node `1132-45670`
**Critères d'acceptation** :
- [ ] Bloc adresse (Pays/CP/Ville/Adresse/complément) → `PATCH /api/carriers/{id}/addresses`.
- [ ] **RG-4.06** : `useAddressAutocomplete()` (BAN) — ville auto depuis CP, dégradé texte libre.
- [ ] **RG-4.05** : champs préremplis si QUALIMAT ; obligatoires si affrété. **RG-4.07** : pas de bouton Valider si QUALIMAT.
**Tests à prévoir** : Vitest — autocomplete nominal + dégradé (réutilisation M1/M2/M3).
**Tips** : ne pas réécrire `useAddressAutocomplete()`.
### 1.16 — Onglet Contacts
**Position** : 1.16 • Suit : Adresses • Précède : Onglet Prix
**Tag** : Frontend • **Effort** : S
**Spec liée** : [`spec-front.md § Onglet Contacts`](./spec-front.md) · RG-4.08 · Figma node `1132-45756`
**Critères d'acceptation** :
- [ ] Blocs contact (Nom/Prénom/Fonction/Téléphone x1-2/Email) → `PATCH /api/carriers/{id}/contacts`.
- [ ] **RG-4.08** : « + Nouveau contact » bloqué tant que le bloc courant est vide ; suppression avec modal.
**Tests à prévoir** : Vitest — règle « ≥ 1 champ », max 2 téléphones.
**Tips** : `mapViolationsToRecord` par ligne (pattern collections M1/M2/M3).
### 1.17 — Onglet Prix (Client/Fournisseur, sites)
**Position** : 1.17 • Suit : Contacts • Précède : Consultation/Modification
**Tag** : Frontend • **Effort** : M
**Spec liée** : [`spec-front.md § Onglet Prix`](./spec-front.md) · RG-4.09→4.11 · Figma node `1132-45859`
**Critères d'acceptation** :
- [ ] Radio `direction` (Client/Fournisseur) → bascule des champs (**RG-4.09**).
- [ ] **RG-4.10** (Client) : Client + Adresse de livraison (du client) + Adresse de départ (86/17/82).
- [ ] **RG-4.11** (Fournisseur) : Fournisseur + Adresse d'approvisionnement + Adresse de livraison (86/17/82).
- [ ] Communs : Benne/FM, Forfait/Tonne, Prix (`MalioInputAmount`), État du prix → `PATCH /api/carriers/{id}/prices`.
**Tests à prévoir** : Vitest — bascule Client/Fournisseur, champs requis.
**Tips** : selects clients/fournisseurs/sites via endpoints existants (security élargie § 4.8).
### 1.18 — Pages Consultation + Modification
**Position** : 1.18 • Suit : Onglet Prix • Précède : Upload/i18n
**Tag** : Frontend • **Effort** : M
**Spec liée** : [`spec-front.md § Consultation / Modification`](./spec-front.md)
**Critères d'acceptation** :
- [ ] Consultation readonly (ouvre sur Adresses) ; flèche retour ; « Modifier » (si `manage`) ; « Archiver » (Admin) → PATCH `isArchived`.
- [ ] Onglet Prix consultation = tableau regroupé Benne/FM + bouton Exporter (XLSX).
- [ ] Modification = mêmes formulaires, champs pré-remplis, PATCH partiel par onglet.
**Tests à prévoir** : Vitest — `useCarrier(id)` peuple les écrans depuis une seule réponse ; visibilité boutons par permission.
**Tips** : « Restaurer » remplace « Archiver » sur un archivé.
### 1.19 — Upload front (`useUpload`) + i18n + libellés audit
**Position** : 1.19 • Suit : Consultation/Modification • Précède : —
**Tag** : Frontend • **Effort** : S
**Spec liée** : [`spec-back.md § 2.7 / 2.8`](./spec-back.md) · [`spec-front.md § Composables`](./spec-front.md)
**Critères d'acceptation** :
- [ ] Composable `useUpload()` : `POST /api/uploaded_documents` (multipart) → IRI posée sur `carrier.dischargeDocument` (RG-4.02).
- [ ] Clés i18n : libellés certification, sidebar (`sidebar.transport.*`), **libellés audit** `audit.entity.transport_carrier/carrieraddress/carriercontact/carrierprice`.
- [ ] `<MalioInputUpload>` (exception documentée si type non couvert).
**Tests à prévoir** : Vitest — `useUpload` (succès + erreur MIME).
**Tips** : `AuditableEntitiesHaveI18nLabelTest` exige les clés audit.
---
## Actions Lesstime (à exécuter au feu vert de Matthieu)
1. `create-group` projectId 6, title « M4 — Répertoire transporteurs » → récupérer l'`id`.
2. `create-task` ×19 (statut `Prêt à dev` = 6, priorité Moyen=2, effort dans la description), dans l'ordre 1.1 → 1.19 :
- Tickets **1.1 → 1.11** (Backend, tag `3`) → **assigné à Matthieu**.
- Tickets **1.12 → 1.19** (Frontend, tag `2`) → **assigné à Tristan**.
3. Mettre à jour le frontmatter des specs (`lesstime_taskgroup_id`) + lien du groupe.
> Au push : récupérer les `userId` via `list-users` (Matthieu = `5` selon le référentiel ; Tristan à confirmer) pour renseigner l'assignation à la création.