diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index f2cb924..ee2f59d 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -14,9 +14,14 @@ doctrine: # mappee : # - `audit_log` : append-only via DBAL brut (AuditLogWriter) pour # eviter la recursion du listener Doctrine. - # - `qualimat_carrier` / `qualimat_sync_log` : referentiel - # transporteurs synchronise en DBAL brut (upsert `ON CONFLICT`) - # par `app:qualimat:sync`, hors ORM. + # - `qualimat_sync_log` : journal de synchro transporteurs + # QUALIMAT, ecrit en DBAL brut par `app:qualimat:sync`, hors ORM. + # NB : `qualimat_carrier` n'est PLUS filtree depuis M4 (ERP-155) : + # elle est desormais mappee en LECTURE SEULE par l'entite + # App\Module\Transport\Domain\Entity\QualimatCarrier (cible de la + # FK editable carrier.qualimat_carrier_id). Son mapping reproduit + # a l'identique le DDL de la migration ERP-39 (unique siret, index + # is_active, TIMESTAMP(6)) -> schema:update reste un no-op. # - `idtf_product` / `idtf_sync_log` : referentiel codes IDTF # synchronise en DBAL brut par `app:idtf:sync`, hors ORM. # Sans ce filtre, schema:update les considere comme "orphelines" et @@ -25,7 +30,7 @@ doctrine: # supprime juste apres). Creation / suppression restent pilotees par # les migrations (audit_log : Version20260420202749 ; qualimat : # Version20260612150000 ; idtf : Version20260612160000). - schema_filter: '~^(?!(?:audit_log|qualimat_carrier|qualimat_sync_log|idtf_product|idtf_sync_log)$).+~' + schema_filter: '~^(?!(?:audit_log|qualimat_sync_log|idtf_product|idtf_sync_log)$).+~' audit: url: '%env(resolve:DATABASE_URL)%' orm: @@ -49,6 +54,15 @@ doctrine: # Permet au module Commercial de referencer une Category via le contrat # Shared sans importer la classe concrete du module Catalog (regle n°1). App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category + # Cibles des ManyToOne de CarrierPrice (M4 Transport, onglet Prix) : + # permet au module Transport de referencer Client / Supplier et leurs + # adresses (M1/M2 Commercial) via des contrats Shared sans importer les + # classes concretes (regle n°1). L'embed JSON passe par les read-groups + # des entites concretes (client:read / supplier:read / ...). + App\Shared\Domain\Contract\ClientInterface: App\Module\Commercial\Domain\Entity\Client + App\Shared\Domain\Contract\ClientAddressInterface: App\Module\Commercial\Domain\Entity\ClientAddress + App\Shared\Domain\Contract\SupplierInterface: App\Module\Commercial\Domain\Entity\Supplier + App\Shared\Domain\Contract\SupplierAddressInterface: App\Module\Commercial\Domain\Entity\SupplierAddress mappings: # Mapping des entites techniques partagees (src/Shared/Domain/Entity). # Premier occupant : UploadedDocument (infra upload generique ERP-154). @@ -108,6 +122,17 @@ doctrine: dir: '%kernel.project_dir%/src/Module/Technique/Domain/Entity' prefix: 'App\Module\Technique\Domain\Entity' alias: Technique + # Mapping inconditionnel du module Transport (meme logique que Technique) : + # les tables transporteurs (carrier + sous-collections) creees par la + # migration M4 (Version20260615150000) et le mapping lecture-seule de + # qualimat_carrier (referentiel ERP-39) doivent etre connus de l'ORM. + # L'activation fonctionnelle passe par config/modules.php. + Transport: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/src/Module/Transport/Domain/Entity' + prefix: 'App\Module\Transport\Domain\Entity' + alias: Transport controller_resolver: auto_mapping: false diff --git a/docs/specs/M4-transporteurs/prompts/00-PLAN-MAITRE.md b/docs/specs/M4-transporteurs/prompts/00-PLAN-MAITRE.md new file mode 100644 index 0000000..7b6737f --- /dev/null +++ b/docs/specs/M4-transporteurs/prompts/00-PLAN-MAITRE.md @@ -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 +tea pr create --base develop --head \ + --title "() : " \ + --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/ + ``` +- **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 ` (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. diff --git a/docs/specs/M4-transporteurs/prompts/WT1-upload.md b/docs/specs/M4-transporteurs/prompts/WT1-upload.md new file mode 100644 index 0000000..8ce26ab --- /dev/null +++ b/docs/specs/M4-transporteurs/prompts/WT1-upload.md @@ -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. diff --git a/docs/specs/M4-transporteurs/prompts/WT10-tests.md b/docs/specs/M4-transporteurs/prompts/WT10-tests.md new file mode 100644 index 0000000..041ef6d --- /dev/null +++ b/docs/specs/M4-transporteurs/prompts/WT10-tests.md @@ -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. diff --git a/docs/specs/M4-transporteurs/prompts/WT2-rbac.md b/docs/specs/M4-transporteurs/prompts/WT2-rbac.md new file mode 100644 index 0000000..9b1c27e --- /dev/null +++ b/docs/specs/M4-transporteurs/prompts/WT2-rbac.md @@ -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. diff --git a/docs/specs/M4-transporteurs/prompts/WT3-migration-entites.md b/docs/specs/M4-transporteurs/prompts/WT3-migration-entites.md new file mode 100644 index 0000000..621a53b --- /dev/null +++ b/docs/specs/M4-transporteurs/prompts/WT3-migration-entites.md @@ -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. diff --git a/docs/specs/M4-transporteurs/prompts/WT4-processor.md b/docs/specs/M4-transporteurs/prompts/WT4-processor.md new file mode 100644 index 0000000..4a671f9 --- /dev/null +++ b/docs/specs/M4-transporteurs/prompts/WT4-processor.md @@ -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. diff --git a/docs/specs/M4-transporteurs/prompts/WT5-qualimat.md b/docs/specs/M4-transporteurs/prompts/WT5-qualimat.md new file mode 100644 index 0000000..5f61501 --- /dev/null +++ b/docs/specs/M4-transporteurs/prompts/WT5-qualimat.md @@ -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. diff --git a/docs/specs/M4-transporteurs/prompts/WT6-adresses.md b/docs/specs/M4-transporteurs/prompts/WT6-adresses.md new file mode 100644 index 0000000..a23a95e --- /dev/null +++ b/docs/specs/M4-transporteurs/prompts/WT6-adresses.md @@ -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. diff --git a/docs/specs/M4-transporteurs/prompts/WT7-contacts.md b/docs/specs/M4-transporteurs/prompts/WT7-contacts.md new file mode 100644 index 0000000..f9160b1 --- /dev/null +++ b/docs/specs/M4-transporteurs/prompts/WT7-contacts.md @@ -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. diff --git a/docs/specs/M4-transporteurs/prompts/WT8-prix.md b/docs/specs/M4-transporteurs/prompts/WT8-prix.md new file mode 100644 index 0000000..b2d5ec2 --- /dev/null +++ b/docs/specs/M4-transporteurs/prompts/WT8-prix.md @@ -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. diff --git a/docs/specs/M4-transporteurs/prompts/WT9-export.md b/docs/specs/M4-transporteurs/prompts/WT9-export.md new file mode 100644 index 0000000..0853323 --- /dev/null +++ b/docs/specs/M4-transporteurs/prompts/WT9-export.md @@ -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. diff --git a/docs/specs/M4-transporteurs/spec-back.md b/docs/specs/M4-transporteurs/spec-back.md new file mode 100644 index 0000000..6a251a5 --- /dev/null +++ b/docs/specs/M4-transporteurs/spec-back.md @@ -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 + ['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 */ + #[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[Groups(['carrier:item:read'])] + private Collection $addresses; + + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[Groups(['carrier:item:read'])] + private Collection $contacts; + + /** @var Collection */ + #[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=` (filtre ; répétable) + - `search=` (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=` — 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. diff --git a/docs/specs/M4-transporteurs/spec-front.md b/docs/specs/M4-transporteurs/spec-front.md new file mode 100644 index 0000000..6ea9e3d --- /dev/null +++ b/docs/specs/M4-transporteurs/spec-front.md @@ -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) | `` | `?search=` | +| **Certification** | `` (QUALIMAT / GMP+ / OVOCOM / Compte-propre / Autre) | `?certificationType=` | +| **Inclure les archivés** | `` | `?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 : `` branché sur `usePaginatedList({ 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) | `` (autocomplete) | Oui | RG-4.01 ; RG-4.13 (UPPERCASE serveur) ; RG-4.12 (unicité) | +| **Liste certification transport** | `` (GMP+ / OVOCOM / Compte-propre / Autre) | Oui | RG-4.02 ; auto = `QUALIMAT` (lecture seule) si transporteur QUALIMAT sélectionné | +| **Affréter** | `` | Non | RG-4.03 | +| **Indexation %** | `` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché | +| **Benne / Fond mouvant** | `` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché | +| **Volume m³** | `` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché | +| **Décharge** | `` *(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** | `` (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 ``** : 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 » (``) → 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** | `` (préremplie « France ») | Conditionnel | RG-4.05 | +| **Code postal** | `` (saisie assistée) | Conditionnel | RG-4.06, RG-4.05 — déclenche autocomplete ville (BAN) | +| **Ville** | `` (saisie assistée) | Conditionnel | RG-4.06, RG-4.05 — alimentée par api-adresse.data.gouv.fr | +| **Adresse** | `` (saisie assistée) | Conditionnel | RG-4.05 | +| **Adresse complémentaire** | `` | 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** | `` | Non | RG-4.08 + RG-4.13 (Capitalize) | +| **Prénom** | `` | Non | RG-4.08 + RG-4.13 (Capitalize) | +| **Fonction** | `` | Non | RG-4.08 | +| **Téléphone** (x1, +1 possible, **max 2**) | `` | Non | RG-4.08 + RG-4.13 (format) | +| **Email** | `` 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** | `` | Oui | RG-4.09 | +| **Client** | `` (liste des clients) | Conditionnel | RG-4.10 — si Client | +| **Adresse de livraison** | `` (adresses du client sélectionné) | Conditionnel | RG-4.10 — si Client | +| **Adresse de départ** | `` (86 / 17 / 82) | Conditionnel | RG-4.10 — si Client ; = un des 3 sites | +| **Fournisseur** | `` (liste des fournisseurs) | Conditionnel | RG-4.11 — si Fournisseur | +| **Adresse d'approvisionnement** | `` (adresses du fournisseur) | Conditionnel | RG-4.11 — si Fournisseur | +| **Adresse de livraison** | `` (86 / 17 / 82) | Conditionnel | RG-4.11 — si Fournisseur ; = un des 3 sites | +| **Benne / Fond mouvant (FM)** | `` | Oui | — | +| **Forfait / Tonne** | `` | Oui | — | +| **Prix** | `` (monnaie) | Oui | — | +| **État du prix** | `` (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** : `` (+ `usePaginatedList`) +- **Input texte** : `` +- **Input nombre / montant** : `` (indexation, volume), `` (prix) +- **Select simple** : `` (certification, pays, ville, client, fournisseur, adresses, sites, état du prix) +- **Select multi (cases à cocher)** : `` (filtres certification) +- **Radio** : `` (Benne/Fond mouvant, Forfait/Tonne, Client/Fournisseur) +- **Checkbox** : `` (Affréter, inclure archivés) +- **Upload** : `` (Décharge — exception documentée si type non couvert) +- **Bouton** : ``, `` +- **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). +- `` si le type fichier / drag & drop n'est pas couvert. + +## Composables & appels API + +- `usePaginatedList({ 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 `` 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 | diff --git a/docs/specs/M4-transporteurs/tickets-lesstime.md b/docs/specs/M4-transporteurs/tickets-lesstime.md new file mode 100644 index 0000000..ac7079b --- /dev/null +++ b/docs/specs/M4-transporteurs/tickets-lesstime.md @@ -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** : +- [ ] `` + `usePaginatedList({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`. +- [ ] `` (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. diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index fab417d..d072ae0 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -552,7 +552,11 @@ "technique_provider": "Prestataire", "technique_provideraddress": "Adresse prestataire", "technique_providercontact": "Contact prestataire", - "technique_providerrib": "RIB prestataire" + "technique_providerrib": "RIB prestataire", + "transport_carrier": "Transporteur", + "transport_carrieraddress": "Adresse transporteur", + "transport_carriercontact": "Contact transporteur", + "transport_carrierprice": "Prix transporteur" }, "empty": "Aucune activité enregistrée", "no_results": "Aucun résultat pour ces filtres", diff --git a/makefile b/makefile index 6109908..e6e43f1 100644 --- a/makefile +++ b/makefile @@ -232,6 +232,7 @@ test-db-setup: $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_provider_company_name_active ON provider (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" + $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_carrier_name_active ON carrier (LOWER(name)) WHERE is_archived = FALSE AND deleted_at IS NULL" fixtures: $(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load diff --git a/migrations/Version20260615150000.php b/migrations/Version20260615150000.php new file mode 100644 index 0000000..9ecfaa2 --- /dev/null +++ b/migrations/Version20260615150000.php @@ -0,0 +1,356 @@ +string de l'ORM). Seule + * carrier.qualimat_carrier_id est BIGINT pour matcher qualimat_carrier.id (existant). + * Horodatages `TIMESTAMP(0) WITHOUT TIME ZONE` (le TimestampableBlamableTrait mappe + * `datetime_immutable`), pour que `schema:update --force` reste un no-op. + * + * Chaque colonne porte son `COMMENT ON COLUMN` (regle ABSOLUE n°12). Les 4 + * tables carrier* etant mappees par l'ORM des ce ticket, elles sont aussi ajoutees + * a ColumnCommentsCatalog : `app:apply-column-comments` (test-db-setup) rejoue ces + * COMMENT apres le `schema:update --force` qui les droperait sinon. + */ +final class Version20260615150000 extends AbstractMigration +{ + public function getDescription(): string + { + return 'ERP-155/157 (M4) : tables carrier + carrier_address + carrier_contact + carrier_price (repertoire transporteurs).'; + } + + public function up(Schema $schema): void + { + $this->createCarrierTable(); + $this->createCarrierAddress(); + $this->createCarrierContact(); + $this->createCarrierPrice(); + } + + public function down(Schema $schema): void + { + // Ordre inverse des dependances FK : sous-collections d'abord, puis carrier. + $this->addSql('DROP TABLE IF EXISTS carrier_price'); + $this->addSql('DROP TABLE IF EXISTS carrier_contact'); + $this->addSql('DROP TABLE IF EXISTS carrier_address'); + $this->addSql('DROP TABLE IF EXISTS carrier'); + } + + // ================================================================= + // Table principale `carrier` + // ================================================================= + + private function createCarrierTable(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE carrier ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + qualimat_carrier_id BIGINT DEFAULT NULL, + name VARCHAR(255) NOT NULL, + certification_type VARCHAR(20) DEFAULT NULL, + is_chartered BOOLEAN DEFAULT FALSE NOT NULL, + indexation_rate NUMERIC(5, 2) DEFAULT NULL, + container_type VARCHAR(12) DEFAULT NULL, + volume_m3 NUMERIC(10, 2) DEFAULT NULL, + discharge_document_id INT DEFAULT NULL, + liot_plates TEXT DEFAULT NULL, + is_archived BOOLEAN DEFAULT FALSE NOT NULL, + archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT DEFAULT NULL, + updated_by INT DEFAULT NULL, + PRIMARY KEY (id), + 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')), + CONSTRAINT fk_carrier_qualimat + FOREIGN KEY (qualimat_carrier_id) REFERENCES qualimat_carrier (id) ON DELETE SET NULL, + CONSTRAINT fk_carrier_discharge_document + FOREIGN KEY (discharge_document_id) REFERENCES uploaded_document (id) ON DELETE SET NULL, + CONSTRAINT fk_carrier_created_by + FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, + CONSTRAINT fk_carrier_updated_by + FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL + ) + SQL); + + $this->addSql('CREATE INDEX idx_carrier_is_archived ON carrier (is_archived)'); + $this->addSql('CREATE INDEX idx_carrier_deleted_at ON carrier (deleted_at)'); + $this->addSql('CREATE INDEX idx_carrier_qualimat ON carrier (qualimat_carrier_id)'); + $this->addSql('CREATE INDEX idx_carrier_discharge_document ON carrier (discharge_document_id)'); + $this->addSql('CREATE INDEX idx_carrier_created_by ON carrier (created_by)'); + $this->addSql('CREATE INDEX idx_carrier_updated_by ON carrier (updated_by)'); + + // Unicite metier partielle : nom insensible a la casse, parmi les + // non-archives ET non soft-deletes uniquement (§ 2.6). Inexprimable en ORM. + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX uq_carrier_name_active + ON carrier (LOWER(name)) + WHERE is_archived = FALSE AND deleted_at IS NULL + SQL); + + $this->comment('carrier', '_table', 'Repertoire transporteurs (M4 Transport) — entites editables, archivables (is_archived) et soft-deletables (deleted_at). Distinct du referentiel qualimat_carrier.'); + $this->comment('carrier', 'id', 'Identifiant interne auto-incremente.'); + $this->comment('carrier', 'qualimat_carrier_id', 'Lien editable vers le referentiel QUALIMAT (saisie assistee RG-4.01). FK -> qualimat_carrier.id, ON DELETE SET NULL : transporteur conserve si la ligne QUALIMAT disparait.'); + $this->comment('carrier', 'name', 'Raison sociale du transporteur (stockee en MAJUSCULES). Unique case-insensitive parmi les non-archives/non-supprimes (uq_carrier_name_active, RG-4.12 / § 2.6).'); + $this->comment('carrier', 'certification_type', 'Type de certification : QUALIMAT (si lie, lecture seule) ou GMP_PLUS/OVOCOM/COMPTE_PROPRE/AUTRE. AUTRE declenche le champ Decharge (RG-4.02). Null en cas LIOT (RG-4.01).'); + $this->comment('carrier', 'is_chartered', '« Affreter » coche : declenche indexation/benne-fond mouvant/volume, obligatoires (RG-4.03). Faux par defaut.'); + $this->comment('carrier', 'indexation_rate', 'Taux d indexation en pourcentage (NUMERIC 5,2) — renseigne si affrete (RG-4.03).'); + $this->comment('carrier', 'container_type', 'Type de contenant BENNE|FOND_MOUVANT (chk_carrier_container_type) — renseigne si affrete (RG-4.03).'); + $this->comment('carrier', 'volume_m3', 'Volume en m3 (NUMERIC 10,2) — renseigne si affrete (RG-4.03).'); + $this->comment('carrier', 'discharge_document_id', 'Document de Decharge (visible si certification_type = AUTRE, RG-4.02). FK -> uploaded_document.id (infra Shared § 2.7), ON DELETE SET NULL.'); + $this->comment('carrier', 'liot_plates', 'Immatriculations LIOT separees par « ; » (cas special nom=LIOT, RG-4.01). Les autres champs sont masques dans ce cas.'); + $this->comment('carrier', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission transport.carriers.archive (Admin seul).'); + $this->comment('carrier', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.'); + $this->comment('carrier', 'deleted_at', 'Horodatage du soft-delete technique — non expose par l API au M4. Null = ligne active.'); + $this->addTimestampableBlamableComments('carrier'); + } + + // ================================================================= + // Sous-collection : adresses (1:n) + // ================================================================= + + private function createCarrierAddress(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE carrier_address ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + carrier_id INT NOT NULL, + country VARCHAR(80) DEFAULT 'France' NOT NULL, + postal_code VARCHAR(20) DEFAULT NULL, + city VARCHAR(120) DEFAULT NULL, + street VARCHAR(255) DEFAULT NULL, + street_complement VARCHAR(255) DEFAULT NULL, + position INT DEFAULT 0 NOT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT DEFAULT NULL, + updated_by INT DEFAULT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_carrier_address_carrier + FOREIGN KEY (carrier_id) REFERENCES carrier (id) ON DELETE CASCADE, + CONSTRAINT fk_carrier_address_created_by + FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, + CONSTRAINT fk_carrier_address_updated_by + FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL + ) + SQL); + $this->addSql('CREATE INDEX idx_carrier_address_carrier ON carrier_address (carrier_id)'); + $this->addSql('CREATE INDEX idx_carrier_address_created_by ON carrier_address (created_by)'); + $this->addSql('CREATE INDEX idx_carrier_address_updated_by ON carrier_address (updated_by)'); + + $this->comment('carrier_address', '_table', 'Adresses d un transporteur (1:n) — onglet Adresse (M4). Pre-remplie depuis QUALIMAT si applicable (RG-4.05).'); + $this->comment('carrier_address', 'id', 'Identifiant interne auto-incremente.'); + $this->comment('carrier_address', 'carrier_id', 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire de l adresse.'); + $this->comment('carrier_address', 'country', 'Pays de l adresse — defaut France.'); + $this->comment('carrier_address', 'postal_code', 'Code postal (saisie assistee BAN cote front, RG-4.06).'); + $this->comment('carrier_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front.'); + $this->comment('carrier_address', 'street', 'Numero et voie de l adresse.'); + $this->comment('carrier_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.'); + $this->comment('carrier_address', 'position', 'Ordre d affichage de l adresse dans la liste du transporteur (croissant).'); + $this->addTimestampableBlamableComments('carrier_address'); + } + + // ================================================================= + // Sous-collection : contacts (1:n) + // ================================================================= + + private function createCarrierContact(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE carrier_contact ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + carrier_id INT NOT NULL, + first_name VARCHAR(120) DEFAULT NULL, + last_name VARCHAR(120) DEFAULT NULL, + job_title VARCHAR(120) DEFAULT NULL, + phone_primary VARCHAR(20) DEFAULT NULL, + phone_secondary VARCHAR(20) DEFAULT NULL, + email VARCHAR(180) DEFAULT NULL, + position INT DEFAULT 0 NOT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT DEFAULT NULL, + updated_by INT DEFAULT NULL, + PRIMARY KEY (id), + 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), + CONSTRAINT fk_carrier_contact_carrier + FOREIGN KEY (carrier_id) REFERENCES carrier (id) ON DELETE CASCADE, + CONSTRAINT fk_carrier_contact_created_by + FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, + CONSTRAINT fk_carrier_contact_updated_by + FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL + ) + SQL); + $this->addSql('CREATE INDEX idx_carrier_contact_carrier ON carrier_contact (carrier_id)'); + $this->addSql('CREATE INDEX idx_carrier_contact_created_by ON carrier_contact (created_by)'); + $this->addSql('CREATE INDEX idx_carrier_contact_updated_by ON carrier_contact (updated_by)'); + + $this->comment('carrier_contact', '_table', 'Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins un champ rempli (RG-4.08, chk_carrier_contact_filled), max 2 telephones.'); + $this->comment('carrier_contact', 'id', 'Identifiant interne auto-incremente.'); + $this->comment('carrier_contact', 'carrier_id', 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du contact.'); + $this->comment('carrier_contact', 'first_name', 'Prenom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).'); + $this->comment('carrier_contact', 'last_name', 'Nom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).'); + $this->comment('carrier_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).'); + $this->comment('carrier_contact', 'phone_primary', 'Telephone principal — chiffres uniquement (normalisation serveur).'); + $this->comment('carrier_contact', 'phone_secondary', 'Telephone secondaire — chiffres uniquement (max 2 telephones, RG-4.08).'); + $this->comment('carrier_contact', 'email', 'Email du contact (lowercase serveur).'); + $this->comment('carrier_contact', 'position', 'Ordre d affichage du contact dans la liste du transporteur (croissant).'); + $this->addTimestampableBlamableComments('carrier_contact'); + } + + // ================================================================= + // Sous-collection : prix (1:n) — onglet Prix (RG-4.09 -> RG-4.11) + // ================================================================= + + private function createCarrierPrice(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE carrier_price ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + carrier_id INT NOT NULL, + direction VARCHAR(12) NOT NULL, + client_id INT DEFAULT NULL, + client_delivery_address_id INT DEFAULT NULL, + departure_site_id INT DEFAULT NULL, + supplier_id INT DEFAULT NULL, + supplier_supply_address_id INT DEFAULT NULL, + delivery_site_id INT DEFAULT NULL, + container_type VARCHAR(12) NOT NULL, + pricing_unit VARCHAR(8) NOT NULL, + price NUMERIC(12, 2) NOT NULL, + price_state VARCHAR(12) NOT NULL, + position INT DEFAULT 0 NOT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT DEFAULT NULL, + updated_by INT DEFAULT NULL, + PRIMARY KEY (id), + 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')), + CONSTRAINT chk_carrier_price_client_branch + CHECK (direction <> 'CLIENT' OR (client_id IS NOT NULL AND supplier_id IS NULL)), + CONSTRAINT chk_carrier_price_supplier_branch + CHECK (direction <> 'FOURNISSEUR' OR (supplier_id IS NOT NULL AND client_id IS NULL)), + CONSTRAINT fk_carrier_price_carrier + FOREIGN KEY (carrier_id) REFERENCES carrier (id) ON DELETE CASCADE, + CONSTRAINT fk_carrier_price_client + FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE RESTRICT, + CONSTRAINT fk_carrier_price_client_address + FOREIGN KEY (client_delivery_address_id) REFERENCES client_address (id) ON DELETE RESTRICT, + CONSTRAINT fk_carrier_price_departure_site + FOREIGN KEY (departure_site_id) REFERENCES site (id) ON DELETE RESTRICT, + CONSTRAINT fk_carrier_price_supplier + FOREIGN KEY (supplier_id) REFERENCES supplier (id) ON DELETE RESTRICT, + CONSTRAINT fk_carrier_price_supplier_address + FOREIGN KEY (supplier_supply_address_id) REFERENCES supplier_address (id) ON DELETE RESTRICT, + CONSTRAINT fk_carrier_price_delivery_site + FOREIGN KEY (delivery_site_id) REFERENCES site (id) ON DELETE RESTRICT, + CONSTRAINT fk_carrier_price_created_by + FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, + CONSTRAINT fk_carrier_price_updated_by + FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL + ) + SQL); + $this->addSql('CREATE INDEX idx_carrier_price_carrier ON carrier_price (carrier_id)'); + $this->addSql('CREATE INDEX idx_carrier_price_client ON carrier_price (client_id)'); + $this->addSql('CREATE INDEX idx_carrier_price_client_address ON carrier_price (client_delivery_address_id)'); + $this->addSql('CREATE INDEX idx_carrier_price_departure_site ON carrier_price (departure_site_id)'); + $this->addSql('CREATE INDEX idx_carrier_price_supplier ON carrier_price (supplier_id)'); + $this->addSql('CREATE INDEX idx_carrier_price_supplier_address ON carrier_price (supplier_supply_address_id)'); + $this->addSql('CREATE INDEX idx_carrier_price_delivery_site ON carrier_price (delivery_site_id)'); + $this->addSql('CREATE INDEX idx_carrier_price_created_by ON carrier_price (created_by)'); + $this->addSql('CREATE INDEX idx_carrier_price_updated_by ON carrier_price (updated_by)'); + + $this->comment('carrier_price', '_table', 'Prix d un transporteur (1:n) — onglet Prix (M4). Branche CLIENT ou FOURNISSEUR selon direction (RG-4.09→4.11, CHECK chk_carrier_price_*).'); + $this->comment('carrier_price', 'id', 'Identifiant interne auto-incremente.'); + $this->comment('carrier_price', 'carrier_id', 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du prix.'); + $this->comment('carrier_price', 'direction', '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->comment('carrier_price', 'client_id', 'Branche CLIENT (RG-4.10) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi direction = CLIENT.'); + $this->comment('carrier_price', 'client_delivery_address_id', 'Branche CLIENT : adresse de livraison du client. FK -> client_address.id, ON DELETE RESTRICT.'); + $this->comment('carrier_price', 'departure_site_id', 'Branche CLIENT : adresse de depart = un des 3 sites (86/17/82). FK -> site.id, ON DELETE RESTRICT.'); + $this->comment('carrier_price', 'supplier_id', 'Branche FOURNISSEUR (RG-4.11) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi direction = FOURNISSEUR.'); + $this->comment('carrier_price', 'supplier_supply_address_id', 'Branche FOURNISSEUR : adresse d approvisionnement du fournisseur. FK -> supplier_address.id, ON DELETE RESTRICT.'); + $this->comment('carrier_price', 'delivery_site_id', 'Branche FOURNISSEUR : adresse de livraison = un des 3 sites (86/17/82). FK -> site.id, ON DELETE RESTRICT.'); + $this->comment('carrier_price', 'container_type', 'Type de contenant BENNE|FOND_MOUVANT (chk_carrier_price_container).'); + $this->comment('carrier_price', 'pricing_unit', 'Unite de tarification FORFAIT|TONNE (chk_carrier_price_unit).'); + $this->comment('carrier_price', 'price', 'Montant du prix (NUMERIC 12,2).'); + $this->comment('carrier_price', 'price_state', 'Etat du prix : EN_COURS, VALIDE ou NON_VALIDE (chk_carrier_price_state). Affiche dans le tableau Prix.'); + $this->comment('carrier_price', 'position', 'Ordre d affichage du prix dans la liste du transporteur (croissant).'); + $this->addTimestampableBlamableComments('carrier_price'); + } + + // ================================================================= + // Helpers (identiques au M2 Version20260605130000) + // ================================================================= + + /** + * Pose les 4 commentaires standardises Timestampable/Blamable sur une table, + * en reutilisant le catalogue partage (source unique, ERP-67). + */ + private function addTimestampableBlamableComments(string $table): void + { + foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) { + $this->comment($table, $column, $description); + } + } + + /** + * Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou `COMMENT ON COLUMN` + * en dollar-quoting Postgres ($_$...$_$) pour eviter tout echappement d apostrophe. + */ + private function comment(string $table, string $column, string $description): void + { + $quotedTable = '"'.str_replace('"', '""', $table).'"'; + + if ('_table' === $column) { + $this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description)); + + return; + } + + $this->addSql(sprintf( + 'COMMENT ON COLUMN %s.%s IS $_$%s$_$', + $quotedTable, + '"'.str_replace('"', '""', $column).'"', + $description, + )); + } +} diff --git a/src/Module/Commercial/Domain/Entity/Client.php b/src/Module/Commercial/Domain/Entity/Client.php index f3f2c5c..d0e9eb4 100644 --- a/src/Module/Commercial/Domain/Entity/Client.php +++ b/src/Module/Commercial/Domain/Entity/Client.php @@ -15,6 +15,7 @@ use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\CategoryInterface; +use App\Shared\Domain\Contract\ClientInterface; use App\Shared\Domain\Contract\SiteInterface; use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Trait\TimestampableBlamableTrait; @@ -147,7 +148,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; #[ORM\Index(name: 'idx_client_created_by', columns: ['created_by'])] #[ORM\Index(name: 'idx_client_updated_by', columns: ['updated_by'])] #[Auditable] -class Client implements TimestampableInterface, BlamableInterface +class Client implements TimestampableInterface, BlamableInterface, ClientInterface { use TimestampableBlamableTrait; diff --git a/src/Module/Commercial/Domain/Entity/ClientAddress.php b/src/Module/Commercial/Domain/Entity/ClientAddress.php index 98d0a45..c5d9802 100644 --- a/src/Module/Commercial/Domain/Entity/ClientAddress.php +++ b/src/Module/Commercial/Domain/Entity/ClientAddress.php @@ -15,6 +15,7 @@ use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientAddressRepositor use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\CategoryInterface; +use App\Shared\Domain\Contract\ClientAddressInterface; use App\Shared\Domain\Contract\SiteInterface; use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Trait\TimestampableBlamableTrait; @@ -89,7 +90,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; #[ORM\Table(name: 'client_address')] #[ORM\Index(name: 'idx_client_address_client', columns: ['client_id'])] #[Auditable] -class ClientAddress implements TimestampableInterface, BlamableInterface +class ClientAddress implements TimestampableInterface, BlamableInterface, ClientAddressInterface { use TimestampableBlamableTrait; diff --git a/src/Module/Commercial/Domain/Entity/Supplier.php b/src/Module/Commercial/Domain/Entity/Supplier.php index c182e62..fbd9c4d 100644 --- a/src/Module/Commercial/Domain/Entity/Supplier.php +++ b/src/Module/Commercial/Domain/Entity/Supplier.php @@ -16,6 +16,7 @@ use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\CategoryInterface; use App\Shared\Domain\Contract\SiteInterface; +use App\Shared\Domain\Contract\SupplierInterface; use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Trait\TimestampableBlamableTrait; use DateTimeImmutable; @@ -142,7 +143,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; #[ORM\Index(name: 'idx_supplier_created_by', columns: ['created_by'])] #[ORM\Index(name: 'idx_supplier_updated_by', columns: ['updated_by'])] #[Auditable] -class Supplier implements TimestampableInterface, BlamableInterface +class Supplier implements TimestampableInterface, BlamableInterface, SupplierInterface { use TimestampableBlamableTrait; diff --git a/src/Module/Commercial/Domain/Entity/SupplierAddress.php b/src/Module/Commercial/Domain/Entity/SupplierAddress.php index 94a3aaf..d19efde 100644 --- a/src/Module/Commercial/Domain/Entity/SupplierAddress.php +++ b/src/Module/Commercial/Domain/Entity/SupplierAddress.php @@ -16,6 +16,7 @@ use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\CategoryInterface; use App\Shared\Domain\Contract\SiteInterface; +use App\Shared\Domain\Contract\SupplierAddressInterface; use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Trait\TimestampableBlamableTrait; use Doctrine\Common\Collections\ArrayCollection; @@ -96,7 +97,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; #[ORM\Table(name: 'supplier_address')] #[ORM\Index(name: 'idx_supplier_address_supplier', columns: ['supplier_id'])] #[Auditable] -class SupplierAddress implements TimestampableInterface, BlamableInterface +class SupplierAddress implements TimestampableInterface, BlamableInterface, SupplierAddressInterface { use TimestampableBlamableTrait; @@ -117,7 +118,11 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['supplier:item:read'])] + // supplier_address:read : groupe additif consomme par l'embed cross-module + // (CarrierPrice.supplierSupplyAddress, M4 § 3.4). Inerte pour M2 (ses contextes + // ne l'incluent pas) — expose le libelle d'adresse quand un autre module embarque + // une SupplierAddress. + #[Groups(['supplier:item:read', 'supplier_address:read'])] private ?int $id = null; #[ORM\ManyToOne(targetEntity: Supplier::class, inversedBy: 'addresses')] @@ -130,12 +135,12 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface #[ORM\Column(length: 20)] #[Assert\NotBlank(message: 'Le type d\'adresse est obligatoire.', normalizer: 'trim')] #[Assert\Choice(choices: self::ADDRESS_TYPES, message: 'Le type d\'adresse doit être Prospect, Départ ou Rendu.')] - #[Groups(['supplier:item:read', 'supplier:write:addresses'])] + #[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])] private ?string $addressType = null; #[ORM\Column(length: 80, options: ['default' => 'France'])] #[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] - #[Groups(['supplier:item:read', 'supplier:write:addresses'])] + #[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])] private string $country = 'France'; // RG-2.05 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur). @@ -143,24 +148,24 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface #[ORM\Column(length: 20)] #[Assert\NotBlank(message: 'Le code postal est obligatoire.', normalizer: 'trim')] #[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')] - #[Groups(['supplier:item:read', 'supplier:write:addresses'])] + #[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])] private ?string $postalCode = null; #[ORM\Column(length: 120)] #[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] - #[Groups(['supplier:item:read', 'supplier:write:addresses'])] + #[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])] private ?string $city = null; #[ORM\Column(length: 255)] #[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] - #[Groups(['supplier:item:read', 'supplier:write:addresses'])] + #[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])] private ?string $street = null; #[ORM\Column(length: 255, nullable: true)] #[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] - #[Groups(['supplier:item:read', 'supplier:write:addresses'])] + #[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])] private ?string $streetComplement = null; // Specifique fournisseur : nombre de bennes sur le site. diff --git a/src/Module/Transport/Domain/Entity/Carrier.php b/src/Module/Transport/Domain/Entity/Carrier.php new file mode 100644 index 0000000..6d10014 --- /dev/null +++ b/src/Module/Transport/Domain/Entity/Carrier.php @@ -0,0 +1,412 @@ + ['carrier:read', 'qualimat:read', 'default:read']], + provider: CarrierProvider::class, + ), + new Get( + security: "is_granted('transport.carriers.view')", + // Detail : transporteur + qualimatCarrier + sous-collections embarquees + // (addresses / contacts / prices). Les relations cross-module des prix + // (client / supplier / sites / adresses) sont embarquees via leurs + // read-groups (client:read / supplier:read / ... — bugs #1/#2 M1). + 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, + ), + // Pas de Post/Patch/Delete au WT3 (lecture seule). Ecriture + archivage : WT4. + ], +)] +#[ORM\Entity(repositoryClass: DoctrineCarrierRepository::class)] +#[ORM\Table(name: 'carrier')] +#[ORM\Index(name: 'idx_carrier_is_archived', columns: ['is_archived'])] +#[ORM\Index(name: 'idx_carrier_deleted_at', columns: ['deleted_at'])] +#[ORM\Index(name: 'idx_carrier_qualimat', columns: ['qualimat_carrier_id'])] +#[ORM\Index(name: 'idx_carrier_discharge_document', columns: ['discharge_document_id'])] +#[ORM\Index(name: 'idx_carrier_created_by', columns: ['created_by'])] +#[ORM\Index(name: 'idx_carrier_updated_by', columns: ['updated_by'])] +#[Auditable] +class Carrier implements TimestampableInterface, BlamableInterface +{ + use TimestampableBlamableTrait; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['carrier:read'])] + private ?int $id = null; + + #[ORM\Column(length: 255)] + #[Groups(['carrier:read'])] + private ?string $name = null; + + /** Lien editable vers le referentiel QUALIMAT (saisie assistee RG-4.01, § 2.5). */ + #[ORM\ManyToOne(targetEntity: QualimatCarrier::class)] + #[ORM\JoinColumn(name: 'qualimat_carrier_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + #[Groups(['carrier:read'])] + private ?QualimatCarrier $qualimatCarrier = null; + + /** QUALIMAT|GMP_PLUS|OVOCOM|COMPTE_PROPRE|AUTRE ; null en cas LIOT (RG-4.01). */ + #[ORM\Column(length: 20, nullable: true)] + #[Groups(['carrier:read'])] + private ?string $certificationType = null; + + #[ORM\Column(name: 'is_chartered', options: ['default' => false])] + private bool $isChartered = false; + + /** % d'indexation — renseigne si affrete (RG-4.03). */ + #[ORM\Column(name: 'indexation_rate', type: 'decimal', precision: 5, scale: 2, nullable: true)] + #[Groups(['carrier:read'])] + private ?string $indexationRate = null; + + /** BENNE|FOND_MOUVANT — renseigne si affrete (RG-4.03). */ + #[ORM\Column(name: 'container_type', length: 12, nullable: true)] + #[Groups(['carrier:read'])] + private ?string $containerType = null; + + /** Volume m3 — renseigne si affrete (RG-4.03). */ + #[ORM\Column(name: 'volume_m3', type: 'decimal', precision: 10, scale: 2, nullable: true)] + #[Groups(['carrier:read'])] + private ?string $volumeM3 = null; + + /** Decharge (upload, visible si certificationType = AUTRE — RG-4.02). Infra Shared (§ 2.7). */ + #[ORM\ManyToOne(targetEntity: UploadedDocument::class)] + #[ORM\JoinColumn(name: 'discharge_document_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + #[Groups(['carrier:read'])] + private ?UploadedDocument $dischargeDocument = null; + + /** Immatriculations LIOT separees par « ; » (cas LIOT — RG-4.01). */ + #[ORM\Column(name: 'liot_plates', type: 'text', nullable: true)] + #[Groups(['carrier:read'])] + private ?string $liotPlates = null; + + // === Sous-collections — EMBARQUEES dans le DETAIL (read-group sur le getter) === + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $addresses; + + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $contacts; + + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierPrice::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $prices; + + // === Archive / Soft delete === + #[ORM\Column(name: 'is_archived', options: ['default' => false])] + private bool $isArchived = false; + + #[ORM\Column(name: 'archived_at', type: 'datetime_immutable', nullable: true)] + #[Groups(['carrier:read'])] + private ?DateTimeImmutable $archivedAt = null; + + #[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)] + private ?DateTimeImmutable $deletedAt = null; + + public function __construct() + { + $this->addresses = new ArrayCollection(); + $this->contacts = new ArrayCollection(); + $this->prices = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getQualimatCarrier(): ?QualimatCarrier + { + return $this->qualimatCarrier; + } + + public function setQualimatCarrier(?QualimatCarrier $qualimatCarrier): static + { + $this->qualimatCarrier = $qualimatCarrier; + + return $this; + } + + public function getCertificationType(): ?string + { + return $this->certificationType; + } + + public function setCertificationType(?string $certificationType): static + { + $this->certificationType = $certificationType; + + return $this; + } + + // Boolean trap (RETEX M1 bug #3) : #[Groups] + #[SerializedName] sur le getter, + // sinon Symfony strip le prefixe "is" et drope la cle du JSON. + #[Groups(['carrier:read'])] + #[SerializedName('isChartered')] + public function isChartered(): bool + { + return $this->isChartered; + } + + public function setIsChartered(bool $isChartered): static + { + $this->isChartered = $isChartered; + + return $this; + } + + public function getIndexationRate(): ?string + { + return $this->indexationRate; + } + + public function setIndexationRate(?string $indexationRate): static + { + $this->indexationRate = $indexationRate; + + return $this; + } + + public function getContainerType(): ?string + { + return $this->containerType; + } + + public function setContainerType(?string $containerType): static + { + $this->containerType = $containerType; + + return $this; + } + + public function getVolumeM3(): ?string + { + return $this->volumeM3; + } + + public function setVolumeM3(?string $volumeM3): static + { + $this->volumeM3 = $volumeM3; + + return $this; + } + + public function getDischargeDocument(): ?UploadedDocument + { + return $this->dischargeDocument; + } + + public function setDischargeDocument(?UploadedDocument $dischargeDocument): static + { + $this->dischargeDocument = $dischargeDocument; + + return $this; + } + + public function getLiotPlates(): ?string + { + return $this->liotPlates; + } + + public function setLiotPlates(?string $liotPlates): static + { + $this->liotPlates = $liotPlates; + + return $this; + } + + /** @return Collection */ + #[Groups(['carrier:item:read'])] + public function getAddresses(): Collection + { + return $this->addresses; + } + + public function addAddress(CarrierAddress $address): static + { + if (!$this->addresses->contains($address)) { + $this->addresses->add($address); + $address->setCarrier($this); + } + + return $this; + } + + public function removeAddress(CarrierAddress $address): static + { + if ($this->addresses->removeElement($address) && $address->getCarrier() === $this) { + $address->setCarrier(null); + } + + return $this; + } + + /** @return Collection */ + #[Groups(['carrier:item:read'])] + public function getContacts(): Collection + { + return $this->contacts; + } + + public function addContact(CarrierContact $contact): static + { + if (!$this->contacts->contains($contact)) { + $this->contacts->add($contact); + $contact->setCarrier($this); + } + + return $this; + } + + public function removeContact(CarrierContact $contact): static + { + if ($this->contacts->removeElement($contact) && $contact->getCarrier() === $this) { + $contact->setCarrier(null); + } + + return $this; + } + + /** @return Collection */ + #[Groups(['carrier:item:read'])] + public function getPrices(): Collection + { + return $this->prices; + } + + public function addPrice(CarrierPrice $price): static + { + if (!$this->prices->contains($price)) { + $this->prices->add($price); + $price->setCarrier($this); + } + + return $this; + } + + public function removePrice(CarrierPrice $price): static + { + if ($this->prices->removeElement($price) && $price->getCarrier() === $this) { + $price->setCarrier(null); + } + + return $this; + } + + // Boolean trap (cf. isChartered) : groupe de lecture + SerializedName sur le getter. + #[Groups(['carrier:read'])] + #[SerializedName('isArchived')] + public function isArchived(): bool + { + return $this->isArchived; + } + + public function setIsArchived(bool $isArchived): static + { + $this->isArchived = $isArchived; + + return $this; + } + + public function getArchivedAt(): ?DateTimeImmutable + { + return $this->archivedAt; + } + + public function setArchivedAt(?DateTimeImmutable $archivedAt): static + { + $this->archivedAt = $archivedAt; + + return $this; + } + + public function getDeletedAt(): ?DateTimeImmutable + { + return $this->deletedAt; + } + + public function setDeletedAt(?DateTimeImmutable $deletedAt): static + { + $this->deletedAt = $deletedAt; + + return $this; + } +} diff --git a/src/Module/Transport/Domain/Entity/CarrierAddress.php b/src/Module/Transport/Domain/Entity/CarrierAddress.php new file mode 100644 index 0000000..fab9fea --- /dev/null +++ b/src/Module/Transport/Domain/Entity/CarrierAddress.php @@ -0,0 +1,154 @@ + 'France'])] + #[Groups(['carrier:item:read'])] + private string $country = 'France'; + + #[ORM\Column(name: 'postal_code', length: 20, nullable: true)] + #[Groups(['carrier:item:read'])] + private ?string $postalCode = null; + + #[ORM\Column(length: 120, nullable: true)] + #[Groups(['carrier:item:read'])] + private ?string $city = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['carrier:item:read'])] + private ?string $street = null; + + #[ORM\Column(name: 'street_complement', length: 255, nullable: true)] + #[Groups(['carrier:item:read'])] + private ?string $streetComplement = null; + + #[ORM\Column(options: ['default' => 0])] + private int $position = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getCarrier(): ?Carrier + { + return $this->carrier; + } + + public function setCarrier(?Carrier $carrier): static + { + $this->carrier = $carrier; + + return $this; + } + + public function getCountry(): string + { + return $this->country; + } + + public function setCountry(string $country): static + { + $this->country = $country; + + return $this; + } + + public function getPostalCode(): ?string + { + return $this->postalCode; + } + + public function setPostalCode(?string $postalCode): static + { + $this->postalCode = $postalCode; + + return $this; + } + + public function getCity(): ?string + { + return $this->city; + } + + public function setCity(?string $city): static + { + $this->city = $city; + + return $this; + } + + public function getStreet(): ?string + { + return $this->street; + } + + public function setStreet(?string $street): static + { + $this->street = $street; + + return $this; + } + + public function getStreetComplement(): ?string + { + return $this->streetComplement; + } + + public function setStreetComplement(?string $streetComplement): static + { + $this->streetComplement = $streetComplement; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } +} diff --git a/src/Module/Transport/Domain/Entity/CarrierContact.php b/src/Module/Transport/Domain/Entity/CarrierContact.php new file mode 100644 index 0000000..1f5e8fb --- /dev/null +++ b/src/Module/Transport/Domain/Entity/CarrierContact.php @@ -0,0 +1,169 @@ + 0])] + private int $position = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getCarrier(): ?Carrier + { + return $this->carrier; + } + + public function setCarrier(?Carrier $carrier): static + { + $this->carrier = $carrier; + + return $this; + } + + public function getFirstName(): ?string + { + return $this->firstName; + } + + public function setFirstName(?string $firstName): static + { + $this->firstName = $firstName; + + return $this; + } + + public function getLastName(): ?string + { + return $this->lastName; + } + + public function setLastName(?string $lastName): static + { + $this->lastName = $lastName; + + return $this; + } + + public function getJobTitle(): ?string + { + return $this->jobTitle; + } + + public function setJobTitle(?string $jobTitle): static + { + $this->jobTitle = $jobTitle; + + return $this; + } + + public function getPhonePrimary(): ?string + { + return $this->phonePrimary; + } + + public function setPhonePrimary(?string $phonePrimary): static + { + $this->phonePrimary = $phonePrimary; + + return $this; + } + + public function getPhoneSecondary(): ?string + { + return $this->phoneSecondary; + } + + public function setPhoneSecondary(?string $phoneSecondary): static + { + $this->phoneSecondary = $phoneSecondary; + + return $this; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(?string $email): static + { + $this->email = $email; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } +} diff --git a/src/Module/Transport/Domain/Entity/CarrierPrice.php b/src/Module/Transport/Domain/Entity/CarrierPrice.php new file mode 100644 index 0000000..90e4be0 --- /dev/null +++ b/src/Module/Transport/Domain/Entity/CarrierPrice.php @@ -0,0 +1,284 @@ + 0])] + private int $position = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getCarrier(): ?Carrier + { + return $this->carrier; + } + + public function setCarrier(?Carrier $carrier): static + { + $this->carrier = $carrier; + + return $this; + } + + public function getDirection(): ?string + { + return $this->direction; + } + + public function setDirection(?string $direction): static + { + $this->direction = $direction; + + return $this; + } + + public function getClient(): ?ClientInterface + { + return $this->client; + } + + public function setClient(?ClientInterface $client): static + { + $this->client = $client; + + return $this; + } + + public function getClientDeliveryAddress(): ?ClientAddressInterface + { + return $this->clientDeliveryAddress; + } + + public function setClientDeliveryAddress(?ClientAddressInterface $clientDeliveryAddress): static + { + $this->clientDeliveryAddress = $clientDeliveryAddress; + + return $this; + } + + public function getDepartureSite(): ?SiteInterface + { + return $this->departureSite; + } + + public function setDepartureSite(?SiteInterface $departureSite): static + { + $this->departureSite = $departureSite; + + return $this; + } + + public function getSupplier(): ?SupplierInterface + { + return $this->supplier; + } + + public function setSupplier(?SupplierInterface $supplier): static + { + $this->supplier = $supplier; + + return $this; + } + + public function getSupplierSupplyAddress(): ?SupplierAddressInterface + { + return $this->supplierSupplyAddress; + } + + public function setSupplierSupplyAddress(?SupplierAddressInterface $supplierSupplyAddress): static + { + $this->supplierSupplyAddress = $supplierSupplyAddress; + + return $this; + } + + public function getDeliverySite(): ?SiteInterface + { + return $this->deliverySite; + } + + public function setDeliverySite(?SiteInterface $deliverySite): static + { + $this->deliverySite = $deliverySite; + + return $this; + } + + public function getContainerType(): ?string + { + return $this->containerType; + } + + public function setContainerType(?string $containerType): static + { + $this->containerType = $containerType; + + return $this; + } + + public function getPricingUnit(): ?string + { + return $this->pricingUnit; + } + + public function setPricingUnit(?string $pricingUnit): static + { + $this->pricingUnit = $pricingUnit; + + return $this; + } + + public function getPrice(): ?string + { + return $this->price; + } + + public function setPrice(?string $price): static + { + $this->price = $price; + + return $this; + } + + public function getPriceState(): ?string + { + return $this->priceState; + } + + public function setPriceState(?string $priceState): static + { + $this->priceState = $priceState; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } +} diff --git a/src/Module/Transport/Domain/Entity/QualimatCarrier.php b/src/Module/Transport/Domain/Entity/QualimatCarrier.php new file mode 100644 index 0000000..b7c471c --- /dev/null +++ b/src/Module/Transport/Domain/Entity/QualimatCarrier.php @@ -0,0 +1,174 @@ + exclue de + * EntitiesAreTimestampableBlamableTest et non #[Auditable]. + */ +#[ApiResource( + operations: [ + new GetCollection( + security: "is_granted('transport.carriers.view')", + normalizationContext: ['groups' => ['qualimat:read', 'default:read']], + ), + new Get( + security: "is_granted('transport.carriers.view')", + normalizationContext: ['groups' => ['qualimat:read', 'default:read']], + ), + ], +)] +#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'siret' => 'partial'])] +#[ApiFilter(BooleanFilter::class, properties: ['isActive'])] +#[ApiFilter(OrderFilter::class, properties: ['name'], arguments: ['orderParameterName' => 'order'])] +#[ORM\Entity] +// Mapping reproduisant a l'identique le DDL de la migration ERP-39 +// (Version20260612150000) pour que `schema:update --force` reste un no-op : +// contrainte d'unicite siret + index is_active. +#[ORM\Table(name: 'qualimat_carrier')] +#[ORM\UniqueConstraint(name: 'uq_qualimat_carrier_siret', columns: ['siret'])] +#[ORM\Index(name: 'idx_qualimat_carrier_active', columns: ['is_active'])] +class QualimatCarrier +{ + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'IDENTITY')] + #[ORM\Column(type: 'bigint')] + #[Groups(['qualimat:read'])] + private ?string $id = null; + + #[ORM\Column(length: 20)] + #[Groups(['qualimat:read'])] + private ?string $siret = null; + + #[ORM\Column(length: 255)] + #[Groups(['qualimat:read'])] + private ?string $name = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['qualimat:read'])] + private ?string $address = null; + + #[ORM\Column(name: 'postal_code', length: 10, nullable: true)] + #[Groups(['qualimat:read'])] + private ?string $postalCode = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['qualimat:read'])] + private ?string $city = null; + + #[ORM\Column(length: 32, nullable: true)] + #[Groups(['qualimat:read'])] + private ?string $phone = null; + + #[ORM\Column(length: 64, nullable: true)] + #[Groups(['qualimat:read'])] + private ?string $department = null; + + #[ORM\Column(length: 32)] + #[Groups(['qualimat:read'])] + private ?string $status = null; + + #[ORM\Column(name: 'validity_date', type: 'date_immutable', nullable: true)] + #[Groups(['qualimat:read'])] + private ?DateTimeImmutable $validityDate = null; + + #[ORM\Column(name: 'is_active', options: ['default' => true])] + #[Groups(['qualimat:read'])] + #[SerializedName('isActive')] + private bool $isActive = true; + + // Colonne technique de synchro (soft-delete) — mappee pour completude, non + // serialisee. Alimentee par app:qualimat:sync. columnDefinition pin la + // precision TIMESTAMP(6) du DDL ERP-39 pour eviter un ALTER de schema:update + // (le datetime_immutable par defaut mapperait sur TIMESTAMP(0)). + #[ORM\Column(name: 'last_synced_at', type: 'datetime_immutable', columnDefinition: 'TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL')] + private ?DateTimeImmutable $lastSyncedAt = null; + + public function getId(): ?string + { + return $this->id; + } + + public function getSiret(): ?string + { + return $this->siret; + } + + public function getName(): ?string + { + return $this->name; + } + + public function getAddress(): ?string + { + return $this->address; + } + + public function getPostalCode(): ?string + { + return $this->postalCode; + } + + public function getCity(): ?string + { + return $this->city; + } + + public function getPhone(): ?string + { + return $this->phone; + } + + public function getDepartment(): ?string + { + return $this->department; + } + + public function getStatus(): ?string + { + return $this->status; + } + + public function getValidityDate(): ?DateTimeImmutable + { + return $this->validityDate; + } + + public function isActive(): bool + { + return $this->isActive; + } + + public function getLastSyncedAt(): ?DateTimeImmutable + { + return $this->lastSyncedAt; + } +} diff --git a/src/Module/Transport/Domain/Repository/CarrierRepositoryInterface.php b/src/Module/Transport/Domain/Repository/CarrierRepositoryInterface.php new file mode 100644 index 0000000..4ff6f88 --- /dev/null +++ b/src/Module/Transport/Domain/Repository/CarrierRepositoryInterface.php @@ -0,0 +1,33 @@ + $certificationTypes filtre repetable (OR) sur certificationType + */ + public function createListQueryBuilder( + bool $includeArchived = false, + ?string $search = null, + array $certificationTypes = [], + ): QueryBuilder; +} diff --git a/src/Module/Transport/Infrastructure/ApiPlatform/State/Provider/CarrierProvider.php b/src/Module/Transport/Infrastructure/ApiPlatform/State/Provider/CarrierProvider.php new file mode 100644 index 0000000..3c2b02f --- /dev/null +++ b/src/Module/Transport/Infrastructure/ApiPlatform/State/Provider/CarrierProvider.php @@ -0,0 +1,137 @@ + + */ +final class CarrierProvider implements ProviderInterface +{ + public function __construct( + #[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository')] + private readonly CarrierRepositoryInterface $repository, + private readonly Pagination $pagination, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): Carrier|iterable|Paginator|null + { + if ($operation instanceof CollectionOperationInterface) { + return $this->provideCollection($operation, $context); + } + + return $this->provideItem($uriVariables); + } + + /** + * @param array $context + * + * @return list|Paginator + */ + private function provideCollection(Operation $operation, array $context): array|Paginator + { + $filters = $context['filters'] ?? []; + $includeArchived = $this->readBool($filters['includeArchived'] ?? false); + $search = $filters['search'] ?? null; + $certificationTypes = $this->readStringList($filters['certificationType'] ?? []); + + $qb = $this->repository->createListQueryBuilder( + $includeArchived, + is_string($search) ? $search : null, + $certificationTypes, + ); + + // Echappatoire ?pagination=false : collection complete (selects front). + if (!$this->pagination->isEnabled($operation, $context)) { + /** @var list $carriers */ + return $qb->getQuery()->getResult(); + } + + $limit = $this->pagination->getLimit($operation, $context); + $page = max(1, $this->pagination->getPage($context)); + $offset = ($page - 1) * $limit; + + $qb->setFirstResult($offset)->setMaxResults($limit); + + // fetchJoinCollection: false — la seule jointure est un ManyToOne (sur), + // pas une to-many : pas de besoin du mode collection du Paginator. + return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false)); + } + + /** + * @param array $uriVariables + */ + private function provideItem(array $uriVariables): ?Carrier + { + $id = $uriVariables['id'] ?? null; + if (!is_int($id) && !(is_string($id) && ctype_digit($id))) { + return null; + } + + $carrier = $this->repository->findById((int) $id); + if (null === $carrier) { + return null; + } + + // Soft-delete : jamais expose (404). Les archives restent consultables. + if (null !== $carrier->getDeletedAt()) { + return null; + } + + return $carrier; + } + + private function readBool(mixed $raw): bool + { + if (is_bool($raw)) { + return $raw; + } + + return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true); + } + + /** + * Normalise un filtre en liste de chaines (valeur unique ou ?key[]=a&key[]=b). + * + * @return list + */ + private function readStringList(mixed $raw): array + { + $values = is_array($raw) ? $raw : [$raw]; + + $out = []; + foreach ($values as $value) { + if (is_string($value) && '' !== trim($value)) { + $out[] = trim($value); + } + } + + return $out; + } +} diff --git a/src/Module/Transport/Infrastructure/DataFixtures/CarrierFixtures.php b/src/Module/Transport/Infrastructure/DataFixtures/CarrierFixtures.php new file mode 100644 index 0000000..4ffaed0 --- /dev/null +++ b/src/Module/Transport/Infrastructure/DataFixtures/CarrierFixtures.php @@ -0,0 +1,51 @@ +setName('TRANSPORTS ALPHA'); + $alpha->setCertificationType('GMP_PLUS'); + $manager->persist($alpha); + + $contact = new CarrierContact(); + $contact->setCarrier($alpha); + $contact->setLastName('Durand'); + $contact->setPhonePrimary('0612345678'); + $alpha->addContact($contact); + $manager->persist($contact); + + // Transporteur affrete (RG-4.03). + $beta = new Carrier(); + $beta->setName('TRANSPORTS BETA'); + $beta->setCertificationType('AUTRE'); + $beta->setIsChartered(true); + $beta->setIndexationRate('5.00'); + $beta->setContainerType('BENNE'); + $beta->setVolumeM3('90.00'); + $manager->persist($beta); + + $manager->flush(); + } +} diff --git a/src/Module/Transport/Infrastructure/Doctrine/DoctrineCarrierRepository.php b/src/Module/Transport/Infrastructure/Doctrine/DoctrineCarrierRepository.php new file mode 100644 index 0000000..bda439a --- /dev/null +++ b/src/Module/Transport/Infrastructure/Doctrine/DoctrineCarrierRepository.php @@ -0,0 +1,101 @@ + + */ +class DoctrineCarrierRepository extends ServiceEntityRepository implements CarrierRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Carrier::class); + } + + public function findById(int $id): ?Carrier + { + return $this->find($id); + } + + public function save(Carrier $carrier): void + { + $this->getEntityManager()->persist($carrier); + $this->getEntityManager()->flush(); + } + + public function createListQueryBuilder( + bool $includeArchived = false, + ?string $search = null, + array $certificationTypes = [], + ): QueryBuilder { + // Fetch-join de la SEULE relation ManyToOne qualimatCarrier (sur, pas de + // cartesien) pour exposer statut/date de validite QUALIMAT en liste sans + // N+1 (§ 2.11). Aucune sous-collection (addresses/contacts/prices) jointe + // en liste : elles ne sont embarquees qu'au detail (carrier:item:read). + $qb = $this->createQueryBuilder('c') + ->leftJoin('c.qualimatCarrier', 'q')->addSelect('q') + ->andWhere('c.deletedAt IS NULL') + ->orderBy('c.name', 'ASC') + ; + + // Pas de cloisonnement par site (§ 2.3) : referentiel global. + if (!$includeArchived) { + $qb->andWhere('c.isArchived = false'); + } + + $this->applySearch($qb, $search); + $this->applyCertificationTypes($qb, $certificationTypes); + + return $qb; + } + + /** + * Recherche fuzzy insensible a la casse sur le nom du transporteur (§ 4.1). + * Metacaracteres LIKE (%, _, \) echappes pour rester litteraux. + */ + private function applySearch(QueryBuilder $qb, ?string $search): void + { + if (null === $search || '' === trim($search)) { + return; + } + + $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search)); + $pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%'; + + $qb->andWhere('LOWER(c.name) LIKE :search') + ->setParameter('search', $pattern) + ; + } + + /** + * Restreint aux transporteurs dont la certification figure dans la liste (OR). + * Alimente le filtre « Certification » de la liste (§ 4.1). + * + * @param list $certificationTypes + */ + private function applyCertificationTypes(QueryBuilder $qb, array $certificationTypes): void + { + $codes = []; + foreach ($certificationTypes as $code) { + if (is_string($code) && '' !== trim($code)) { + $codes[] = trim($code); + } + } + + if ([] === $codes) { + return; + } + + $qb->andWhere('c.certificationType IN (:certificationTypes)') + ->setParameter('certificationTypes', $codes) + ; + } +} diff --git a/src/Shared/Domain/Contract/ClientAddressInterface.php b/src/Shared/Domain/Contract/ClientAddressInterface.php new file mode 100644 index 0000000..4f5f6a4 --- /dev/null +++ b/src/Shared/Domain/Contract/ClientAddressInterface.php @@ -0,0 +1,20 @@ + 'IBAN du compte (≤ 34 caracteres).', 'position' => 'Ordre d affichage du RIB dans la liste du prestataire (croissant).', ] + self::timestampableBlamableComments(), + + // === M4 Transport — referentiel QUALIMAT (ERP-39, mappe lecture seule des ERP-155) === + // Mappe par l'entite QualimatCarrier depuis M4 -> retire du schema_filter, + // donc ses COMMENT sont rejoues par app:apply-column-comments apres schema:update. + 'qualimat_carrier' => [ + '_table' => "Referentiel des transporteurs agrees QUALIMAT, synchronise quotidiennement depuis l'API qualimat.org (type=operateur_transport).", + 'id' => 'Cle technique auto-incrementee.', + 'siret' => 'SIRET normalise (chiffres sans espaces). Cle naturelle de synchro (unique). Source parfois incomplete (longueur variable), non contrainte a 14.', + 'name' => 'Raison sociale du transporteur (champs Nom = Societe de la source, identiques).', + 'address' => 'Adresse postale (voie). Nullable.', + 'postal_code' => 'Code postal. Nullable.', + 'city' => 'Ville. Nullable.', + 'phone' => 'Telephone au format source "indicatif|numero" (ex: +33|0608890316). Nullable.', + 'department' => 'Departement au format source "code - libelle" (ex: 65 - Hautes-Pyrenees). Nullable.', + 'status' => "Statut d'agrement QUALIMAT (valeurs connues : Audite, Valide, Suspendu). Valeur brute de la source, non contrainte.", + 'validity_date' => 'Date de fin de validite de la certification (convertie depuis dd/mm/yyyy). Nullable.', + 'is_active' => 'Faux = transporteur absent du dernier import (soft-delete). Toute ligne non revue par le dernier run passe a FALSE.', + 'last_synced_at' => 'Horodatage du run de synchro ayant vu cette ligne en dernier (soft-delete : last_synced_at < run courant).', + ], + + // === M4 Transport — repertoire transporteurs (ERP-155/157) === + 'carrier' => [ + '_table' => 'Repertoire transporteurs (M4 Transport) — entites editables, archivables (is_archived) et soft-deletables (deleted_at). Distinct du referentiel qualimat_carrier.', + 'id' => 'Identifiant interne auto-incremente.', + 'qualimat_carrier_id' => 'Lien editable vers le referentiel QUALIMAT (saisie assistee RG-4.01). FK -> qualimat_carrier.id, ON DELETE SET NULL : transporteur conserve si la ligne QUALIMAT disparait.', + 'name' => 'Raison sociale du transporteur (stockee en MAJUSCULES). Unique case-insensitive parmi les non-archives/non-supprimes (uq_carrier_name_active, RG-4.12 / § 2.6).', + 'certification_type' => 'Type de certification : QUALIMAT (si lie, lecture seule) ou GMP_PLUS/OVOCOM/COMPTE_PROPRE/AUTRE. AUTRE declenche le champ Decharge (RG-4.02). Null en cas LIOT (RG-4.01).', + 'is_chartered' => '« Affreter » coche : declenche indexation/benne-fond mouvant/volume, obligatoires (RG-4.03). Faux par defaut.', + 'indexation_rate' => 'Taux d indexation en pourcentage (NUMERIC 5,2) — renseigne si affrete (RG-4.03).', + 'container_type' => 'Type de contenant BENNE|FOND_MOUVANT (chk_carrier_container_type) — renseigne si affrete (RG-4.03).', + 'volume_m3' => 'Volume en m3 (NUMERIC 10,2) — renseigne si affrete (RG-4.03).', + 'discharge_document_id' => 'Document de Decharge (visible si certification_type = AUTRE, RG-4.02). FK -> uploaded_document.id (infra Shared § 2.7), ON DELETE SET NULL.', + 'liot_plates' => 'Immatriculations LIOT separees par « ; » (cas special nom=LIOT, RG-4.01). Les autres champs sont masques dans ce cas.', + 'is_archived' => 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission transport.carriers.archive (Admin seul).', + 'archived_at' => 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.', + 'deleted_at' => 'Horodatage du soft-delete technique — non expose par l API au M4. Null = ligne active.', + ] + self::timestampableBlamableComments(), + + 'carrier_address' => [ + '_table' => 'Adresses d un transporteur (1:n) — onglet Adresse (M4). Pre-remplie depuis QUALIMAT si applicable (RG-4.05).', + 'id' => 'Identifiant interne auto-incremente.', + 'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire de l adresse.', + 'country' => 'Pays de l adresse — defaut France.', + 'postal_code' => 'Code postal (saisie assistee BAN cote front, RG-4.06).', + 'city' => 'Ville — preremplie depuis le code postal via API BAN cote front.', + 'street' => 'Numero et voie de l adresse.', + 'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.', + 'position' => 'Ordre d affichage de l adresse dans la liste du transporteur (croissant).', + ] + self::timestampableBlamableComments(), + + 'carrier_contact' => [ + '_table' => 'Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins un champ rempli (RG-4.08, chk_carrier_contact_filled), max 2 telephones.', + 'id' => 'Identifiant interne auto-incremente.', + 'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du contact.', + 'first_name' => 'Prenom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).', + 'last_name' => 'Nom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).', + 'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).', + 'phone_primary' => 'Telephone principal — chiffres uniquement (normalisation serveur).', + 'phone_secondary' => 'Telephone secondaire — chiffres uniquement (max 2 telephones, RG-4.08).', + 'email' => 'Email du contact (lowercase serveur).', + 'position' => 'Ordre d affichage du contact dans la liste du transporteur (croissant).', + ] + self::timestampableBlamableComments(), + + 'carrier_price' => [ + '_table' => 'Prix d un transporteur (1:n) — onglet Prix (M4). Branche CLIENT ou FOURNISSEUR selon direction (RG-4.09->4.11, CHECK chk_carrier_price_*).', + 'id' => 'Identifiant interne auto-incremente.', + 'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du prix.', + 'direction' => 'Sens du prix : CLIENT ou FOURNISSEUR (RG-4.09). Pilote l affichage et l obligation des colonnes client_*/supplier_* (RG-4.10/4.11).', + 'client_id' => 'Branche CLIENT (RG-4.10) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi direction = CLIENT.', + 'client_delivery_address_id' => 'Branche CLIENT : adresse de livraison du client. FK -> client_address.id, ON DELETE RESTRICT.', + 'departure_site_id' => 'Branche CLIENT : adresse de depart = un des 3 sites (86/17/82). FK -> site.id, ON DELETE RESTRICT.', + 'supplier_id' => 'Branche FOURNISSEUR (RG-4.11) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi direction = FOURNISSEUR.', + 'supplier_supply_address_id' => 'Branche FOURNISSEUR : adresse d approvisionnement du fournisseur. FK -> supplier_address.id, ON DELETE RESTRICT.', + 'delivery_site_id' => 'Branche FOURNISSEUR : adresse de livraison = un des 3 sites (86/17/82). FK -> site.id, ON DELETE RESTRICT.', + 'container_type' => 'Type de contenant BENNE|FOND_MOUVANT (chk_carrier_price_container).', + 'pricing_unit' => 'Unite de tarification FORFAIT|TONNE (chk_carrier_price_unit).', + 'price' => 'Montant du prix (NUMERIC 12,2).', + 'price_state' => 'Etat du prix : EN_COURS, VALIDE ou NON_VALIDE (chk_carrier_price_state). Affiche dans le tableau Prix.', + 'position' => 'Ordre d affichage du prix dans la liste du transporteur (croissant).', + ] + self::timestampableBlamableComments(), ]; } diff --git a/tests/Architecture/EntitiesAreTimestampableBlamableTest.php b/tests/Architecture/EntitiesAreTimestampableBlamableTest.php index 605cf7f..3f27d2c 100644 --- a/tests/Architecture/EntitiesAreTimestampableBlamableTest.php +++ b/tests/Architecture/EntitiesAreTimestampableBlamableTest.php @@ -14,6 +14,7 @@ use App\Module\Core\Domain\Entity\Permission; use App\Module\Core\Domain\Entity\Role; use App\Module\Core\Domain\Entity\User; use App\Module\Sites\Domain\Entity\Site; +use App\Module\Transport\Domain\Entity\QualimatCarrier; use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\TimestampableInterface; use Doctrine\ORM\Mapping\Entity; @@ -61,6 +62,10 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase * spec-back M1 § 2.6 + § 3.5. * - Country (ERP-116) : referentiel statique des pays (id/code/name/position), * seede par migration, lecture seule. Meme justification que Bank. + * - QualimatCarrier (M4, ERP-39/155) : mapping ORM LECTURE SEULE sur la table + * referentielle qualimat_carrier, alimentee/soft-deletee exclusivement par + * la commande `app:qualimat:sync` (pas de tracabilite user-driven, pas + * d'ecriture API). Meme justification que les referentiels ci-dessus. * * Les futurs referentiels statiques s'ajoutent ici avec une justification. */ @@ -75,6 +80,7 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase PaymentType::class, Bank::class, Country::class, + QualimatCarrier::class, ]; public function testAllBusinessEntitiesImplementBothInterfaces(): void diff --git a/tests/Module/Transport/Api/AbstractCarrierApiTestCase.php b/tests/Module/Transport/Api/AbstractCarrierApiTestCase.php new file mode 100644 index 0000000..8a2310f --- /dev/null +++ b/tests/Module/Transport/Api/AbstractCarrierApiTestCase.php @@ -0,0 +1,241 @@ +getEm(); + // Carrier d'abord : ON DELETE CASCADE purge carrier_price (FK RESTRICT vers + // client/supplier), liberant les Client/Supplier de test pour leur purge. + $em->createQuery('DELETE FROM '.Carrier::class)->execute(); + $em->createQuery('DELETE FROM '.ClientEntity::class.' c WHERE c.companyName LIKE :p') + ->setParameter('p', self::TEST_REF_PREFIX.'%')->execute(); + $em->createQuery('DELETE FROM '.Supplier::class.' s WHERE s.companyName LIKE :p') + ->setParameter('p', self::TEST_REF_PREFIX.'%')->execute(); + // qualimat_carrier : insere en DBAL brut (entite lecture seule) -> purge DBAL. + $em->getConnection()->executeStatement( + 'DELETE FROM qualimat_carrier WHERE siret LIKE :p', + ['p' => self::TEST_SIRET_PREFIX.'%'], + ); + + parent::tearDown(); + } + + protected function createAdminClient(): Client + { + return $this->authenticatedClient('admin', 'admin'); + } + + /** + * Seede un transporteur minimal (nom en MAJUSCULES, comme le ferait le + * futur Processor). Sert aux tests de liste / archivage. + */ + protected function seedCarrier(string $name, bool $isArchived = false): Carrier + { + $em = $this->getEm(); + $carrier = new Carrier(); + $carrier->setName(mb_strtoupper($name, 'UTF-8')); + $carrier->setCertificationType('GMP_PLUS'); + $carrier->setIsArchived($isArchived); + if ($isArchived) { + $carrier->setArchivedAt(new DateTimeImmutable()); + } + $em->persist($carrier); + $em->flush(); + + return $carrier; + } + + /** + * Seede un transporteur COMPLET (sans passer par l'API) : lien QUALIMAT, + * 1 adresse, 1 contact, et 2 prix couvrant les deux branches (CLIENT avec + * client + adresse de livraison + site de depart ; FOURNISSEUR avec + * fournisseur + adresse d'appro + site de livraison). Socle du contrat de + * serialisation et de la DoD (§ 4.0.bis). + */ + protected function seedCompleteCarrier(string $name): Carrier + { + $em = $this->getEm(); + $suffix = substr(bin2hex(random_bytes(3)), 0, 6); + + $qualimat = $this->seedQualimatCarrier($name); + + $carrier = new Carrier(); + $carrier->setName(mb_strtoupper($name.' '.$suffix, 'UTF-8')); + $carrier->setQualimatCarrier($qualimat); + $carrier->setCertificationType('QUALIMAT'); + $em->persist($carrier); + + $address = new CarrierAddress(); + $address->setCarrier($carrier); + $address->setPostalCode('86000'); + $address->setCity('Poitiers'); + $address->setStreet('12 rue des Acacias'); + $carrier->addAddress($address); + $em->persist($address); + + $contact = new CarrierContact(); + $contact->setCarrier($carrier); + $contact->setFirstName('Marie'); + $contact->setLastName('Martin'); + $contact->setPhonePrimary('0612345678'); + $contact->setEmail('marie.martin@seed.test'); + $carrier->addContact($contact); + $em->persist($contact); + + // Refs cross-module : seedees localement (en test, les fixtures M1/M2 ne + // sont pas chargees — seuls les sites le sont). Prouve l'embed via les + // contrats Shared + resolve_target_entities (regle n°1). + $site = $em->getRepository(Site::class)->findOneBy([]); + self::assertNotNull($site, 'Un site fixture est requis (SitesFixtures).'); + + $clientAddress = $this->seedClientWithAddress($name.' '.$suffix); + $supplierAddress = $this->seedSupplierWithAddress($name.' '.$suffix); + + // Branche CLIENT (RG-4.10). + $clientPrice = new CarrierPrice(); + $clientPrice->setCarrier($carrier); + $clientPrice->setDirection('CLIENT'); + $clientPrice->setClient($clientAddress->getClient()); + $clientPrice->setClientDeliveryAddress($clientAddress); + $clientPrice->setDepartureSite($site); + $clientPrice->setContainerType('BENNE'); + $clientPrice->setPricingUnit('TONNE'); + $clientPrice->setPrice('42.50'); + $clientPrice->setPriceState('VALIDE'); + $carrier->addPrice($clientPrice); + $em->persist($clientPrice); + + // Branche FOURNISSEUR (RG-4.11). + $supplierPrice = new CarrierPrice(); + $supplierPrice->setCarrier($carrier); + $supplierPrice->setDirection('FOURNISSEUR'); + $supplierPrice->setSupplier($supplierAddress->getSupplier()); + $supplierPrice->setSupplierSupplyAddress($supplierAddress); + $supplierPrice->setDeliverySite($site); + $supplierPrice->setContainerType('FOND_MOUVANT'); + $supplierPrice->setPricingUnit('FORFAIT'); + $supplierPrice->setPrice('320.00'); + $supplierPrice->setPriceState('EN_COURS'); + $carrier->addPrice($supplierPrice); + $em->persist($supplierPrice); + + $em->flush(); + + return $carrier; + } + + /** + * Seede un Client minimal (companyName prefixe pour la purge) + une adresse + * de livraison valide (CHECKs client_address respectes). Retourne l'adresse. + */ + protected function seedClientWithAddress(string $label): ClientAddress + { + $em = $this->getEm(); + $suffix = substr(bin2hex(random_bytes(3)), 0, 6); + + $client = new ClientEntity(); + $client->setCompanyName(mb_strtoupper(self::TEST_REF_PREFIX.' CLI '.$label.' '.$suffix, 'UTF-8')); + $em->persist($client); + + $address = new ClientAddress(); + $address->setClient($client); + // Adresse de livraison : is_delivery=true, is_prospect=false, is_billing=false + // -> satisfait chk_client_address_prospect_exclusive + chk_client_address_billing_email. + $address->setIsDelivery(true); + $address->setPostalCode('86000'); + $address->setCity('Poitiers'); + $address->setStreet('1 rue de la Livraison'); + $em->persist($address); + + return $address; + } + + /** + * Seede un Supplier minimal (companyName prefixe pour la purge) + une adresse + * d'approvisionnement valide (address_type DEPART). Retourne l'adresse. + */ + protected function seedSupplierWithAddress(string $label): SupplierAddress + { + $em = $this->getEm(); + $suffix = substr(bin2hex(random_bytes(3)), 0, 6); + + $supplier = new Supplier(); + $supplier->setCompanyName(mb_strtoupper(self::TEST_REF_PREFIX.' FRN '.$label.' '.$suffix, 'UTF-8')); + $em->persist($supplier); + + $address = new SupplierAddress(); + $address->setSupplier($supplier); + $address->setAddressType('DEPART'); + $address->setPostalCode('17000'); + $address->setCity('La Rochelle'); + $address->setStreet('2 quai de l Appro'); + $em->persist($address); + + return $address; + } + + /** + * Insere une ligne qualimat_carrier de test en DBAL brut (l'entite mappee est + * en lecture seule) et retourne l'entite rechargee. SIRET prefixe pour la purge. + */ + protected function seedQualimatCarrier(string $name): QualimatCarrier + { + $em = $this->getEm(); + $siret = self::TEST_SIRET_PREFIX.substr(bin2hex(random_bytes(6)), 0, 9); + + $em->getConnection()->insert('qualimat_carrier', [ + 'siret' => $siret, + 'name' => mb_strtoupper($name, 'UTF-8'), + 'address' => '12 rue des Acacias', + 'postal_code' => '86000', + 'city' => 'Poitiers', + 'status' => 'Valide', + 'validity_date' => '2027-12-31', + 'is_active' => 'true', + 'last_synced_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'), + ]); + + $qualimat = $em->getRepository(QualimatCarrier::class)->findOneBy(['siret' => $siret]); + self::assertNotNull($qualimat, 'La ligne qualimat_carrier de test doit etre rechargeable.'); + + return $qualimat; + } +} diff --git a/tests/Module/Transport/Api/CarrierSerializationContractTest.php b/tests/Module/Transport/Api/CarrierSerializationContractTest.php new file mode 100644 index 0000000..ba5650e --- /dev/null +++ b/tests/Module/Transport/Api/CarrierSerializationContractTest.php @@ -0,0 +1,198 @@ +createAdminClient(); + $token = 'EnvCheck'.substr(bin2hex(random_bytes(3)), 0, 6); + + $this->seedCarrier($token.' Active'); + $this->seedCarrier($token.' Archived', true); + + $default = $http->request('GET', '/api/carriers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray(); + + self::assertArrayHasKey('member', $default); + self::assertArrayHasKey('totalItems', $default); + self::assertArrayNotHasKey('hydra:member', $default); + self::assertArrayNotHasKey('hydra:totalItems', $default); + self::assertSame(1, $default['totalItems'], 'Archive exclu du totalItems par defaut.'); + + $all = $http->request('GET', '/api/carriers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray(); + self::assertSame(2, $all['totalItems']); + + $paged = $http->request('GET', '/api/carriers?search='.$token.'&includeArchived=true&itemsPerPage=1', ['headers' => ['Accept' => self::LD]])->toArray(); + self::assertArrayHasKey('view', $paged); + self::assertArrayNotHasKey('hydra:view', $paged); + } + + // === #3 — Booleens presents (isArchived) + embed qualimatCarrier en LISTE === + + public function testListExposesIsArchivedAndEmbeddedQualimat(): void + { + $token = 'List'.substr(bin2hex(random_bytes(3)), 0, 6); + $carrier = $this->seedCompleteCarrier($token); + + $http = $this->createAdminClient(); + $list = $http->request('GET', '/api/carriers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray(); + + $row = $this->memberById($list, (int) $carrier->getId()); + self::assertNotNull($row, 'Le transporteur seede doit apparaitre dans la liste filtree.'); + + // Boolean trap (#3) : cle presente et typee bool. + self::assertArrayHasKey('isArchived', $row); + self::assertFalse($row['isArchived']); + + // qualimatCarrier embarque en OBJET (statut + date de validite — RG-4.04), + // pas un IRI nu (#1/#2). + self::assertArrayHasKey('qualimatCarrier', $row); + self::assertIsArray($row['qualimatCarrier'], 'qualimatCarrier doit etre un objet embarque, pas un IRI nu.'); + self::assertArrayHasKey('status', $row['qualimatCarrier']); + self::assertArrayHasKey('validityDate', $row['qualimatCarrier']); + + // updatedAt (default:read) expose pour la colonne « Derniere activite ». + self::assertArrayHasKey('updatedAt', $row); + } + + // === Detail : sous-collections embarquees + booleens === + + public function testDetailEmbedsSubCollectionsAndBooleans(): void + { + $carrier = $this->seedCompleteCarrier('Detail Co'); + + $http = $this->createAdminClient(); + $data = $http->request('GET', '/api/carriers/'.$carrier->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); + + self::assertArrayHasKey('isArchived', $data); + self::assertArrayHasKey('isChartered', $data); + self::assertFalse($data['isArchived']); + + self::assertNotEmpty($data['addresses']); + self::assertSame('Poitiers', $data['addresses'][0]['city']); + + self::assertNotEmpty($data['contacts']); + self::assertSame('Marie', $data['contacts'][0]['firstName']); + + self::assertNotEmpty($data['prices']); + self::assertGreaterThanOrEqual(2, count($data['prices'])); + } + + // === #1/#2 — prices[] : client / supplier / sites embarques en OBJET === + + public function testPriceCrossModuleRelationsAreEmbeddedObjects(): void + { + $carrier = $this->seedCompleteCarrier('Price Embed Co'); + + $http = $this->createAdminClient(); + $data = $http->request('GET', '/api/carriers/'.$carrier->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); + + $byDirection = []; + foreach ($data['prices'] as $price) { + $byDirection[$price['direction']] = $price; + } + + self::assertArrayHasKey('CLIENT', $byDirection); + self::assertArrayHasKey('FOURNISSEUR', $byDirection); + + // Branche CLIENT : client + adresse + site de depart en OBJET (pas IRI). + $clientPrice = $byDirection['CLIENT']; + self::assertIsArray($clientPrice['client'], 'prices[].client doit etre un objet embarque (client:read), pas un IRI nu.'); + self::assertArrayHasKey('companyName', $clientPrice['client']); + self::assertIsArray($clientPrice['clientDeliveryAddress']); + self::assertArrayHasKey('city', $clientPrice['clientDeliveryAddress'], 'L\'adresse client doit embarquer ses champs (client_address:read).'); + self::assertIsArray($clientPrice['departureSite']); + self::assertArrayHasKey('name', $clientPrice['departureSite']); + + // Branche FOURNISSEUR : supplier + adresse + site de livraison en OBJET. + $supplierPrice = $byDirection['FOURNISSEUR']; + self::assertIsArray($supplierPrice['supplier'], 'prices[].supplier doit etre un objet embarque (supplier:read), pas un IRI nu.'); + self::assertArrayHasKey('companyName', $supplierPrice['supplier']); + self::assertIsArray($supplierPrice['supplierSupplyAddress']); + self::assertArrayHasKey('city', $supplierPrice['supplierSupplyAddress'], 'L\'adresse fournisseur doit embarquer ses champs (supplier_address:read).'); + self::assertIsArray($supplierPrice['deliverySite']); + } + + // === RBAC : 403 sans la permission view === + + public function testForbiddenWithoutViewPermission(): void + { + $carrier = $this->seedCarrier('Rbac Co'); + + // user-nothing : aucune permission transport.carriers.*. + $creds = $this->createUserWithPermission('core.users.view'); + $http = $this->authenticatedClient($creds['username'], $creds['password']); + + $http->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]); + self::assertSame(403, $http->getResponse()->getStatusCode()); + + $http->request('GET', '/api/carriers/'.$carrier->getId(), ['headers' => ['Accept' => self::LD]]); + self::assertSame(403, $http->getResponse()->getStatusCode()); + } + + /** + * DoD (§ 4.0.bis) : capture des reponses JSON REELLES (liste + detail) pour + * les coller dans la spec avant de lancer les tickets front. Le test asserte + * la forme ; si CARRIER_DOD_DUMP est positionnee, ecrit les corps sous /tmp. + */ + public function testDodReferenceJsonShape(): void + { + $token = 'DoD'.substr(bin2hex(random_bytes(3)), 0, 6); + $carrier = $this->seedCompleteCarrier($token); + $id = (int) $carrier->getId(); + + $admin = $this->createAdminClient(); + $list = $admin->request('GET', '/api/carriers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray(); + $detail = $admin->request('GET', '/api/carriers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray(); + + self::assertArrayHasKey('member', $list); + self::assertArrayHasKey('qualimatCarrier', $detail); + self::assertArrayHasKey('addresses', $detail); + self::assertArrayHasKey('contacts', $detail); + self::assertArrayHasKey('prices', $detail); + + if (false !== getenv('CARRIER_DOD_DUMP')) { + $flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; + file_put_contents('/tmp/carrier-dod-list.json', json_encode($list, $flags)); + file_put_contents('/tmp/carrier-dod-detail.json', json_encode($detail, $flags)); + } + } + + /** + * Retrouve un membre de la collection par son id (liste filtree). + * + * @param array $collection + * + * @return array|null + */ + private function memberById(array $collection, int $id): ?array + { + foreach ($collection['member'] ?? [] as $member) { + if (($member['id'] ?? null) === $id) { + return $member; + } + } + + return null; + } +}