From 2be9cd05d42e86684aab44b94bace0ea78dec468 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 15 Jun 2026 18:11:15 +0200 Subject: [PATCH 01/13] feat(transport) : permissions carriers + sidebar (ERP-153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Socle RBAC du module Transport (M4 § 5) : - TransportModule::permissions() declare transport.carriers.{view,manage,archive} - RbacSeeder::MATRIX (§ 5.2) : Bureau (view+manage), Commerciale (view) ; Compta/Usine aucun acces ; archive admin seul - config/sidebar.php : section Transport + item /carriers (gate transport.carriers.view) - i18n sidebar.transport.{section,carriers} - 3 miroirs RBAC alignes : sidebar.php, personas.ts (user-full), SeedE2ECommand.php - TransportModuleTest : garde-fou sur le jeu de permissions --- config/sidebar.php | 17 ++++++ frontend/i18n/locales/fr.json | 4 ++ frontend/tests/e2e/_fixtures/personas.ts | 7 +++ .../Core/Application/Rbac/RbacSeeder.php | 12 +++- .../Infrastructure/Console/SeedE2ECommand.php | 5 ++ src/Module/Transport/TransportModule.php | 17 ++++-- .../Module/Transport/TransportModuleTest.php | 57 +++++++++++++++++++ 7 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 tests/Module/Transport/TransportModuleTest.php diff --git a/config/sidebar.php b/config/sidebar.php index 5d5ccb3..e38e1e0 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -78,6 +78,23 @@ return [ ], ], ], + // Section "Transport" (M4, ERP-153) : pole logistique, porte le repertoire + // transporteurs. L'item est gate par `transport.carriers.view` ; la section + // disparait automatiquement (SidebarProvider) si le module `transport` est + // desactive ou si l'user n'a pas la permission (Compta / Usine). + [ + 'label' => 'sidebar.transport.section', + 'icon' => 'mdi:truck-outline', + 'items' => [ + [ + 'label' => 'sidebar.transport.carriers', + 'to' => '/carriers', + 'icon' => 'mdi:truck-outline', + 'module' => 'transport', + 'permission' => 'transport.carriers.view', + ], + ], + ], // Section "Administration" : regroupe toutes les pages de configuration // applicative (RBAC, users, sites, audit log). // diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 5709bef..14840fe 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -35,6 +35,10 @@ "section": "Technique", "providers": "Répertoire prestataires" }, + "transport": { + "section": "Transport", + "carriers": "Répertoire transporteurs" + }, "core": { "roles": "Gestion des rôles", "users": "Utilisateurs", diff --git a/frontend/tests/e2e/_fixtures/personas.ts b/frontend/tests/e2e/_fixtures/personas.ts index ededce8..b690cc2 100644 --- a/frontend/tests/e2e/_fixtures/personas.ts +++ b/frontend/tests/e2e/_fixtures/personas.ts @@ -95,6 +95,13 @@ export const personas: Record = { 'technique.providers.accounting.view', 'technique.providers.accounting.manage', 'technique.providers.archive', + // Transport — Repertoire transporteurs (M4, ERP-153). Meme logique : + // mappe sur le persona "tout", pas de nouveau persona (regle ABSOLUE + // n°7). transport.carriers.view n'ajoute pas de lien dans la section + // Administration, donc expectedAdminLinks reste inchange. + 'transport.carriers.view', + 'transport.carriers.manage', + 'transport.carriers.archive', ], expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'], }, diff --git a/src/Module/Core/Application/Rbac/RbacSeeder.php b/src/Module/Core/Application/Rbac/RbacSeeder.php index fd882a6..d96d7b6 100644 --- a/src/Module/Core/Application/Rbac/RbacSeeder.php +++ b/src/Module/Core/Application/Rbac/RbacSeeder.php @@ -51,9 +51,9 @@ final class RbacSeeder * Definition unique des 4 roles + matrice § 2.7. La cle est le code du role, * `label` le libelle FR affichable, `permissions` la liste des codes RBAC a * attacher (admin n'apparait pas car il bypass tout via isAdmin ; - * `commercial.clients.archive`, `commercial.suppliers.archive` et - * `technique.providers.archive` ne sont attaches a aucun role metier — - * admin seul). + * `commercial.clients.archive`, `commercial.suppliers.archive`, + * `technique.providers.archive` et `transport.carriers.archive` ne sont + * attaches a aucun role metier — admin seul). * * Cloisonnement par site des prestataires (M3 § 2.13) : la permission * `sites.bypass_scope` est attribuee par defaut a Bureau / Compta / @@ -77,6 +77,9 @@ final class RbacSeeder // Prestataires (M3 § 2.9, ERP-138) : view + manage (hors Comptabilite). 'technique.providers.view', 'technique.providers.manage', + // Transporteurs (M4 § 5.2, ERP-153) : view + manage (PAS archive -> admin seul). + 'transport.carriers.view', + 'transport.carriers.manage', // Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites. 'sites.bypass_scope', // Lecture des referentiels transverses pour les selects client (ERP-102). @@ -120,6 +123,9 @@ final class RbacSeeder // (onglet Comptabilite masque/filtre pour la Commerciale). 'technique.providers.view', 'technique.providers.manage', + // Transporteurs (M4 § 5.2, ERP-153) : view seul (consultation « Tout », + // ni manage ni archive pour la Commerciale). + 'transport.carriers.view', // Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites. 'sites.bypass_scope', // Lecture des referentiels transverses pour les selects client (ERP-102). diff --git a/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php index bbef32d..880a2cb 100644 --- a/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php +++ b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php @@ -212,6 +212,11 @@ final class SeedE2ECommand extends Command 'technique.providers.accounting.view', 'technique.providers.accounting.manage', 'technique.providers.archive', + // Transport — Repertoire transporteurs (M4, ERP-153). Meme + // logique : mappe sur le persona "tout". Miroir de personas.ts. + 'transport.carriers.view', + 'transport.carriers.manage', + 'transport.carriers.archive', ], ], [ diff --git a/src/Module/Transport/TransportModule.php b/src/Module/Transport/TransportModule.php index f7248ba..7a69fbf 100644 --- a/src/Module/Transport/TransportModule.php +++ b/src/Module/Transport/TransportModule.php @@ -13,17 +13,22 @@ final class TransportModule /** * Liste declarative des permissions RBAC exposees par le module Transport. * - * Vide a ce stade : le module ne porte que des referentiels externes - * synchronises par commandes console (codes IDTF - ERP-149, transporteurs - * QUALIMAT - ERP-39), sans ecran ni action protegee. Les permissions seront - * ajoutees quand une page de consultation sera exposee. + * Socle du repertoire transporteurs (M4 § 5.1, ERP-153) : + * - `view` : consultation de la liste / fiche transporteur ; + * - `manage` : creation / modification (hors archivage) ; + * - `archive` : archivage / restauration (admin seul, cf. matrice § 5.2). * - * Consommee par `app:sync-permissions` (un tableau vide est valide). + * Consommee par `app:sync-permissions`. Matrice role -> permissions dans + * `RbacSeeder::MATRIX` (§ 5.2). * * @return array */ public static function permissions(): array { - return []; + return [ + ['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'], + ]; } } diff --git a/tests/Module/Transport/TransportModuleTest.php b/tests/Module/Transport/TransportModuleTest.php new file mode 100644 index 0000000..79b8880 --- /dev/null +++ b/tests/Module/Transport/TransportModuleTest.php @@ -0,0 +1,57 @@ + Date: Mon, 15 Jun 2026 19:15:12 +0200 Subject: [PATCH 02/13] =?UTF-8?q?feat(transport)=20:=20sch=C3=A9ma=20+=20e?= =?UTF-8?q?ntit=C3=A9s=20Carrier=20+=20contrat=20lecture=20(ERP-155/157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schéma BDD du répertoire transporteurs (M4) + entités + contrat de lecture (liste + détail), socle du front. - Migration Version20260615150000 : tables carrier / carrier_address / carrier_contact / carrier_price (FK cross-module, CHECK enum, index partiel uq_carrier_name_active, COMMENT ON COLUMN). uploaded_document et qualimat_carrier réutilisées (non recréées). - Entités Carrier* (#[Auditable], Timestampable/Blamable) + ApiResource LECTURE seule (GetCollection + Get via CarrierProvider, anti-N+1, exclusion archivés + ?includeArchived). Écriture (POST/PATCH + Processor) reportée WT4+. - QualimatCarrier : mapping ORM lecture seule sur la table référentielle existante (sortie du schema_filter, mapping aligné DDL ERP-39, schema:update no-op) + endpoint de recherche read-only (§ 4.7). - Relations cross-module des prix (Client/Supplier/adresses) via contrats Shared (ClientInterface, SupplierInterface, ClientAddressInterface, SupplierAddressInterface) + resolve_target_entities — sans import inter-module (règle n°1). Ajout du groupe supplier_address:read aux champs de SupplierAddress pour l'embed. - Garde-fous : ColumnCommentsCatalog (carrier* + qualimat_carrier), makefile test-db-setup (index partiel carrier), i18n audit (transport_carrier*), EntitiesAreTimestampableBlamableTest (QualimatCarrier whitelisté). - CarrierSerializationContractTest : contrat JSON liste + détail vérifié (embeds objet, booléens, enveloppe Hydra) ; JSON réel capturé dans spec-back § 4.0.bis. make db-reset OK, make test vert (731), make nuxt-test vert (480), php-cs-fixer OK. --- config/packages/doctrine.yaml | 33 +- .../prompts/00-PLAN-MAITRE.md | 100 ++ .../M4-transporteurs/prompts/WT1-upload.md | 44 + .../M4-transporteurs/prompts/WT10-tests.md | 37 + .../M4-transporteurs/prompts/WT2-rbac.md | 45 + .../prompts/WT3-migration-entites.md | 54 + .../M4-transporteurs/prompts/WT4-processor.md | 41 + .../M4-transporteurs/prompts/WT5-qualimat.md | 37 + .../M4-transporteurs/prompts/WT6-adresses.md | 37 + .../M4-transporteurs/prompts/WT7-contacts.md | 35 + .../M4-transporteurs/prompts/WT8-prix.md | 39 + .../M4-transporteurs/prompts/WT9-export.md | 36 + docs/specs/M4-transporteurs/spec-back.md | 994 ++++++++++++++++++ docs/specs/M4-transporteurs/spec-front.md | 354 +++++++ .../M4-transporteurs/tickets-lesstime.md | 307 ++++++ frontend/i18n/locales/fr.json | 6 +- makefile | 1 + migrations/Version20260615150000.php | 356 +++++++ .../Commercial/Domain/Entity/Client.php | 3 +- .../Domain/Entity/ClientAddress.php | 3 +- .../Commercial/Domain/Entity/Supplier.php | 3 +- .../Domain/Entity/SupplierAddress.php | 21 +- .../Transport/Domain/Entity/Carrier.php | 412 ++++++++ .../Domain/Entity/CarrierAddress.php | 154 +++ .../Domain/Entity/CarrierContact.php | 169 +++ .../Transport/Domain/Entity/CarrierPrice.php | 284 +++++ .../Domain/Entity/QualimatCarrier.php | 174 +++ .../Repository/CarrierRepositoryInterface.php | 33 + .../State/Provider/CarrierProvider.php | 137 +++ .../DataFixtures/CarrierFixtures.php | 51 + .../Doctrine/DoctrineCarrierRepository.php | 101 ++ .../Contract/ClientAddressInterface.php | 20 + .../Domain/Contract/ClientInterface.php | 23 + .../Contract/SupplierAddressInterface.php | 20 + .../Domain/Contract/SupplierInterface.php | 23 + .../Database/ColumnCommentsCatalog.php | 80 ++ .../EntitiesAreTimestampableBlamableTest.php | 6 + .../Api/AbstractCarrierApiTestCase.php | 241 +++++ .../Api/CarrierSerializationContractTest.php | 198 ++++ 39 files changed, 4696 insertions(+), 16 deletions(-) create mode 100644 docs/specs/M4-transporteurs/prompts/00-PLAN-MAITRE.md create mode 100644 docs/specs/M4-transporteurs/prompts/WT1-upload.md create mode 100644 docs/specs/M4-transporteurs/prompts/WT10-tests.md create mode 100644 docs/specs/M4-transporteurs/prompts/WT2-rbac.md create mode 100644 docs/specs/M4-transporteurs/prompts/WT3-migration-entites.md create mode 100644 docs/specs/M4-transporteurs/prompts/WT4-processor.md create mode 100644 docs/specs/M4-transporteurs/prompts/WT5-qualimat.md create mode 100644 docs/specs/M4-transporteurs/prompts/WT6-adresses.md create mode 100644 docs/specs/M4-transporteurs/prompts/WT7-contacts.md create mode 100644 docs/specs/M4-transporteurs/prompts/WT8-prix.md create mode 100644 docs/specs/M4-transporteurs/prompts/WT9-export.md create mode 100644 docs/specs/M4-transporteurs/spec-back.md create mode 100644 docs/specs/M4-transporteurs/spec-front.md create mode 100644 docs/specs/M4-transporteurs/tickets-lesstime.md create mode 100644 migrations/Version20260615150000.php create mode 100644 src/Module/Transport/Domain/Entity/Carrier.php create mode 100644 src/Module/Transport/Domain/Entity/CarrierAddress.php create mode 100644 src/Module/Transport/Domain/Entity/CarrierContact.php create mode 100644 src/Module/Transport/Domain/Entity/CarrierPrice.php create mode 100644 src/Module/Transport/Domain/Entity/QualimatCarrier.php create mode 100644 src/Module/Transport/Domain/Repository/CarrierRepositoryInterface.php create mode 100644 src/Module/Transport/Infrastructure/ApiPlatform/State/Provider/CarrierProvider.php create mode 100644 src/Module/Transport/Infrastructure/DataFixtures/CarrierFixtures.php create mode 100644 src/Module/Transport/Infrastructure/Doctrine/DoctrineCarrierRepository.php create mode 100644 src/Shared/Domain/Contract/ClientAddressInterface.php create mode 100644 src/Shared/Domain/Contract/ClientInterface.php create mode 100644 src/Shared/Domain/Contract/SupplierAddressInterface.php create mode 100644 src/Shared/Domain/Contract/SupplierInterface.php create mode 100644 tests/Module/Transport/Api/AbstractCarrierApiTestCase.php create mode 100644 tests/Module/Transport/Api/CarrierSerializationContractTest.php 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 14840fe..c469f5a 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -553,7 +553,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; + } +} From aa23189fe121b33239dcbb77116bd24ffe3c5c15 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 16 Jun 2026 14:38:33 +0200 Subject: [PATCH 03/13] feat(transport) : filtre archivedOnly sur le repertoire (coherence M1/M2/M3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligne CarrierProvider/DoctrineCarrierRepository sur Client/Supplier/Provider : ?archivedOnly=true n'expose que les archives (prioritaire sur includeArchived), pour que le toggle « Voir les archives » du front (ERP-173/ERP-164) soit operant. Parametre optionnel en fin de signature : retro-compatible avec les appels existants. --- .../Domain/Repository/CarrierRepositoryInterface.php | 8 ++++++++ .../ApiPlatform/State/Provider/CarrierProvider.php | 6 +++++- .../Infrastructure/Doctrine/DoctrineCarrierRepository.php | 7 ++++++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/Module/Transport/Domain/Repository/CarrierRepositoryInterface.php b/src/Module/Transport/Domain/Repository/CarrierRepositoryInterface.php index 4ff6f88..13466f0 100644 --- a/src/Module/Transport/Domain/Repository/CarrierRepositoryInterface.php +++ b/src/Module/Transport/Domain/Repository/CarrierRepositoryInterface.php @@ -23,11 +23,19 @@ interface CarrierRepositoryInterface * Fetch-join uniquement qualimatCarrier (ManyToOne, sur — § 2.11) : la liste * n'embarque aucune sous-collection. Tri par defaut name ASC. * + * Perimetre d'archivage (aligne sur ClientProvider/SupplierProvider/ + * ProviderProvider — toggle « Voir les archives » d'ERP-173) : + * - $archivedOnly = true -> uniquement les archives (is_archived = true) ; + * - sinon $includeArchived = true -> actifs + archives (echappatoire) ; + * - par defaut -> actifs seuls (is_archived = false). + * $archivedOnly a la priorite sur $includeArchived. + * * @param list $certificationTypes filtre repetable (OR) sur certificationType */ public function createListQueryBuilder( bool $includeArchived = false, ?string $search = null, array $certificationTypes = [], + bool $archivedOnly = false, ): QueryBuilder; } diff --git a/src/Module/Transport/Infrastructure/ApiPlatform/State/Provider/CarrierProvider.php b/src/Module/Transport/Infrastructure/ApiPlatform/State/Provider/CarrierProvider.php index 3c2b02f..f091400 100644 --- a/src/Module/Transport/Infrastructure/ApiPlatform/State/Provider/CarrierProvider.php +++ b/src/Module/Transport/Infrastructure/ApiPlatform/State/Provider/CarrierProvider.php @@ -23,6 +23,8 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire; * Collection (GET /api/carriers) : * - exclut par defaut les archives (is_archived = true) ET les soft-deletes ; * - ?includeArchived=true reintegre les archives (soft-deletes toujours exclus) ; + * - ?archivedOnly=true n'expose QUE les archives (prioritaire sur includeArchived, + * aligne sur Client/Supplier/Provider — toggle « Voir les archives » ERP-173) ; * - filtres ?search= (fuzzy name) et ?certificationType= (repetable) ; * - tri par defaut name ASC ; pagination Hydra (regle n°13) + echappatoire * ?pagination=false. @@ -58,6 +60,7 @@ final class CarrierProvider implements ProviderInterface { $filters = $context['filters'] ?? []; $includeArchived = $this->readBool($filters['includeArchived'] ?? false); + $archivedOnly = $this->readBool($filters['archivedOnly'] ?? false); $search = $filters['search'] ?? null; $certificationTypes = $this->readStringList($filters['certificationType'] ?? []); @@ -65,11 +68,12 @@ final class CarrierProvider implements ProviderInterface $includeArchived, is_string($search) ? $search : null, $certificationTypes, + $archivedOnly, ); // Echappatoire ?pagination=false : collection complete (selects front). if (!$this->pagination->isEnabled($operation, $context)) { - /** @var list $carriers */ + // @var list $carriers return $qb->getQuery()->getResult(); } diff --git a/src/Module/Transport/Infrastructure/Doctrine/DoctrineCarrierRepository.php b/src/Module/Transport/Infrastructure/Doctrine/DoctrineCarrierRepository.php index bda439a..a39eca5 100644 --- a/src/Module/Transport/Infrastructure/Doctrine/DoctrineCarrierRepository.php +++ b/src/Module/Transport/Infrastructure/Doctrine/DoctrineCarrierRepository.php @@ -35,6 +35,7 @@ class DoctrineCarrierRepository extends ServiceEntityRepository implements Carri bool $includeArchived = false, ?string $search = null, array $certificationTypes = [], + bool $archivedOnly = false, ): QueryBuilder { // Fetch-join de la SEULE relation ManyToOne qualimatCarrier (sur, pas de // cartesien) pour exposer statut/date de validite QUALIMAT en liste sans @@ -47,7 +48,11 @@ class DoctrineCarrierRepository extends ServiceEntityRepository implements Carri ; // Pas de cloisonnement par site (§ 2.3) : referentiel global. - if (!$includeArchived) { + // Perimetre d'archivage : archivedOnly prioritaire sur includeArchived + // (jumeau de DoctrineProviderRepository — toggle « Voir les archives »). + if ($archivedOnly) { + $qb->andWhere('c.isArchived = true'); + } elseif (!$includeArchived) { $qb->andWhere('c.isArchived = false'); } From 13d4a08bc9697c1ccd9d399e775761fd35fb93c5 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 16 Jun 2026 08:14:54 +0200 Subject: [PATCH 04/13] feat(transport) : CarrierProcessor + champs conditionnels formulaire principal (ERP-158) Ecriture du formulaire principal transporteur (M4, WT4) : POST/PATCH via CarrierProcessor + CarrierFieldNormalizer, contraintes conditionnelles sur l'entite Carrier. - RG-4.01 : POST qualimatCarrier -> certificationType=QUALIMAT + FK persistee ; cas LIOT (name=LIOT) -> certification non requise, liotPlates accepte. - RG-4.02 : certificationType=AUTRE sans dischargeDocument -> 422 (Assert\Callback). - RG-4.03 : isChartered=true sans indexationRate/containerType/volumeM3 -> 422. - RG-4.12 : doublon de nom (parmi actifs) -> 409 (index partiel uq_carrier_name_active). - RG-4.13 : normalisation serveur (name UPPER, liotPlates ;-split/trim/UPPER) + methodes personne/telephone/email pour les sous-ressources Contact (WT7). - RG-4.14 : PATCH isArchived exige transport.carriers.archive (Admin seul), mode strict -> 403 + 422 si autre champ ; restauration en conflit -> 409. Operations Post/Patch ajoutees a l'ApiResource (lecture posee au WT3 conservee). RG conditionnelles portees par validateMainFormConsistency (Assert\Callback + ->atPath()) pour un propertyPath mappable inline (useFormErrors, ERP-101). certificationType / containerType whitelistes dans EXCLUDED_LENGTH_MIRROR (Choice borne deja les valeurs, miroir SupplierAddress::addressType). Tests : CarrierWriteApiTest (RG-4.01->4.03/4.12->4.14), CarrierRBACMatrixTest (matrice bureau/compta/commerciale/usine), CarrierArchiveTest (409 restauration), CarrierFieldNormalizerTest (RG-4.13). make test vert (750). --- .../Service/CarrierFieldNormalizer.php | 105 +++++++ .../Transport/Domain/Entity/Carrier.php | 157 ++++++++-- .../State/Processor/CarrierProcessor.php | 275 ++++++++++++++++++ ...EntityConstraintsHaveFrenchMessageTest.php | 4 + .../Api/AbstractCarrierApiTestCase.php | 19 +- .../Transport/Api/CarrierArchiveTest.php | 36 +++ .../Transport/Api/CarrierRBACMatrixTest.php | 158 ++++++++++ .../Transport/Api/CarrierWriteApiTest.php | 240 +++++++++++++++ .../CarrierFieldNormalizerTest.php | 69 +++++ 9 files changed, 1040 insertions(+), 23 deletions(-) create mode 100644 src/Module/Transport/Application/Service/CarrierFieldNormalizer.php create mode 100644 src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierProcessor.php create mode 100644 tests/Module/Transport/Api/CarrierArchiveTest.php create mode 100644 tests/Module/Transport/Api/CarrierRBACMatrixTest.php create mode 100644 tests/Module/Transport/Api/CarrierWriteApiTest.php create mode 100644 tests/Module/Transport/Application/CarrierFieldNormalizerTest.php diff --git a/src/Module/Transport/Application/Service/CarrierFieldNormalizer.php b/src/Module/Transport/Application/Service/CarrierFieldNormalizer.php new file mode 100644 index 0000000..9880294 --- /dev/null +++ b/src/Module/Transport/Application/Service/CarrierFieldNormalizer.php @@ -0,0 +1,105 @@ + "0612345678" (RG-4.13). + * Le formatage d'affichage "XX XX XX XX XX" est de la responsabilite du front. + * - email : lowercase integral (RG-4.13) + * - liotPlates : liste « ; » -> split, trim, UPPER, rejoin "; " (cas LIOT RG-4.01). + * + * Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide apres + * trim devient null (evite de persister "" dans des colonnes nullable). + */ +final class CarrierFieldNormalizer +{ + /** + * Raison sociale en majuscules (RG-4.13). Conserve null tel quel ; une chaine + * non vide est trim + upper. Une chaine vide reste "" (champ obligatoire : + * c'est l'Assert\NotBlank qui rejette, pas le normalizer). + */ + public function normalizeName(?string $value): ?string + { + if (null === $value) { + return null; + } + + return mb_strtoupper(trim($value), 'UTF-8'); + } + + /** + * Nom/prenom de personne en Title Case (RG-4.13) : "JEAN dupont" -> + * "Jean Dupont". Une chaine vide apres trim devient null. + */ + public function normalizePersonName(?string $value): ?string + { + if (null === $value) { + return null; + } + + $value = trim($value); + + return '' === $value ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8'); + } + + /** + * Email en minuscules (RG-4.13). Une chaine vide apres trim devient null. + */ + public function normalizeEmail(?string $value): ?string + { + if (null === $value) { + return null; + } + + $value = trim($value); + + return '' === $value ? null : mb_strtolower($value, 'UTF-8'); + } + + /** + * Telephone reduit aux chiffres (RG-4.13) : "06.12.34.56.78" -> "0612345678". + * Une valeur sans aucun chiffre devient null. + */ + public function normalizePhone(?string $value): ?string + { + if (null === $value) { + return null; + } + + $digits = preg_replace('/\D+/', '', $value) ?? ''; + + return '' === $digits ? null : $digits; + } + + /** + * Immatriculations LIOT (RG-4.01 / RG-4.13) : la saisie « ; »-separee est + * decoupee, chaque plaque trim + UPPER, les segments vides ecartes, puis + * recomposee avec le separateur canonique "; ". Une saisie sans aucune plaque + * exploitable devient null. + */ + public function normalizeLiotPlates(?string $value): ?string + { + if (null === $value) { + return null; + } + + $plates = []; + foreach (explode(';', $value) as $plate) { + $plate = trim($plate); + if ('' !== $plate) { + $plates[] = mb_strtoupper($plate, 'UTF-8'); + } + } + + return [] === $plates ? null : implode('; ', $plates); + } +} diff --git a/src/Module/Transport/Domain/Entity/Carrier.php b/src/Module/Transport/Domain/Entity/Carrier.php index 6d10014..de39955 100644 --- a/src/Module/Transport/Domain/Entity/Carrier.php +++ b/src/Module/Transport/Domain/Entity/Carrier.php @@ -7,6 +7,9 @@ namespace App\Module\Transport\Domain\Entity; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierProcessor; use App\Module\Transport\Infrastructure\ApiPlatform\State\Provider\CarrierProvider; use App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository; use App\Shared\Domain\Attribute\Auditable; @@ -20,6 +23,8 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\SerializedName; +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Context\ExecutionContextInterface; /** * Transporteur (M4 Transport) — entite racine du repertoire transporteurs, @@ -28,16 +33,17 @@ use Symfony\Component\Serializer\Attribute\SerializedName; * (is_archived / archived_at) et le soft-delete technique prepare mais non * expose au M4 (deleted_at). * - * Perimetre WT3 (ERP-155/157) = CONTRAT DE LECTURE uniquement : l'#[ApiResource] - * n'expose que GetCollection + Get (via CarrierProvider). La creation / - * modification (POST/PATCH + CarrierProcessor : normalisation, RG-4.01→4.14, - * 409 doublon, gating archive) et les sous-ressources d'ecriture - * (adresses/contacts/prix) arrivent aux worktrees suivants (WT4+). C'est - * pourquoi les proprietes ne portent ICI que des read-groups (carrier:read / - * carrier:item:read / qualimat:read), sans groupe d'ecriture ni contrainte - * Assert de validation (qui appartiennent au flux d'ecriture). Les invariants - * BDD (NOT NULL, CHECK enum, FK, unicite partielle) restent garantis par la - * migration Version20260615150000. + * Perimetre WT4 (ERP-158) = formulaire principal en ecriture. L'#[ApiResource] + * expose desormais Post + Patch (via CarrierProcessor : normalisation RG-4.13, + * gating archive mode strict RG-4.14, 409 doublon de nom RG-4.12) en plus du + * contrat de lecture pose au WT3. Les proprietes du formulaire principal portent + * leur groupe d'ecriture (carrier:write:main / carrier:write:archive) et leurs + * contraintes Assert ; les RG conditionnelles (RG-4.01 certification obligatoire + * sauf LIOT, RG-4.02 AUTRE -> decharge, RG-4.03 affrete -> indexation/benne/volume) + * sont portees par validateMainFormConsistency (Assert\Callback + ->atPath()). + * Les sous-ressources d'ecriture (adresses/contacts/prix) arrivent aux worktrees + * suivants (WT6/7/8). Les invariants BDD (NOT NULL, CHECK enum, FK, unicite + * partielle) restent garantis par la migration Version20260615150000. * * Contrat de serialisation (RETEX M1, 3 maillons — spec § 4.0) : * - LISTE (carrier:read + qualimat:read + default:read) : name, certificationType, @@ -79,7 +85,27 @@ use Symfony\Component\Serializer\Attribute\SerializedName; ]], provider: CarrierProvider::class, ), - // Pas de Post/Patch/Delete au WT3 (lecture seule). Ecriture + archivage : WT4. + new Post( + // Creation du formulaire principal (RG-4.01 -> RG-4.03 / RG-4.12 / + // RG-4.13). La reponse 201 ne renvoie que les scalaires principaux + + // id : le front enchaine ensuite les sous-ressources par onglet. + security: "is_granted('transport.carriers.manage')", + normalizationContext: ['groups' => ['carrier:read', 'default:read']], + denormalizationContext: ['groups' => ['carrier:write:main']], + processor: CarrierProcessor::class, + ), + new Patch( + // Security elargie au seul `manage` (Admin + Bureau). Le CarrierProcessor + // re-gate ensuite l'archivage : un payload basculant isArchived exige + // `transport.carriers.archive` (Admin seul -> Bureau recoit 403, mode + // strict RG-4.14). + 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-M4-C). Archivage via PATCH { isArchived: true }. ], )] #[ORM\Entity(repositoryClass: DoctrineCarrierRepository::class)] @@ -95,6 +121,12 @@ class Carrier implements TimestampableInterface, BlamableInterface { use TimestampableBlamableTrait; + /** RG-4.01 : nom du cas special « compte-propre » LIOT (comparaison insensible a la casse). */ + private const string LIOT_NAME = 'LIOT'; + + /** RG-4.02 : valeur de certification imposant le champ Decharge. */ + private const string CERTIFICATION_AUTRE = 'AUTRE'; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] @@ -102,47 +134,65 @@ class Carrier implements TimestampableInterface, BlamableInterface private ?int $id = null; #[ORM\Column(length: 255)] - #[Groups(['carrier:read'])] + #[Assert\NotBlank(message: 'Le nom du transporteur est obligatoire.', normalizer: 'trim')] + #[Assert\Length( + min: 2, + max: 255, + minMessage: 'Le nom du transporteur doit contenir au moins {{ limit }} caractères.', + maxMessage: 'Le nom du transporteur ne peut dépasser {{ limit }} caractères.', + normalizer: 'trim', + )] + #[Groups(['carrier:read', 'carrier:write:main'])] 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'])] + #[Groups(['carrier:read', 'carrier:write:main'])] 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'])] + #[Assert\Choice( + choices: ['QUALIMAT', 'GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'], + message: 'Type de certification invalide.', + )] + // Obligatoire SAUF en cas LIOT (champ masque) : controle conditionnel dans + // validateMainFormConsistency (RG-4.01). La longueur est bornee par le Choice + // (whitelist EntityConstraintsHaveFrenchMessageTest::EXCLUDED_LENGTH_MIRROR). + #[Groups(['carrier:read', 'carrier:write:main'])] private ?string $certificationType = null; #[ORM\Column(name: 'is_chartered', options: ['default' => false])] + #[Groups(['carrier:write:main'])] private bool $isChartered = false; - /** % d'indexation — renseigne si affrete (RG-4.03). */ + /** % d'indexation — obligatoire si affrete (RG-4.03, validateMainFormConsistency). */ #[ORM\Column(name: 'indexation_rate', type: 'decimal', precision: 5, scale: 2, nullable: true)] - #[Groups(['carrier:read'])] + #[Groups(['carrier:read', 'carrier:write:main'])] private ?string $indexationRate = null; - /** BENNE|FOND_MOUVANT — renseigne si affrete (RG-4.03). */ + /** BENNE|FOND_MOUVANT — obligatoire si affrete (RG-4.03). */ #[ORM\Column(name: 'container_type', length: 12, nullable: true)] - #[Groups(['carrier:read'])] + #[Assert\Choice(choices: ['BENNE', 'FOND_MOUVANT'], message: 'Type de contenant invalide.')] + // Longueur bornee par le Choice (whitelist EXCLUDED_LENGTH_MIRROR). + #[Groups(['carrier:read', 'carrier:write:main'])] private ?string $containerType = null; - /** Volume m3 — renseigne si affrete (RG-4.03). */ + /** Volume m3 — obligatoire si affrete (RG-4.03). */ #[ORM\Column(name: 'volume_m3', type: 'decimal', precision: 10, scale: 2, nullable: true)] - #[Groups(['carrier:read'])] + #[Groups(['carrier:read', 'carrier:write:main'])] 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'])] + #[Groups(['carrier:read', 'carrier:write:main'])] 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'])] + #[Groups(['carrier:read', 'carrier:write:main'])] private ?string $liotPlates = null; // === Sous-collections — EMBARQUEES dans le DETAIL (read-group sur le getter) === @@ -159,7 +209,11 @@ class Carrier implements TimestampableInterface, BlamableInterface private Collection $prices; // === Archive / Soft delete === + // Le groupe de LECTURE est declare sur le getter isArchived() avec + // SerializedName('isArchived') (piege booleen #3 M1) ; le groupe d'ECRITURE + // vit sur la propriete pour que la denormalisation cible setIsArchived. #[ORM\Column(name: 'is_archived', options: ['default' => false])] + #[Groups(['carrier:write:archive'])] private bool $isArchived = false; #[ORM\Column(name: 'archived_at', type: 'datetime_immutable', nullable: true)] @@ -176,6 +230,65 @@ class Carrier implements TimestampableInterface, BlamableInterface $this->prices = new ArrayCollection(); } + /** + * Coherence conditionnelle du formulaire principal (RG-4.01 / RG-4.02 / + * RG-4.03). Decision figee (miroir M2 RG-2.07/2.08) : ces RG inter-champs + * passent par une contrainte d'entite (Assert\Callback + ->atPath()) et NON par + * le CarrierProcessor, afin que chaque 422 porte un propertyPath exploitable + * par useFormErrors (mapping inline sous le champ, pas un toast — ERP-101). + * Jouee par API Platform AVANT le processor, sur POST comme sur PATCH. + * + * Cas LIOT (RG-4.01) : seul liotPlates est pertinent ; les autres champs sont + * masques cote front et le back ne les valide pas (« stocke ce qu'il recoit, + * pas de 422 sur la presence residuelle »). Le nom est compare en majuscules + * car la normalisation UPPER n'intervient qu'au processor (apres validation). + */ + #[Assert\Callback] + public function validateMainFormConsistency(ExecutionContextInterface $context): void + { + if (self::LIOT_NAME === mb_strtoupper(trim((string) $this->name), 'UTF-8')) { + return; + } + + // RG-4.01 : certification obligatoire hors cas LIOT. + if (null === $this->certificationType || '' === $this->certificationType) { + $context->buildViolation('Le type de certification est obligatoire.') + ->atPath('certificationType') + ->addViolation() + ; + } + + // RG-4.02 : certification AUTRE -> decharge obligatoire. + if (self::CERTIFICATION_AUTRE === $this->certificationType && null === $this->dischargeDocument) { + $context->buildViolation('La décharge est obligatoire pour une certification « Autre ».') + ->atPath('dischargeDocument') + ->addViolation() + ; + } + + // RG-4.03 : affrete -> indexation + benne/fond mouvant + volume obligatoires. + if ($this->isChartered) { + if (null === $this->indexationRate) { + $context->buildViolation('Le taux d\'indexation est obligatoire pour un transporteur affrété.') + ->atPath('indexationRate') + ->addViolation() + ; + } + if (null === $this->containerType) { + $context->buildViolation('Le type de contenant est obligatoire pour un transporteur affrété.') + ->atPath('containerType') + ->addViolation() + ; + } + if (null === $this->volumeM3) { + $context->buildViolation('Le volume est obligatoire pour un transporteur affrété.') + ->atPath('volumeM3') + ->addViolation() + ; + } + } + } + public function getId(): ?int { return $this->id; diff --git a/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierProcessor.php b/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierProcessor.php new file mode 100644 index 0000000..27d39a3 --- /dev/null +++ b/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierProcessor.php @@ -0,0 +1,275 @@ + 403, et une requete d'archivage ne peut modifier aucun autre + * champ -> 422. C'est ce qui empeche Bureau d'archiver (manage sans archive). + * 2. Normalisation serveur (RG-4.13) via CarrierFieldNormalizer (name UPPER, + * liotPlates « ; »-normalise). Les champs personne / telephone / email sont + * portes par les sous-ressources Contact (WT7), pas par le formulaire principal. + * 3. Pose / retrait de archivedAt (RG-4.14 true=now, false=null). + * 4. Persistance via le persist_processor Doctrine, avec traduction des + * collisions d'unicite partielle (uq_carrier_name_active) en 409 (RG-4.12 + * doublon de nom ; conflit de restauration). + * + * Les RG conditionnelles du formulaire principal (RG-4.01 certification obligatoire + * sauf LIOT, RG-4.02 AUTRE -> decharge, RG-4.03 affrete -> indexation/benne/volume) + * sont portees par un Assert\Callback + ->atPath() sur l'entite Carrier (joue par + * API Platform AVANT ce processor), pour que chaque 422 porte un propertyPath + * consommable par useFormErrors (mapping inline, pas un toast — convention ERP-101). + * + * @implements ProcessorInterface + */ +final class CarrierProcessor implements ProcessorInterface +{ + /** Champs ecrivables du formulaire principal (groupe carrier:write:main). */ + private const array MAIN_FIELDS = [ + 'name', 'qualimatCarrier', 'certificationType', 'isChartered', + 'indexationRate', 'containerType', 'volumeM3', 'dischargeDocument', 'liotPlates', + ]; + + /** Champ d'archivage (groupe carrier:write:archive). */ + private const string ARCHIVE_FIELD = 'isArchived'; + + private const string PERM_ARCHIVE = 'transport.carriers.archive'; + + /** + * Memoisation du dernier corps de requete decode, clos par le contenu brut. + * payloadKeys() est appele plusieurs fois par requete : on evite de rejouer + * json_decode. La cle etant le contenu lui-meme et le calcul une fonction pure + * de ce contenu, aucune fuite n'est possible entre requetes sur ce service + * partage (un meme corps redonne les memes cles). + */ + private ?string $decodedContent = null; + + /** @var list Cles de premier niveau correspondant au corps memoise. */ + private array $decodedPayloadKeys = []; + + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + private readonly CarrierFieldNormalizer $normalizer, + private readonly Security $security, + private readonly RequestStack $requestStack, + private readonly EntityManagerInterface $em, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof Carrier) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + // Reinitialisation de la memoisation du payload en debut de traitement : + // le service est partage (stateful), on repart du corps de LA requete + // courante et on n'herite jamais des cles decodees d'une requete passee. + $this->decodedContent = null; + $this->decodedPayloadKeys = []; + + $isArchiveRequest = $this->guardArchive($data, $this->writablePayloadKeys()); + + $this->normalize($data); + + try { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } catch (UniqueConstraintViolationException $e) { + // Le seul index unique partiel est uq_carrier_name_active + // (LOWER(name) parmi non-archives/non-deletes — § 2.6). + if ($isArchiveRequest && false === $data->isArchived()) { + // RG-4.14 : restauration en conflit avec un homonyme actif. + throw new ConflictHttpException( + 'Restauration impossible : un autre transporteur a pris le nom entre-temps.', + $e, + ); + } + + // RG-4.12 : doublon de nom de transporteur. + throw new ConflictHttpException( + sprintf('Un transporteur nommé "%s" existe déjà.', (string) $data->getName()), + $e, + ); + } + } + + /** + * RG-4.14 : si le payload bascule reellement isArchived, exige la permission + * archive (403), interdit toute autre modification (422) et pose/retire + * archivedAt. Retourne true si la requete est une requete d'archivage. + * + * Le gating est restreint a la mise a jour d'un transporteur existant ET au + * seul cas ou isArchived change vraiment : un POST (entite non encore geree + * par l'ORM) ou un PATCH « representation complete » renvoyant isArchived + * inchange ne doit declencher ni 403 ni 422 parasite. + * + * @param list $writableKeys cles ecrivables du payload (hors @* et champs inconnus) + */ + private function guardArchive(Carrier $data, array $writableKeys): bool + { + // POST / entite non geree : l'archivage est une action de mise a jour. + if (!$this->em->contains($data)) { + return false; + } + + // isArchived inchange par rapport a l'etat persiste : pas une requete + // d'archivage (cas du PATCH representation complete). + if (!$this->fieldChanged($data, 'isArchived', $data->isArchived())) { + return false; + } + + if (!$this->security->isGranted(self::PERM_ARCHIVE)) { + throw new AccessDeniedHttpException(sprintf( + 'Le champ "%s" requiert la permission "%s".', + self::ARCHIVE_FIELD, + self::PERM_ARCHIVE, + )); + } + + // RG-4.14 : une requete d'archivage ne modifie aucun autre champ ecrivable. + if ([] !== array_diff($writableKeys, [self::ARCHIVE_FIELD])) { + throw new UnprocessableEntityHttpException( + 'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".', + ); + } + + // RG-4.14 (true -> now) / (false -> null). + $data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null); + + return true; + } + + /** + * Normalisation serveur du formulaire principal (RG-4.13). name (non-nullable) + * et liotPlates (cas LIOT) sont les seuls champs texte du formulaire principal ; + * le contact (personne / telephone / email) est normalise par son propre + * processor (sous-ressource, WT7). Les setters ne sont touches que si une valeur + * est presente, pour ne jamais ecraser l'existant lors d'un PATCH partiel. + */ + private function normalize(Carrier $data): void + { + if (null !== $data->getName()) { + $data->setName((string) $this->normalizer->normalizeName($data->getName())); + } + + if (null !== $data->getLiotPlates()) { + $data->setLiotPlates($this->normalizer->normalizeLiotPlates($data->getLiotPlates())); + } + } + + /** + * Cles ecrivables effectivement presentes dans le payload : on retire les cles + * JSON-LD (@id, @context...) et tout champ non rattache a un groupe d'ecriture + * connu. C'est la base du 422 d'archivage (RG-4.14) — sans elles, un PATCH + * « representation complete » porteur de @id ferait croire a une modification + * multi-champs. + * + * @return list + */ + private function writablePayloadKeys(): array + { + $writable = array_merge(self::MAIN_FIELDS, [self::ARCHIVE_FIELD]); + + return array_values(array_intersect($this->payloadKeys(), $writable)); + } + + /** + * Vrai si la valeur courante d'un champ differe de l'etat persiste. Pour une + * entite non geree (creation/POST), l'etat persiste est vide : toute valeur + * non-null est alors un changement. + */ + private function fieldChanged(Carrier $data, string $field, mixed $newValue): bool + { + $original = $this->originalData($data); + + return $newValue !== ($original[$field] ?? null); + } + + /** + * Snapshot des valeurs persistees de l'entite (telles que chargees, avant + * application du payload). Vide pour une entite non geree (POST). + * + * @return array + */ + private function originalData(Carrier $data): array + { + if (!$this->em->contains($data)) { + return []; + } + + return $this->em->getUnitOfWork()->getOriginalEntityData($data); + } + + /** + * Cles de premier niveau effectivement envoyees par le client (payload JSON + * brut), filtrage compris. Pour un PATCH merge-patch+json, ce sont les seuls + * champs modifies. + * + * @return list + */ + private function payloadKeys(): array + { + $request = $this->requestStack->getCurrentRequest(); + if (null === $request) { + return []; + } + + $content = $request->getContent(); + + // Cache hit : meme corps brut que le dernier decodage -> memes cles. + if ($content === $this->decodedContent) { + return $this->decodedPayloadKeys; + } + + $this->decodedContent = $content; + $this->decodedPayloadKeys = $this->extractPayloadKeys($content); + + return $this->decodedPayloadKeys; + } + + /** + * Decode le corps brut et en extrait les cles de premier niveau (chaines). + * Corps vide ou JSON invalide -> aucune cle. + * + * @return list + */ + private function extractPayloadKeys(string $content): array + { + if ('' === $content) { + return []; + } + + try { + $decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return []; + } + + return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : []; + } +} diff --git a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php index 1e2213f..a8978ea 100644 --- a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php +++ b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php @@ -58,6 +58,10 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase 'ProviderAddress::postalCode' => 'Regex {4,5} borne deja la longueur.', // Le Choice {PROSPECT,DEPART,RENDU} borne les valeurs (<= 8 < 20). 'SupplierAddress::addressType' => 'Choice {PROSPECT,DEPART,RENDU} borne deja les valeurs.', + // Le Choice {QUALIMAT,GMP_PLUS,OVOCOM,COMPTE_PROPRE,AUTRE} borne les valeurs (<= 13 < 20). + 'Carrier::certificationType' => 'Choice des 5 certifications borne deja les valeurs.', + // Le Choice {BENNE,FOND_MOUVANT} borne les valeurs (<= 12). + 'Carrier::containerType' => 'Choice {BENNE,FOND_MOUVANT} borne deja les valeurs.', // Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres. 'Site::color' => 'Regex code hex #RRGGBB borne deja la longueur.', ]; diff --git a/tests/Module/Transport/Api/AbstractCarrierApiTestCase.php b/tests/Module/Transport/Api/AbstractCarrierApiTestCase.php index 8a2310f..e613d25 100644 --- a/tests/Module/Transport/Api/AbstractCarrierApiTestCase.php +++ b/tests/Module/Transport/Api/AbstractCarrierApiTestCase.php @@ -31,7 +31,8 @@ use DateTimeImmutable; */ abstract class AbstractCarrierApiTestCase extends AbstractApiTestCase { - protected const string LD = 'application/ld+json'; + protected const string LD = 'application/ld+json'; + protected const string MERGE = 'application/merge-patch+json'; /** Prefixe SIRET des lignes qualimat_carrier seedees par les tests (purge ciblee). */ private const string TEST_SIRET_PREFIX = 'TESTQ'; @@ -63,6 +64,22 @@ abstract class AbstractCarrierApiTestCase extends AbstractApiTestCase return $this->authenticatedClient('admin', 'admin'); } + /** + * Payload minimal valide du formulaire principal (transporteur non-QUALIMAT, + * non affrete) : nom + certification GMP_PLUS. Sert de base aux tests + * d'ecriture / RBAC. + * + * @return array + */ + protected function validMainPayload(string $name): array + { + return [ + 'name' => $name, + 'certificationType' => 'GMP_PLUS', + 'isChartered' => false, + ]; + } + /** * Seede un transporteur minimal (nom en MAJUSCULES, comme le ferait le * futur Processor). Sert aux tests de liste / archivage. diff --git a/tests/Module/Transport/Api/CarrierArchiveTest.php b/tests/Module/Transport/Api/CarrierArchiveTest.php new file mode 100644 index 0000000..3b5eefb --- /dev/null +++ b/tests/Module/Transport/Api/CarrierArchiveTest.php @@ -0,0 +1,36 @@ +createAdminClient(); + + $archived = $this->seedCarrier('Acme Conflict', true); + $this->seedCarrier('Acme Conflict', false); + + $client->request('PATCH', '/api/carriers/'.$archived->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => false], + ]); + + self::assertResponseStatusCodeSame(409); + } +} diff --git a/tests/Module/Transport/Api/CarrierRBACMatrixTest.php b/tests/Module/Transport/Api/CarrierRBACMatrixTest.php new file mode 100644 index 0000000..6e248fd --- /dev/null +++ b/tests/Module/Transport/Api/CarrierRBACMatrixTest.php @@ -0,0 +1,158 @@ +setAutoExit(false); + $exit = $application->run( + new ArrayInput([ + 'command' => 'app:seed-rbac', + '--with-demo-users' => true, + '--password' => self::PWD, + ]), + new NullOutput(), + ); + self::assertSame( + 0, + $exit, + 'app:seed-rbac a echoue : les permissions transport.carriers.* sont-elles synchronisees (app:sync-permissions) ?', + ); + + self::ensureKernelShutdown(); + } + + public function testUsineIsForbiddenEverywhere(): void + { + $seed = $this->seedCarrier('Usine Target'); + $client = $this->authAs('usine'); + + $client->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403); + + $client->request('GET', '/api/carriers/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403); + + $client->request('POST', '/api/carriers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Usine Post'), + ]); + self::assertResponseStatusCodeSame(403); + } + + public function testComptaHasNoAccess(): void + { + $seed = $this->seedCarrier('Compta Target'); + $client = $this->authAs('compta'); + + // PAS view (matrice § 5.2 : Compta sans acces transporteurs). + $client->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403); + + // PAS manage : creation refusee. + $client->request('POST', '/api/carriers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Compta Post'), + ]); + self::assertResponseStatusCodeSame(403); + + $client->request('PATCH', '/api/carriers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['name' => 'Renamed By Compta'], + ]); + self::assertResponseStatusCodeSame(403); + } + + public function testBureauHasViewAndManageButNoArchive(): void + { + $seed = $this->seedCarrier('Bureau Target'); + $client = $this->authAs('bureau'); + + // view + $client->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + // manage : creation OK + $client->request('POST', '/api/carriers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Bureau Created'), + ]); + self::assertResponseStatusCodeSame(201); + + // manage : edition formulaire principal OK + $client->request('PATCH', '/api/carriers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['name' => 'Bureau Renamed'], + ]); + self::assertResponseStatusCodeSame(200); + + // PAS archive : archivage refuse (RG-4.14, gating CarrierProcessor). + $client->request('PATCH', '/api/carriers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + self::assertResponseStatusCodeSame(403); + } + + public function testCommercialeHasViewOnly(): void + { + $seed = $this->seedCarrier('Commerciale Target'); + $client = $this->authAs('commerciale'); + + // view (consultation « Tout ») + $client->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + // PAS manage : creation refusee + $client->request('POST', '/api/carriers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Commerciale Post'), + ]); + self::assertResponseStatusCodeSame(403); + + // PAS manage : edition refusee + $client->request('PATCH', '/api/carriers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['name' => 'Renamed By Commerciale'], + ]); + self::assertResponseStatusCodeSame(403); + } + + private function authAs(string $role): Client + { + return $this->authenticatedClient($role, self::PWD); + } +} diff --git a/tests/Module/Transport/Api/CarrierWriteApiTest.php b/tests/Module/Transport/Api/CarrierWriteApiTest.php new file mode 100644 index 0000000..130575b --- /dev/null +++ b/tests/Module/Transport/Api/CarrierWriteApiTest.php @@ -0,0 +1,240 @@ + decharge), RG-4.03 (affrete -> indexation/benne/volume), + * RG-4.12 (doublon de nom -> 409), RG-4.13 (normalisation), RG-4.14 (archivage + + * mode strict). Jumeau des SupplierApiTest / SupplierPatchStrictTest (M2). + * + * @internal + */ +final class CarrierWriteApiTest extends AbstractCarrierApiTestCase +{ + /** + * RG-4.01 : POST avec qualimatCarrier -> certificationType=QUALIMAT accepte et + * FK persistee (verifiee au detail, qualimatCarrier embarque). + */ + public function testPostQualimatPersistsCertificationAndForeignKey(): void + { + $client = $this->createAdminClient(); + $qualimat = $this->seedQualimatCarrier('Transports Grelillier'); + + $created = $client->request('POST', '/api/carriers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'name' => 'Transports Grelillier', + 'qualimatCarrier' => '/api/qualimat_carriers/'.$qualimat->getId(), + 'certificationType' => 'QUALIMAT', + 'isChartered' => false, + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertSame('QUALIMAT', $created['certificationType']); + + $detail = $client->request('GET', $created['@id'], ['headers' => ['Accept' => self::LD]])->toArray(); + self::assertIsArray($detail['qualimatCarrier']); + self::assertSame((int) $qualimat->getId(), (int) $detail['qualimatCarrier']['id']); + } + + /** + * RG-4.01 (cas LIOT) : nom = LIOT -> certificationType non requis (champ masque) + * et liotPlates accepte (et normalise, RG-4.13). + */ + public function testPostLiotAcceptsPlatesWithoutCertification(): void + { + $client = $this->createAdminClient(); + + $created = $client->request('POST', '/api/carriers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'name' => 'LIOT', + 'liotPlates' => 'ab-123-cd ; ef-456-gh', + 'isChartered' => false, + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertNull($created['certificationType']); + self::assertSame('AB-123-CD; EF-456-GH', $created['liotPlates']); + } + + /** + * RG-4.01 : hors cas LIOT, l'absence de certification est rejetee (422 cible + * sur certificationType). + */ + public function testPostWithoutCertificationOutsideLiotIsRejected(): void + { + $client = $this->createAdminClient(); + + $response = $client->request('POST', '/api/carriers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['name' => 'Sans Certif', 'isChartered' => false], + ]); + + self::assertResponseStatusCodeSame(422); + self::assertViolationOnPath($response, 'certificationType'); + } + + /** + * RG-4.02 : certificationType=AUTRE sans dischargeDocument -> 422 cible ; une + * certification != AUTRE sans decharge passe (201). + */ + public function testAutreCertificationRequiresDischarge(): void + { + $client = $this->createAdminClient(); + + $response = $client->request('POST', '/api/carriers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['name' => 'Sans Decharge', 'certificationType' => 'AUTRE', 'isChartered' => false], + ]); + self::assertResponseStatusCodeSame(422); + self::assertViolationOnPath($response, 'dischargeDocument'); + + // Certification != AUTRE : pas de decharge requise. + $client->request('POST', '/api/carriers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['name' => 'Avec GmpPlus', 'certificationType' => 'GMP_PLUS', 'isChartered' => false], + ]); + self::assertResponseStatusCodeSame(201); + } + + /** + * RG-4.03 : isChartered=true sans indexationRate / containerType / volumeM3 -> + * 422 (violations ciblees) ; complet -> 201. + */ + public function testCharteredRequiresIndexationContainerAndVolume(): void + { + $client = $this->createAdminClient(); + + $response = $client->request('POST', '/api/carriers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['name' => 'Affrete Incomplet', 'certificationType' => 'GMP_PLUS', 'isChartered' => true], + ]); + self::assertResponseStatusCodeSame(422); + self::assertViolationOnPath($response, 'indexationRate'); + self::assertViolationOnPath($response, 'containerType'); + self::assertViolationOnPath($response, 'volumeM3'); + + $client->request('POST', '/api/carriers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'name' => 'Affrete Complet', + 'certificationType' => 'GMP_PLUS', + 'isChartered' => true, + 'indexationRate' => '5.00', + 'containerType' => 'BENNE', + 'volumeM3' => '90.00', + ], + ]); + self::assertResponseStatusCodeSame(201); + } + + /** + * RG-4.12 : nom deja pris (parmi actifs) -> 409. Le meme nom redevient + * disponible apres archivage de l'ancien -> 201. + */ + public function testDuplicateNameReturns409AndIsFreedAfterArchive(): void + { + $client = $this->createAdminClient(); + $existing = $this->seedCarrier('Doublon Co'); + + $client->request('POST', '/api/carriers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Doublon Co'), + ]); + self::assertResponseStatusCodeSame(409); + + // Archivage de l'ancien -> le nom se libere (index partiel sur actifs). + $client->request('PATCH', '/api/carriers/'.$existing->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + self::assertResponseStatusCodeSame(200); + + $client->request('POST', '/api/carriers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Doublon Co'), + ]); + self::assertResponseStatusCodeSame(201); + } + + /** + * RG-4.13 : le nom est persiste en MAJUSCULES (normalisation serveur). + */ + public function testNameIsUpperCasedOnPersist(): void + { + $client = $this->createAdminClient(); + + $created = $client->request('POST', '/api/carriers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('transports x'), + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertSame('TRANSPORTS X', $created['name']); + } + + /** + * RG-4.14 : PATCH isArchived=true par Admin -> 200 + archivedAt rempli ; + * restauration -> archivedAt remis a null. + */ + public function testAdminArchiveSetsArchivedAtAndRestoreClearsIt(): void + { + $client = $this->createAdminClient(); + $carrier = $this->seedCarrier('A Archiver'); + + $archived = $client->request('PATCH', '/api/carriers/'.$carrier->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ])->toArray(); + self::assertResponseStatusCodeSame(200); + self::assertTrue($archived['isArchived']); + self::assertNotNull($archived['archivedAt']); + + $restored = $client->request('PATCH', '/api/carriers/'.$carrier->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => false], + ])->toArray(); + self::assertResponseStatusCodeSame(200); + self::assertFalse($restored['isArchived']); + self::assertNull($restored['archivedAt']); + } + + /** + * RG-4.14 (mode strict) : une requete d'archivage ne peut modifier aucun autre + * champ ecrivable -> 422. + */ + public function testArchiveRequestMixingOtherFieldIsRejected(): void + { + $client = $this->createAdminClient(); + $carrier = $this->seedCarrier('Strict Co'); + + $client->request('PATCH', '/api/carriers/'.$carrier->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true, 'name' => 'Renamed While Archiving'], + ]); + self::assertResponseStatusCodeSame(422); + } + + /** + * Verifie qu'une violation 422 cible bien la propriete attendue (propertyPath), + * gage du mapping inline front (useFormErrors, ERP-101). + */ + private function assertViolationOnPath(object $response, string $path): void + { + /** @var \Symfony\Contracts\HttpClient\ResponseInterface $response */ + $paths = array_column($response->toArray(false)['violations'] ?? [], 'propertyPath'); + + self::assertContains( + $path, + $paths, + sprintf('Aucune violation sur "%s" (paths: %s).', $path, implode(', ', $paths)), + ); + } +} diff --git a/tests/Module/Transport/Application/CarrierFieldNormalizerTest.php b/tests/Module/Transport/Application/CarrierFieldNormalizerTest.php new file mode 100644 index 0000000..c1b1833 --- /dev/null +++ b/tests/Module/Transport/Application/CarrierFieldNormalizerTest.php @@ -0,0 +1,69 @@ +normalizer = new CarrierFieldNormalizer(); + } + + public function testNameIsUpperCasedAndTrimmed(): void + { + self::assertSame('TRANSPORTS X', $this->normalizer->normalizeName(' transports x ')); + self::assertNull($this->normalizer->normalizeName(null)); + } + + public function testPersonNameIsTitleCased(): void + { + self::assertSame('Jean Dupont', $this->normalizer->normalizePersonName('JEAN dupont')); + self::assertNull($this->normalizer->normalizePersonName(' ')); + self::assertNull($this->normalizer->normalizePersonName(null)); + } + + public function testEmailIsLowerCased(): void + { + self::assertSame('marie.martin@seed.test', $this->normalizer->normalizeEmail(' Marie.MARTIN@Seed.Test ')); + self::assertNull($this->normalizer->normalizeEmail(' ')); + self::assertNull($this->normalizer->normalizeEmail(null)); + } + + public function testPhoneKeepsDigitsOnly(): void + { + self::assertSame('0612345678', $this->normalizer->normalizePhone('06.12.34.56.78')); + self::assertSame('33612345678', $this->normalizer->normalizePhone('+33 6 12 34 56 78')); + self::assertNull($this->normalizer->normalizePhone('sans chiffre')); + self::assertNull($this->normalizer->normalizePhone(null)); + } + + /** + * RG-4.01 / RG-4.13 : la saisie « ; »-separee est decoupee, chaque plaque trim + * + UPPER, segments vides ecartes, recomposee avec "; ". + */ + public function testLiotPlatesAreSplitTrimmedUpperedAndRejoined(): void + { + self::assertSame( + 'AB-123-CD; EF-456-GH', + $this->normalizer->normalizeLiotPlates('ab-123-cd ; ef-456-gh'), + ); + // Segments vides (« ;; » / fin de chaine) ecartes. + self::assertSame('AB-123-CD', $this->normalizer->normalizeLiotPlates(' ab-123-cd ; ; ')); + self::assertNull($this->normalizer->normalizeLiotPlates(' ; ; ')); + self::assertNull($this->normalizer->normalizeLiotPlates(null)); + } +} From 397fb22c62ee22e8af2358e3ab3b1b03e220a212 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 16 Jun 2026 08:34:28 +0200 Subject: [PATCH 05/13] feat(transport) : endpoint recherche QualimatCarrier (ERP-156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/qualimat_carriers?search= pour la saisie assistee du nom (RG-4.01, spec-back § 4.7) : recherche fuzzy sur name (+ siret), restreinte aux lignes actives (is_active = true), triee name ASC, paginee (regle n°13). - QualimatCarrierRepositoryInterface + DoctrineQualimatCarrierRepository : QueryBuilder de recherche (forcage is_active cote serveur, fuzzy multi-champs). - QualimatCarrierSearchProvider : provider de la GetCollection (pagination Hydra + echappatoire ?pagination=false), branche uniquement sur la collection. - ApiResource : provider custom sur GetCollection, retrait des ApiFilter natifs (incapables d'unifier name/siret sous ?search= ni d'imposer l'actif). Mapping ORM inchange (schema:update reste no-op). Aucune ecriture exposee. - Tests : actifs seuls, tri name, match siret, pagination Hydra, 403 sans perm. --- .../Domain/Entity/QualimatCarrier.php | 19 +-- .../QualimatCarrierRepositoryInterface.php | 25 ++++ .../QualimatCarrierSearchProvider.php | 64 +++++++++ .../DoctrineQualimatCarrierRepository.php | 57 ++++++++ .../Api/QualimatCarrierSearchTest.php | 129 ++++++++++++++++++ 5 files changed, 285 insertions(+), 9 deletions(-) create mode 100644 src/Module/Transport/Domain/Repository/QualimatCarrierRepositoryInterface.php create mode 100644 src/Module/Transport/Infrastructure/ApiPlatform/State/Provider/QualimatCarrierSearchProvider.php create mode 100644 src/Module/Transport/Infrastructure/Doctrine/DoctrineQualimatCarrierRepository.php create mode 100644 tests/Module/Transport/Api/QualimatCarrierSearchTest.php diff --git a/src/Module/Transport/Domain/Entity/QualimatCarrier.php b/src/Module/Transport/Domain/Entity/QualimatCarrier.php index b7c471c..35d951d 100644 --- a/src/Module/Transport/Domain/Entity/QualimatCarrier.php +++ b/src/Module/Transport/Domain/Entity/QualimatCarrier.php @@ -4,13 +4,10 @@ declare(strict_types=1); namespace App\Module\Transport\Domain\Entity; -use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; -use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; -use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; -use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use App\Module\Transport\Infrastructure\ApiPlatform\State\Provider\QualimatCarrierSearchProvider; use DateTimeImmutable; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; @@ -26,8 +23,9 @@ use Symfony\Component\Serializer\Attribute\SerializedName; * - cible de la FK editable `carrier.qualimat_carrier_id` (§ 2.5) ; * - embarquee (groupe `qualimat:read`) dans la liste et le detail Carrier pour * afficher statut + date de validite QUALIMAT (RG-4.04) ; - * - endpoint de recherche `GET /api/qualimat_carriers?...` pour la saisie - * assistee du nom (§ 4.7) — filtres built-in name/siret (partiel), isActive. + * - endpoint de recherche `GET /api/qualimat_carriers?search=` pour la saisie + * assistee du nom (§ 4.7) — fuzzy name (+ siret), SEULEMENT les lignes actives, + * tri name ASC, paginee ; logique portee par QualimatCarrierSearchProvider. * * La table reste hors `schema_filter` Doctrine (doctrine.yaml) : c'est la * migration modulaire Version20260612150000 qui possede son DDL et ses COMMENT @@ -36,8 +34,14 @@ use Symfony\Component\Serializer\Attribute\SerializedName; */ #[ApiResource( operations: [ + // Saisie assistee (§ 4.7 / RG-4.01) : ?search= fuzzy name (+ siret), + // SEULEMENT les lignes actives, tri name ASC, paginee. La logique vit + // dans le provider (forcage is_active + recherche multi-champs) car un + // SearchFilter natif ne sait ni unifier name/siret sous un seul ?search=, + // ni imposer cote serveur le filtre actif. new GetCollection( security: "is_granted('transport.carriers.view')", + provider: QualimatCarrierSearchProvider::class, normalizationContext: ['groups' => ['qualimat:read', 'default:read']], ), new Get( @@ -46,9 +50,6 @@ use Symfony\Component\Serializer\Attribute\SerializedName; ), ], )] -#[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 : diff --git a/src/Module/Transport/Domain/Repository/QualimatCarrierRepositoryInterface.php b/src/Module/Transport/Domain/Repository/QualimatCarrierRepositoryInterface.php new file mode 100644 index 0000000..26a1577 --- /dev/null +++ b/src/Module/Transport/Domain/Repository/QualimatCarrierRepositoryInterface.php @@ -0,0 +1,25 @@ + : + * - restreint aux lignes actives (is_active = true) — regle serveur, pas un + * filtre client desactivable ; + * - recherche fuzzy insensible a la casse sur name (+ siret) ; + * - tri par name ASC ; + * - pagination Hydra (regle n°13) + echappatoire ?pagination=false (selects). + * + * Branche uniquement sur la GetCollection ; le Get unitaire reste servi par le + * provider ORM par defaut (lecture seule, aucune ecriture exposee). + * + * @implements ProviderInterface + */ +final class QualimatCarrierSearchProvider implements ProviderInterface +{ + public function __construct( + #[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineQualimatCarrierRepository')] + private readonly QualimatCarrierRepositoryInterface $repository, + private readonly Pagination $pagination, + ) {} + + /** + * @return list|Paginator + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|Paginator + { + $filters = $context['filters'] ?? []; + $search = $filters['search'] ?? null; + + $qb = $this->repository->createSearchQueryBuilder(is_string($search) ? $search : null); + + // 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 — aucune jointure to-many (referentiel plat). + return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false)); + } +} diff --git a/src/Module/Transport/Infrastructure/Doctrine/DoctrineQualimatCarrierRepository.php b/src/Module/Transport/Infrastructure/Doctrine/DoctrineQualimatCarrierRepository.php new file mode 100644 index 0000000..33b351c --- /dev/null +++ b/src/Module/Transport/Infrastructure/Doctrine/DoctrineQualimatCarrierRepository.php @@ -0,0 +1,57 @@ + + */ +class DoctrineQualimatCarrierRepository extends ServiceEntityRepository implements QualimatCarrierRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, QualimatCarrier::class); + } + + public function createSearchQueryBuilder(?string $search = null): QueryBuilder + { + // Saisie assistee (§ 4.7) : on ne propose QUE des transporteurs QUALIMAT + // actifs (is_active = true), tries par nom. Le forcage de l'actif est une + // regle serveur (pas un filtre client) — les lignes soft-deletees par la + // synchro restent invisibles. + $qb = $this->createQueryBuilder('q') + ->andWhere('q.isActive = true') + ->orderBy('q.name', 'ASC') + ; + + $this->applySearch($qb, $search); + + return $qb; + } + + /** + * Recherche fuzzy insensible a la casse sur le nom (+ siret) du transporteur + * QUALIMAT (§ 4.7 / RG-4.01). 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(q.name) LIKE :search OR LOWER(q.siret) LIKE :search') + ->setParameter('search', $pattern) + ; + } +} diff --git a/tests/Module/Transport/Api/QualimatCarrierSearchTest.php b/tests/Module/Transport/Api/QualimatCarrierSearchTest.php new file mode 100644 index 0000000..5bb56c0 --- /dev/null +++ b/tests/Module/Transport/Api/QualimatCarrierSearchTest.php @@ -0,0 +1,129 @@ +setAutoExit(false); + $exit = $application->run( + new ArrayInput([ + 'command' => 'app:seed-rbac', + '--with-demo-users' => true, + '--password' => self::PWD, + ]), + new NullOutput(), + ); + self::assertSame(0, $exit, 'app:seed-rbac a echoue (permissions transport.carriers.* synchronisees ?).'); + + self::ensureKernelShutdown(); + } + + public function testSearchReturnsOnlyActiveOrderedByName(): void + { + // Marqueur unique partage par les 3 lignes : isole la recherche d'eventuelles + // autres lignes du referentiel. + $this->insertQualimat('QSEARCH GAMMA', true, 'A1'); + $this->insertQualimat('QSEARCH ALPHA', true, 'A2'); + $this->insertQualimat('QSEARCH BETA', false, 'A3'); // inactive -> exclue + + $client = $this->createAdminClient(); + $client->request('GET', '/api/qualimat_carriers?search=qsearch', ['headers' => ['Accept' => self::LD]]); + self::assertResponseIsSuccessful(); + + $data = $client->getResponse()->toArray(); + $names = array_column($data['member'], 'name'); + + self::assertSame(2, $data['totalItems'], 'Seules les 2 lignes actives doivent remonter (BETA inactive exclue).'); + self::assertSame(['QSEARCH ALPHA', 'QSEARCH GAMMA'], $names, 'Tri name ASC, sans la ligne inactive.'); + } + + public function testSearchMatchesSiret(): void + { + // Le nom ne porte pas le marqueur : la correspondance se fait via le siret. + $this->insertQualimat('TRANSPORTEUR SANS MARQUEUR', true, 'SIRETHIT1'); + + $client = $this->createAdminClient(); + $client->request('GET', '/api/qualimat_carriers?search=testqsirethit1', ['headers' => ['Accept' => self::LD]]); + self::assertResponseIsSuccessful(); + + $data = $client->getResponse()->toArray(); + self::assertSame(1, $data['totalItems'], 'La recherche fuzzy doit aussi cibler le siret.'); + self::assertSame('TRANSPORTEUR SANS MARQUEUR', $data['member'][0]['name']); + } + + public function testCollectionExposesHydraPagination(): void + { + $this->insertQualimat('QPAGE UN', true, 'P1'); + $this->insertQualimat('QPAGE DEUX', true, 'P2'); + $this->insertQualimat('QPAGE TROIS', true, 'P3'); + + $client = $this->createAdminClient(); + $client->request('GET', '/api/qualimat_carriers?search=qpage&itemsPerPage=2', ['headers' => ['Accept' => self::LD]]); + self::assertResponseIsSuccessful(); + + $data = $client->getResponse()->toArray(); + self::assertArrayHasKey('totalItems', $data, 'La collection doit exposer totalItems.'); + self::assertArrayHasKey('view', $data, 'La collection doit exposer view quand totalItems > itemsPerPage.'); + self::assertIsArray($data['member']); + self::assertSame(3, $data['totalItems']); + self::assertCount(2, $data['member'], 'La page doit etre bornee a itemsPerPage=2.'); + } + + public function testForbiddenWithoutPermission(): void + { + // Usine : aucun acces transporteurs (matrice § 5.2) -> 403 sur la recherche. + $client = $this->authenticatedClient('usine', self::PWD); + $client->request('GET', '/api/qualimat_carriers', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403); + } + + /** + * Insere une ligne qualimat_carrier de test en DBAL brut (l'entite mappee est + * en lecture seule). SIRET prefixe TESTQ pour la purge ciblee du tearDown. + */ + private function insertQualimat(string $name, bool $isActive, string $siretSuffix): void + { + $this->getEm()->getConnection()->insert('qualimat_carrier', [ + 'siret' => self::SIRET_PREFIX.$siretSuffix, + 'name' => $name, + 'status' => 'Valide', + 'validity_date' => '2027-12-31', + 'is_active' => $isActive ? 'true' : 'false', + 'last_synced_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'), + ]); + } +} From 7012306a789d2ddcc78d6fb1eadfa7380a17110c Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 16 Jun 2026 09:19:20 +0200 Subject: [PATCH 06/13] feat(transport) : sous-ressource adresses transporteur (ERP-159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/carriers/{id}/addresses + PATCH/DELETE /api/carrier_addresses/{id} (security transport.carriers.manage), spec-back § 4.5. Jumelle de SupplierAddress (M2) / ProviderAddress (M3), sans address_type ni M2M. - CarrierAddress : ajout #[ApiResource] (Get/Post/Patch/Delete) + groupe d'ecriture carrier:write:addresses + contraintes FR. RG-4.06 : code postal ^[0-9]{4,5}$ (Assert\Regex). Mapping ORM/colonnes inchange. - CarrierAddressProcessor : rattachement parent (404 si absent) + RG-4.05 (transporteur affrete -> Pays/CP/Ville/Adresse obligatoires, 422 par champ). RG-4.05 portee par le processor car le parent est indisponible a la validation Symfony sur un POST sous-ressource read:false. RG-4.07 = front (PATCH accepte). - EXCLUDED_LENGTH_MIRROR : CarrierAddress::postalCode (Regex borne la longueur). - Tests : CP invalide 422, affrete incomplet 422, affrete complet 201, PATCH/DELETE OK (manage), 403 sans manage. --- .../Domain/Entity/CarrierAddress.php | 89 ++++++++- .../Processor/CarrierAddressProcessor.php | 134 ++++++++++++++ ...EntityConstraintsHaveFrenchMessageTest.php | 2 + .../Transport/Api/CarrierAddressApiTest.php | 172 ++++++++++++++++++ 4 files changed, 389 insertions(+), 8 deletions(-) create mode 100644 src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierAddressProcessor.php create mode 100644 tests/Module/Transport/Api/CarrierAddressApiTest.php diff --git a/src/Module/Transport/Domain/Entity/CarrierAddress.php b/src/Module/Transport/Domain/Entity/CarrierAddress.php index fab9fea..80bf418 100644 --- a/src/Module/Transport/Domain/Entity/CarrierAddress.php +++ b/src/Module/Transport/Domain/Entity/CarrierAddress.php @@ -4,22 +4,86 @@ declare(strict_types=1); namespace App\Module\Transport\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierAddressProcessor; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Trait\TimestampableBlamableTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Validator\Constraints as Assert; /** * Adresse d'un transporteur (1:n) — onglet Adresse (M4). Jumelle de * SupplierAddress (M2), version simplifiee (pas de type d'adresse, pas de M2M * sites/categories sur l'adresse : les sites du M4 vivent dans l'onglet Prix). * - * WT3 (ERP-155/157) = LECTURE seule : proprietes en `carrier:item:read` - * (embarquees au detail du transporteur). Les sous-ressources d'ecriture - * (POST/PATCH/DELETE) + RG-4.05→4.07 arrivent au worktree dedie (WT6). + * Lecture : proprietes en `carrier:item:read` (embarquees au detail du + * transporteur). Ecriture : groupe `carrier:write:addresses`. + * + * Sous-ressource API (ERP-159, spec § 4.5) — jumelle de SupplierAddress (M2) / + * ProviderAddress (M3), sans address_type ni M2M (les sites du M4 vivent dans + * l'onglet Prix) : + * - POST /api/carriers/{carrierId}/addresses : creation rattachee au + * transporteur parent (Link toProperty 'carrier'), security + * transport.carriers.manage. + * - PATCH / DELETE /api/carrier_addresses/{id} : security + * transport.carriers.manage. + * - GET /api/carrier_addresses/{id} : lecture unitaire (security view) — la + * lecture courante reste via le parent. Pas de GET collection autonome. + * Tout passe par le CarrierAddressProcessor (rattachement parent + RG-4.05). + * + * Regles de l'onglet Adresse : + * - RG-4.06 : code postal a 4 ou 5 chiffres (Assert\Regex ; pas de controle + * CP/ville serveur, l'autocomplete BAN est front). + * - RG-4.05 : si le transporteur est affrete (isChartered), l'adresse devient + * obligatoire (Pays / CP / Ville / Adresse) — validation conditionnelle portee + * par le CarrierAddressProcessor (le parent n'est pas disponible a la + * validation Symfony sur un POST sous-ressource en read:false). + * - RG-4.07 : masquage du bouton « Valider » si QUALIMAT = front ; le back + * accepte le PATCH normalement (aucune garde back specifique). + * + * Audite (#[Auditable]) + Timestampable / Blamable. */ +#[ApiResource( + operations: [ + new Get( + security: "is_granted('transport.carriers.view')", + normalizationContext: ['groups' => ['carrier:item:read', 'default:read']], + ), + new Post( + uriTemplate: '/carriers/{carrierId}/addresses', + uriVariables: [ + 'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'), + ], + // read:false : pas de stade lecture du parent. Le Link toProperty + // resoudrait l'enfant (SELECT CarrierAddress ... WHERE carrier = :id) + // et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache + // manuellement par CarrierAddressProcessor::linkParent (404 si absent). + read: false, + security: "is_granted('transport.carriers.manage')", + normalizationContext: ['groups' => ['carrier:item:read', 'default:read']], + denormalizationContext: ['groups' => ['carrier:write:addresses']], + processor: CarrierAddressProcessor::class, + ), + new Patch( + security: "is_granted('transport.carriers.manage')", + normalizationContext: ['groups' => ['carrier:item:read', 'default:read']], + denormalizationContext: ['groups' => ['carrier:write:addresses']], + processor: CarrierAddressProcessor::class, + ), + new Delete( + security: "is_granted('transport.carriers.manage')", + processor: CarrierAddressProcessor::class, + ), + ], +)] #[ORM\Entity] #[ORM\Table(name: 'carrier_address')] #[ORM\Index(name: 'idx_carrier_address_carrier', columns: ['carrier_id'])] @@ -41,23 +105,32 @@ class CarrierAddress implements TimestampableInterface, BlamableInterface private ?Carrier $carrier = null; #[ORM\Column(length: 80, options: ['default' => 'France'])] - #[Groups(['carrier:item:read'])] + #[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['carrier:item:read', 'carrier:write:addresses'])] private string $country = 'France'; + // RG-4.06 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur, + // l'autocomplete BAN est front). Le Regex borne deja la longueur (<= 5) : pas + // de Length redondant (whitelist EXCLUDED_LENGTH_MIRROR). Nullable : obligatoire + // seulement si affrete (RG-4.05, garde CarrierAddressProcessor). #[ORM\Column(name: 'postal_code', length: 20, nullable: true)] - #[Groups(['carrier:item:read'])] + #[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')] + #[Groups(['carrier:item:read', 'carrier:write:addresses'])] private ?string $postalCode = null; #[ORM\Column(length: 120, nullable: true)] - #[Groups(['carrier:item:read'])] + #[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['carrier:item:read', 'carrier:write:addresses'])] private ?string $city = null; #[ORM\Column(length: 255, nullable: true)] - #[Groups(['carrier:item:read'])] + #[Assert\Length(max: 255, maxMessage: 'L\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['carrier:item:read', 'carrier:write:addresses'])] private ?string $street = null; #[ORM\Column(name: 'street_complement', length: 255, nullable: true)] - #[Groups(['carrier:item:read'])] + #[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['carrier:item:read', 'carrier:write:addresses'])] private ?string $streetComplement = null; #[ORM\Column(options: ['default' => 0])] diff --git a/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierAddressProcessor.php b/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierAddressProcessor.php new file mode 100644 index 0000000..ad88d2a --- /dev/null +++ b/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierAddressProcessor.php @@ -0,0 +1,134 @@ + + */ +final class CarrierAddressProcessor implements ProcessorInterface +{ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private readonly ProcessorInterface $removeProcessor, + private readonly EntityManagerInterface $em, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof CarrierAddress) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof DeleteOperationInterface) { + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } + + $this->linkParent($data, $uriVariables); + $this->guardCharteredAddress($data); + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + /** + * Rattache l'adresse au transporteur parent de la sous-ressource POST + * (/carriers/{carrierId}/addresses) : la relation n'est pas peuplee + * automatiquement par le Link sur une ecriture. Sur PATCH, no-op. + */ + private function linkParent(CarrierAddress $address, array $uriVariables): void + { + if (null !== $address->getCarrier()) { + return; + } + + $carrierId = $uriVariables['carrierId'] ?? null; + if (null === $carrierId) { + return; + } + + $carrier = $carrierId instanceof Carrier + ? $carrierId + : $this->em->getRepository(Carrier::class)->find($carrierId); + + // read:false sur le POST : sans stade lecture, un parent introuvable n'est + // plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la + // contrainte carrier_id NOT NULL). + if (!$carrier instanceof Carrier) { + throw new NotFoundHttpException('Transporteur introuvable.'); + } + + $address->setCarrier($carrier); + } + + /** + * RG-4.05 : si le transporteur parent est affrete (isChartered), l'adresse doit + * porter Pays / Code postal / Ville / Adresse. Chaque champ manquant -> une + * violation 422 sur son propre propertyPath (mapping inline ERP-101). La + * validation porte sur l'ETAT RESULTANT de l'adresse (apres application du + * payload), donc identique sur POST et sur PATCH partiel. Sans affretement, + * l'adresse reste partielle (champs nullable, RG-4.06 inchangee). + */ + private function guardCharteredAddress(CarrierAddress $address): void + { + $carrier = $address->getCarrier(); + if (!$carrier instanceof Carrier || !$carrier->isChartered()) { + return; + } + + $required = [ + 'country' => [$address->getCountry(), 'Le pays est obligatoire pour un transporteur affrété.'], + 'postalCode' => [$address->getPostalCode(), 'Le code postal est obligatoire pour un transporteur affrété.'], + 'city' => [$address->getCity(), 'La ville est obligatoire pour un transporteur affrété.'], + 'street' => [$address->getStreet(), 'L\'adresse est obligatoire pour un transporteur affrété.'], + ]; + + $violations = new ConstraintViolationList(); + foreach ($required as $path => [$value, $message]) { + if (null === $value || '' === trim($value)) { + $violations->add(new ConstraintViolation($message, null, [], $address, $path, $value)); + } + } + + if (0 < $violations->count()) { + throw new ValidationException($violations); + } + } +} diff --git a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php index a8978ea..799ff09 100644 --- a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php +++ b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php @@ -56,6 +56,8 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase 'SupplierAddress::postalCode' => 'Regex {4,5} borne deja la longueur.', // Idem cote prestataire (meme Regex CP — M3 Technique). 'ProviderAddress::postalCode' => 'Regex {4,5} borne deja la longueur.', + // Idem cote transporteur (meme Regex CP — M4 Transport). + 'CarrierAddress::postalCode' => 'Regex {4,5} borne deja la longueur.', // Le Choice {PROSPECT,DEPART,RENDU} borne les valeurs (<= 8 < 20). 'SupplierAddress::addressType' => 'Choice {PROSPECT,DEPART,RENDU} borne deja les valeurs.', // Le Choice {QUALIMAT,GMP_PLUS,OVOCOM,COMPTE_PROPRE,AUTRE} borne les valeurs (<= 13 < 20). diff --git a/tests/Module/Transport/Api/CarrierAddressApiTest.php b/tests/Module/Transport/Api/CarrierAddressApiTest.php new file mode 100644 index 0000000..53ea6f3 --- /dev/null +++ b/tests/Module/Transport/Api/CarrierAddressApiTest.php @@ -0,0 +1,172 @@ + 422 ; + * - RG-4.05 : transporteur affrete + adresse incomplete -> 422 (par champ) ; + * - RG-4.05 : transporteur affrete + adresse complete -> 201 ; + * - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul). + * + * @internal + */ +final class CarrierAddressApiTest extends AbstractCarrierApiTestCase +{ + private const string PWD = RbacDemoFixtures::DEMO_PASSWORD; + + protected function setUp(): void + { + parent::setUp(); + + // Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin + // qu'en recette), requis pour les tests de permission (bureau/commerciale). + self::bootKernel(); + $application = new Application(self::$kernel); + $application->setAutoExit(false); + $exit = $application->run( + new ArrayInput([ + 'command' => 'app:seed-rbac', + '--with-demo-users' => true, + '--password' => self::PWD, + ]), + new NullOutput(), + ); + self::assertSame(0, $exit, 'app:seed-rbac a echoue (permissions transport.carriers.* synchronisees ?).'); + + self::ensureKernelShutdown(); + } + + public function testInvalidPostalCodeReturns422(): void + { + // Transporteur NON affrete : RG-4.05 ne s'applique pas, seule RG-4.06 joue. + $carrier = $this->seedCarrierWithChartered('Cp Invalide', false); + $client = $this->createAdminClient(); + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['postalCode' => '123'], // 3 chiffres -> Regex KO + ]); + self::assertResponseStatusCodeSame(422); + } + + public function testCharteredCarrierIncompleteAddressReturns422(): void + { + // Transporteur affrete : RG-4.05 exige Pays/CP/Ville/Adresse. CP valide mais + // ville + rue manquantes -> 422 conditionnelle (CarrierAddressProcessor). + $carrier = $this->seedCarrierWithChartered('Affrete Incomplet', true); + $client = $this->createAdminClient(); + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['postalCode' => '86000'], + ]); + self::assertResponseStatusCodeSame(422); + } + + public function testCharteredCarrierCompleteAddressIsCreated(): void + { + $carrier = $this->seedCarrierWithChartered('Affrete Complet', true); + $client = $this->createAdminClient(); + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'country' => 'France', + 'postalCode' => '86000', + 'city' => 'Poitiers', + 'street' => '12 rue des Acacias', + ], + ]); + self::assertResponseStatusCodeSame(201); + } + + public function testPatchAndDeleteSucceedWithManage(): void + { + $address = $this->seedAddress('Patch Delete', false); + $client = $this->authenticatedClient('bureau', self::PWD); // manage (matrice § 5.2) + + // PATCH (manage) -> 200 + $client->request('PATCH', '/api/carrier_addresses/'.$address->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['city' => 'Lyon'], + ]); + self::assertResponseStatusCodeSame(200); + + // DELETE (manage) -> 204 + $client->request('DELETE', '/api/carrier_addresses/'.$address->getId()); + self::assertResponseStatusCodeSame(204); + } + + public function testWriteForbiddenWithoutManage(): void + { + $address = $this->seedAddress('Forbidden', false); + $carrier = $address->getCarrier(); + self::assertNotNull($carrier); + $client = $this->authenticatedClient('commerciale', self::PWD); // view seul + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['postalCode' => '86000', 'city' => 'Poitiers', 'street' => '1 rue X'], + ]); + self::assertResponseStatusCodeSame(403); + + $client->request('PATCH', '/api/carrier_addresses/'.$address->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['city' => 'Lyon'], + ]); + self::assertResponseStatusCodeSame(403); + + $client->request('DELETE', '/api/carrier_addresses/'.$address->getId()); + self::assertResponseStatusCodeSame(403); + } + + /** + * Seede un transporteur minimal en controlant le flag affrete (RG-4.05). + */ + private function seedCarrierWithChartered(string $name, bool $isChartered): Carrier + { + $em = $this->getEm(); + $carrier = new Carrier(); + $carrier->setName(mb_strtoupper($name, 'UTF-8')); + $carrier->setCertificationType('GMP_PLUS'); + $carrier->setIsChartered($isChartered); + $em->persist($carrier); + $em->flush(); + + return $carrier; + } + + /** + * Seede un transporteur + une adresse rattachee (pour les tests PATCH/DELETE). + */ + private function seedAddress(string $name, bool $isChartered): CarrierAddress + { + $em = $this->getEm(); + $carrier = $this->seedCarrierWithChartered($name, $isChartered); + + $address = new CarrierAddress(); + $address->setCarrier($carrier); + $address->setPostalCode('86000'); + $address->setCity('Poitiers'); + $address->setStreet('12 rue des Acacias'); + $carrier->addAddress($address); + $em->persist($address); + $em->flush(); + + return $address; + } +} From daa8224b8b2e26fd90c52f71597d5e0e877e1070 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 16 Jun 2026 10:18:10 +0200 Subject: [PATCH 07/13] feat(transport) : sous-ressource contacts transporteur (ERP-160) --- .../Domain/Entity/CarrierContact.php | 111 ++++++++- .../Processor/CarrierContactProcessor.php | 235 ++++++++++++++++++ .../Transport/Api/CarrierContactApiTest.php | 179 +++++++++++++ 3 files changed, 519 insertions(+), 6 deletions(-) create mode 100644 src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierContactProcessor.php create mode 100644 tests/Module/Transport/Api/CarrierContactApiTest.php diff --git a/src/Module/Transport/Domain/Entity/CarrierContact.php b/src/Module/Transport/Domain/Entity/CarrierContact.php index 1f5e8fb..93a507a 100644 --- a/src/Module/Transport/Domain/Entity/CarrierContact.php +++ b/src/Module/Transport/Domain/Entity/CarrierContact.php @@ -4,21 +4,80 @@ declare(strict_types=1); namespace App\Module\Transport\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierContactProcessor; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Trait\TimestampableBlamableTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Validator\Constraints as Assert; /** * Contact d'un transporteur (1:n) — onglet Contact (M4). Jumeau de * SupplierContact (M2) : au moins un champ rempli (RG-4.08, garanti par le * CHECK chk_carrier_contact_filled + le Processor), max 2 telephones. * - * WT3 (ERP-155/157) = LECTURE seule : proprietes en `carrier:item:read` - * (embarquees au detail). Les sous-ressources d'ecriture arrivent au WT7. + * Lecture : proprietes en `carrier:item:read` (embarquees au detail du + * transporteur). Ecriture : groupe `carrier:write:contacts`. + * + * Sous-ressource API (ERP-160, spec § 4.5) — jumelle de SupplierContact (M2) : + * - POST /api/carriers/{carrierId}/contacts : creation rattachee au + * transporteur parent (Link toProperty 'carrier'), security + * transport.carriers.manage. + * - PATCH / DELETE /api/carrier_contacts/{id} : security + * transport.carriers.manage. + * - GET /api/carrier_contacts/{id} : lecture unitaire (security view) — la + * lecture courante reste via le parent. Pas de GET collection autonome. + * Tout passe par le CarrierContactProcessor (rattachement parent + RG-4.08 + + * RG-4.13). + * + * Telephones (RG-4.08, max 2) : le contrat d'ecriture expose un tableau virtuel + * `phones` (liste dynamique cote front « x1, +1 possible, max 2 ») mappe par le + * Processor vers `phonePrimary` / `phoneSecondary` (un 3e numero -> 422). Les + * deux colonnes scalaires restent en lecture seule (embarquees au detail). + * + * Audite (#[Auditable]) + Timestampable / Blamable. */ +#[ApiResource( + operations: [ + new Get( + security: "is_granted('transport.carriers.view')", + normalizationContext: ['groups' => ['carrier:item:read', 'default:read']], + ), + new Post( + uriTemplate: '/carriers/{carrierId}/contacts', + uriVariables: [ + 'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'), + ], + // read:false : pas de stade lecture du parent. Le Link toProperty + // resoudrait l'enfant (SELECT CarrierContact ... WHERE carrier = :id) + // et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache + // manuellement par CarrierContactProcessor::linkParent (404 si absent). + read: false, + security: "is_granted('transport.carriers.manage')", + normalizationContext: ['groups' => ['carrier:item:read', 'default:read']], + denormalizationContext: ['groups' => ['carrier:write:contacts']], + processor: CarrierContactProcessor::class, + ), + new Patch( + security: "is_granted('transport.carriers.manage')", + normalizationContext: ['groups' => ['carrier:item:read', 'default:read']], + denormalizationContext: ['groups' => ['carrier:write:contacts']], + processor: CarrierContactProcessor::class, + ), + new Delete( + security: "is_granted('transport.carriers.manage')", + processor: CarrierContactProcessor::class, + ), + ], +)] #[ORM\Entity] #[ORM\Table(name: 'carrier_contact')] #[ORM\Index(name: 'idx_carrier_contact_carrier', columns: ['carrier_id'])] @@ -39,18 +98,27 @@ class CarrierContact implements TimestampableInterface, BlamableInterface #[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] private ?Carrier $carrier = null; + // RG-4.08 : aucun champ obligatoire isolement (≥ 1 champ rempli, garde + // Processor + CHECK BDD). Les colonnes restent nullable au niveau ORM. #[ORM\Column(name: 'first_name', length: 120, nullable: true)] - #[Groups(['carrier:item:read'])] + #[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['carrier:item:read', 'carrier:write:contacts'])] private ?string $firstName = null; #[ORM\Column(name: 'last_name', length: 120, nullable: true)] - #[Groups(['carrier:item:read'])] + #[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['carrier:item:read', 'carrier:write:contacts'])] private ?string $lastName = null; #[ORM\Column(name: 'job_title', length: 120, nullable: true)] - #[Groups(['carrier:item:read'])] + #[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['carrier:item:read', 'carrier:write:contacts'])] private ?string $jobTitle = null; + // Telephones en LECTURE seule : alimentes en ecriture via le tableau virtuel + // `phones` (mappe par le CarrierContactProcessor). Pas de groupe write -> pas + // de saisie directe (et donc exemptes du miroir Assert\Length, le Processor + // borne deja la longueur). #[ORM\Column(name: 'phone_primary', length: 20, nullable: true)] #[Groups(['carrier:item:read'])] private ?string $phonePrimary = null; @@ -60,9 +128,22 @@ class CarrierContact implements TimestampableInterface, BlamableInterface private ?string $phoneSecondary = null; #[ORM\Column(length: 180, nullable: true)] - #[Groups(['carrier:item:read'])] + #[Assert\Email(message: 'L\'adresse email n\'est pas valide.')] + #[Assert\Length(max: 180, maxMessage: 'L\'email ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['carrier:item:read', 'carrier:write:contacts'])] private ?string $email = null; + /** + * Telephones en ecriture (RG-4.08, max 2), NON persiste : le + * CarrierContactProcessor normalise chaque numero (RG-4.13) puis le mappe vers + * phonePrimary / phoneSecondary. null = non fourni (PATCH partiel : on ne + * touche pas aux telephones existants). Un 3e numero -> 422 sur `phones`. + * + * @var null|list + */ + #[Groups(['carrier:write:contacts'])] + private ?array $phones = null; + #[ORM\Column(options: ['default' => 0])] private int $position = 0; @@ -155,6 +236,24 @@ class CarrierContact implements TimestampableInterface, BlamableInterface return $this; } + /** + * @return null|list + */ + public function getPhones(): ?array + { + return $this->phones; + } + + /** + * @param null|list $phones + */ + public function setPhones(?array $phones): static + { + $this->phones = $phones; + + return $this; + } + public function getPosition(): int { return $this->position; diff --git a/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierContactProcessor.php b/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierContactProcessor.php new file mode 100644 index 0000000..6291feb --- /dev/null +++ b/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierContactProcessor.php @@ -0,0 +1,235 @@ + phonePrimary/phoneSecondary + * (max 2, chiffres uniquement), puis garde RG-4.08 (≥ 1 champ) avant + * persistance. + * - DELETE : aucune regle metier specifique (suppression physique directe). + * + * RG-4.08 vit ICI (double du CHECK BDD) pour transformer une violation SQL (500 + * generique) en 422 propre rattachee au champ `firstName` (mapping inline + * ERP-101). Le « max 2 telephones » est rattache au champ `phones` : seul + * point de saisie des numeros (les colonnes phonePrimary/phoneSecondary sont en + * lecture seule). + * + * La security d'operation (transport.carriers.manage) est appliquee par API + * Platform en amont, de meme que la validation Symfony des contraintes d'attribut + * (Assert\Email, Assert\Length...). + * + * @implements ProcessorInterface + */ +final class CarrierContactProcessor implements ProcessorInterface +{ + /** RG-4.08 : nombre maximal de telephones par contact. */ + private const int MAX_PHONES = 2; + + /** Longueur max d'un telephone normalise (colonne VARCHAR(20)). */ + private const int PHONE_MAX_LENGTH = 20; + + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private readonly ProcessorInterface $removeProcessor, + private readonly CarrierFieldNormalizer $normalizer, + private readonly EntityManagerInterface $em, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof CarrierContact) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof DeleteOperationInterface) { + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } + + $this->linkParent($data, $uriVariables); + $this->normalize($data); + $this->applyPhones($data); + $this->validateAtLeastOneField($data); + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + /** + * Rattache le contact au transporteur parent de la sous-ressource POST + * (/carriers/{carrierId}/contacts) : la relation n'est pas peuplee + * automatiquement par le Link sur une ecriture. Sur PATCH (entite existante), + * le transporteur est deja present -> no-op. + */ + private function linkParent(CarrierContact $contact, array $uriVariables): void + { + if (null !== $contact->getCarrier()) { + return; + } + + $carrierId = $uriVariables['carrierId'] ?? null; + if (null === $carrierId) { + return; + } + + $carrier = $carrierId instanceof Carrier + ? $carrierId + : $this->em->getRepository(Carrier::class)->find($carrierId); + + // read:false sur le POST : sans stade lecture, un parent introuvable n'est + // plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la + // contrainte carrier_id NOT NULL). + if (!$carrier instanceof Carrier) { + throw new NotFoundHttpException('Transporteur introuvable.'); + } + + $contact->setCarrier($carrier); + } + + /** + * Normalisation serveur RG-4.13 des champs texte. Toutes les methodes du + * normalizer sont null-safe : une chaine vide apres trim devient null (donc la + * garde RG-4.08 detecte bien « champ non rempli »). Les telephones sont + * traites a part (applyPhones). + */ + private function normalize(CarrierContact $contact): void + { + $contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName())); + $contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName())); + $contact->setJobTitle($this->blankToNull($contact->getJobTitle())); + $contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail())); + } + + /** + * Mappe le tableau d'ecriture `phones` (max 2, RG-4.08) vers phonePrimary / + * phoneSecondary apres normalisation RG-4.13 (chiffres uniquement). Les + * numeros vides (sans chiffre) sont ecartes. null = champ non fourni (PATCH + * partiel) -> on ne touche pas aux telephones existants. Un 3e numero + * exploitable, ou un numero trop long (> colonne VARCHAR(20)), -> 422 sur + * `phones`. + */ + private function applyPhones(CarrierContact $contact): void + { + $phones = $contact->getPhones(); + if (null === $phones) { + return; + } + + $normalized = []; + foreach ($phones as $phone) { + $digits = $this->normalizer->normalizePhone(is_string($phone) ? $phone : null); + if (null !== $digits) { + $normalized[] = $digits; + } + } + + $violations = new ConstraintViolationList(); + if (self::MAX_PHONES < count($normalized)) { + $violations->add(new ConstraintViolation( + 'Un contact ne peut comporter plus de deux téléphones.', + null, + [], + $contact, + 'phones', + $phones, + )); + } + foreach ($normalized as $digits) { + if (self::PHONE_MAX_LENGTH < mb_strlen($digits)) { + $violations->add(new ConstraintViolation( + 'Un numéro de téléphone ne peut dépasser '.self::PHONE_MAX_LENGTH.' caractères.', + null, + [], + $contact, + 'phones', + $phones, + )); + + break; + } + } + + if (0 < $violations->count()) { + throw new ValidationException($violations); + } + + $contact->setPhonePrimary($normalized[0] ?? null); + $contact->setPhoneSecondary($normalized[1] ?? null); + // Nettoie le champ virtuel (non persiste, mais evite toute fuite ulterieure). + $contact->setPhones(null); + } + + /** + * RG-4.08 : un bloc Contact est valide des qu'au moins 1 champ est rempli + * (firstName, lastName, jobTitle, phonePrimary ou email — meme perimetre que + * le CHECK BDD chk_carrier_contact_filled, qui exclut phoneSecondary). Double + * garde : leve une 422 propre rattachee a `firstName` plutot qu'une 500 SQL. + * Joue apres normalisation + mapping telephones, donc les chaines vides sont + * deja ramenees a null. + */ + private function validateAtLeastOneField(CarrierContact $contact): void + { + if ( + null === $contact->getFirstName() + && null === $contact->getLastName() + && null === $contact->getJobTitle() + && null === $contact->getPhonePrimary() + && null === $contact->getEmail() + ) { + $violations = new ConstraintViolationList(); + $violations->add(new ConstraintViolation( + 'Renseignez au moins un champ pour le contact.', + null, + [], + $contact, + 'firstName', + null, + )); + + throw new ValidationException($violations); + } + } + + /** + * Trim + chaine vide -> null (la fonction n'est pas normalisee en casse, + * contrairement aux noms de personne). Garantit que RG-4.08 detecte un champ + * « non rempli » meme si le client envoie une chaine vide. + */ + private function blankToNull(?string $value): ?string + { + if (null === $value) { + return null; + } + + $value = trim($value); + + return '' === $value ? null : $value; + } +} diff --git a/tests/Module/Transport/Api/CarrierContactApiTest.php b/tests/Module/Transport/Api/CarrierContactApiTest.php new file mode 100644 index 0000000..189ef45 --- /dev/null +++ b/tests/Module/Transport/Api/CarrierContactApiTest.php @@ -0,0 +1,179 @@ + 422 (au moins 1 champ requis) ; + * - RG-4.08 : 1 seul champ rempli -> 201 ; + * - RG-4.08 : 3 telephones (tableau `phones`) -> 422 (max 2) ; + * - mapping `phones[]` -> phonePrimary / phoneSecondary + normalisation (RG-4.13) ; + * - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul). + * + * @internal + */ +final class CarrierContactApiTest extends AbstractCarrierApiTestCase +{ + private const string PWD = RbacDemoFixtures::DEMO_PASSWORD; + + protected function setUp(): void + { + parent::setUp(); + + // Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin + // qu'en recette), requis pour les tests de permission (bureau/commerciale). + self::bootKernel(); + $application = new Application(self::$kernel); + $application->setAutoExit(false); + $exit = $application->run( + new ArrayInput([ + 'command' => 'app:seed-rbac', + '--with-demo-users' => true, + '--password' => self::PWD, + ]), + new NullOutput(), + ); + self::assertSame(0, $exit, 'app:seed-rbac a echoue (permissions transport.carriers.* synchronisees ?).'); + + self::ensureKernelShutdown(); + } + + public function testEmptyContactReturns422(): void + { + // RG-4.08 : aucun champ rempli -> 422 (garde Processor, double du CHECK BDD). + $carrier = $this->seedCarrier('Contact Vide'); + $client = $this->createAdminClient(); + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [], + ]); + self::assertResponseStatusCodeSame(422); + } + + public function testSingleFieldContactIsCreated(): void + { + // RG-4.08 : un seul champ suffit a valider le bloc. + $carrier = $this->seedCarrier('Contact Mono'); + $client = $this->createAdminClient(); + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['lastName' => 'martin'], + ]); + self::assertResponseStatusCodeSame(201); + + // RG-4.13 : nom capitalise serveur. + self::assertJsonContains(['lastName' => 'Martin']); + } + + public function testThirdPhoneReturns422(): void + { + // RG-4.08 : max 2 telephones. Le contrat d'ecriture accepte un tableau + // `phones` (liste dynamique cote front « x1, +1 possible, max 2 ») ; un 3e + // numero -> 422 rattachee au champ `phones`. + $carrier = $this->seedCarrier('Contact Trois Tel'); + $client = $this->createAdminClient(); + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'firstName' => 'Jean', + 'phones' => ['0611111111', '0622222222', '0633333333'], + ], + ]); + self::assertResponseStatusCodeSame(422); + } + + public function testPhonesAreMappedAndNormalized(): void + { + // Mapping `phones[0]` -> phonePrimary, `phones[1]` -> phoneSecondary + + // normalisation RG-4.13 (chiffres uniquement). + $carrier = $this->seedCarrier('Contact Deux Tel'); + $client = $this->createAdminClient(); + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'lastName' => 'Dupont', + 'phones' => ['06.11.11.11.11', '06 22 22 22 22'], + ], + ]); + self::assertResponseStatusCodeSame(201); + self::assertJsonContains([ + 'phonePrimary' => '0611111111', + 'phoneSecondary' => '0622222222', + ]); + } + + public function testPatchAndDeleteSucceedWithManage(): void + { + $contact = $this->seedContact('Patch Delete'); + $client = $this->authenticatedClient('bureau', self::PWD); // manage (matrice § 5.2) + + // PATCH (manage) -> 200 + $client->request('PATCH', '/api/carrier_contacts/'.$contact->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['jobTitle' => 'Directeur'], + ]); + self::assertResponseStatusCodeSame(200); + + // DELETE (manage) -> 204 + $client->request('DELETE', '/api/carrier_contacts/'.$contact->getId()); + self::assertResponseStatusCodeSame(204); + } + + public function testWriteForbiddenWithoutManage(): void + { + $contact = $this->seedContact('Forbidden'); + $carrier = $contact->getCarrier(); + self::assertNotNull($carrier); + $client = $this->authenticatedClient('commerciale', self::PWD); // view seul + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['lastName' => 'Bernard'], + ]); + self::assertResponseStatusCodeSame(403); + + $client->request('PATCH', '/api/carrier_contacts/'.$contact->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['jobTitle' => 'Chef'], + ]); + self::assertResponseStatusCodeSame(403); + + $client->request('DELETE', '/api/carrier_contacts/'.$contact->getId()); + self::assertResponseStatusCodeSame(403); + } + + /** + * Seede un transporteur + un contact rattache (pour les tests PATCH/DELETE). + */ + private function seedContact(string $name): CarrierContact + { + $em = $this->getEm(); + $carrier = $this->seedCarrier($name); + + $contact = new CarrierContact(); + $contact->setCarrier($carrier); + $contact->setLastName('Martin'); + $contact->setPhonePrimary('0612345678'); + $carrier->addContact($contact); + $em->persist($contact); + $em->flush(); + + return $contact; + } +} From 7d2812cea65c3a966dcc123f2deda3595f32003a Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 16 Jun 2026 10:42:41 +0200 Subject: [PATCH 08/13] feat(transport) : sous-ressource prix transporteur (ERP-161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/carriers/{id}/prices + PATCH/DELETE /api/carrier_prices/{id} (security transport.carriers.manage) via CarrierPriceProcessor. RG-4.09->4.11 : coherence de branche CLIENT/FOURNISSEUR (champs requis + appartenance de l'adresse de livraison au client / de l'adresse d'appro au fournisseur, sinon 422), nettoyage de la branche opposee (CHECK BDD). Champs communs obligatoires via Assert\NotBlank + Assert\Choice. Les contrats Shared ClientAddressInterface / SupplierAddressInterface exposent desormais getClient() / getSupplier() (canal cross-module, regle n°1) pour la verification d'appartenance. Colonnes enum du prix whitelistees dans le miroir Assert\Length (deja bornees par Choice). --- .../Transport/Domain/Entity/CarrierPrice.php | 113 ++++++- .../State/Processor/CarrierPriceProcessor.php | 170 +++++++++++ .../Contract/ClientAddressInterface.php | 8 + .../Contract/SupplierAddressInterface.php | 8 + ...EntityConstraintsHaveFrenchMessageTest.php | 12 +- .../Transport/Api/CarrierPriceApiTest.php | 276 ++++++++++++++++++ 6 files changed, 570 insertions(+), 17 deletions(-) create mode 100644 src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierPriceProcessor.php create mode 100644 tests/Module/Transport/Api/CarrierPriceApiTest.php diff --git a/src/Module/Transport/Domain/Entity/CarrierPrice.php b/src/Module/Transport/Domain/Entity/CarrierPrice.php index 90e4be0..ed41f01 100644 --- a/src/Module/Transport/Domain/Entity/CarrierPrice.php +++ b/src/Module/Transport/Domain/Entity/CarrierPrice.php @@ -4,6 +4,13 @@ declare(strict_types=1); namespace App\Module\Transport\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierPriceProcessor; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\ClientAddressInterface; @@ -15,6 +22,7 @@ use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Trait\TimestampableBlamableTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Validator\Constraints as Assert; /** * Prix d'un transporteur (1:n) — onglet Prix (M4, RG-4.09→4.11). Une ligne porte @@ -30,9 +38,73 @@ use Symfony\Component\Serializer\Attribute\Groups; * (client:read / client_address:read / supplier:read / supplier_address:read / * site:read), inclus dans le contexte du Get racine de Carrier (§ 4.0). * - * WT3 (ERP-155/157) = LECTURE seule : proprietes en `carrier:item:read`. Les - * sous-ressources d'ecriture + validation des branches (Processor) : WT8. + * Lecture : proprietes en `carrier:item:read` (embarquees au detail du + * transporteur). Ecriture : groupe `carrier:write:prices`. + * + * Sous-ressource API (ERP-161, spec § 4.5) — jumelle de CarrierAddress / + * CarrierContact : + * - POST /api/carriers/{carrierId}/prices : creation rattachee au transporteur + * parent (Link toProperty 'carrier'), security transport.carriers.manage. + * - PATCH / DELETE /api/carrier_prices/{id} : security transport.carriers.manage. + * - GET /api/carrier_prices/{id} : lecture unitaire (security view). + * Tout passe par le CarrierPriceProcessor (rattachement parent + RG-4.09→4.11 : + * coherence de branche CLIENT/FOURNISSEUR + appartenance de l'adresse). + * + * Les champs communs (direction, containerType, pricingUnit, price, priceState) + * sont obligatoires (Assert\NotBlank + Assert\Choice). L'obligation conditionnelle + * des champs de branche (client/supplier + adresses + sites) et l'appartenance de + * l'adresse au client/fournisseur sont portees par le Processor (violations Hydra + * a la main) : ces RG dependent de relations resolues a la denormalisation et non + * exprimables par une simple contrainte d'attribut. */ +#[ApiResource( + operations: [ + new Get( + security: "is_granted('transport.carriers.view')", + normalizationContext: ['groups' => [ + 'carrier:item:read', + 'client:read', 'client_address:read', + 'supplier:read', 'supplier_address:read', + 'site:read', 'default:read', + ]], + ), + new Post( + uriTemplate: '/carriers/{carrierId}/prices', + uriVariables: [ + 'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'), + ], + // read:false : pas de stade lecture du parent. Le Link toProperty + // resoudrait l'enfant (SELECT CarrierPrice ... WHERE carrier = :id) et + // casse en NonUniqueResult des >= 2 enfants. Le parent est rattache + // manuellement par CarrierPriceProcessor::linkParent (404 si absent). + read: false, + security: "is_granted('transport.carriers.manage')", + normalizationContext: ['groups' => [ + 'carrier:item:read', + 'client:read', 'client_address:read', + 'supplier:read', 'supplier_address:read', + 'site:read', 'default:read', + ]], + denormalizationContext: ['groups' => ['carrier:write:prices']], + processor: CarrierPriceProcessor::class, + ), + new Patch( + security: "is_granted('transport.carriers.manage')", + normalizationContext: ['groups' => [ + 'carrier:item:read', + 'client:read', 'client_address:read', + 'supplier:read', 'supplier_address:read', + 'site:read', 'default:read', + ]], + denormalizationContext: ['groups' => ['carrier:write:prices']], + processor: CarrierPriceProcessor::class, + ), + new Delete( + security: "is_granted('transport.carriers.manage')", + processor: CarrierPriceProcessor::class, + ), + ], +)] #[ORM\Entity] #[ORM\Table(name: 'carrier_price')] #[ORM\Index(name: 'idx_carrier_price_carrier', columns: ['carrier_id'])] @@ -61,61 +133,74 @@ class CarrierPrice implements TimestampableInterface, BlamableInterface /** CLIENT|FOURNISSEUR (RG-4.09) — pilote la branche active. */ #[ORM\Column(length: 12)] - #[Groups(['carrier:item:read'])] + #[Assert\NotBlank(message: 'Le sens du prix est obligatoire.')] + #[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR'], message: 'Le sens du prix est invalide.')] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?string $direction = null; // === Branche CLIENT (RG-4.10) === + // Obligation conditionnelle (direction=CLIENT) + appartenance de l'adresse au + // client : portees par le CarrierPriceProcessor (relations resolues a la + // denormalisation, hors portee d'une contrainte d'attribut). #[ORM\ManyToOne(targetEntity: ClientInterface::class)] #[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] - #[Groups(['carrier:item:read'])] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?ClientInterface $client = null; #[ORM\ManyToOne(targetEntity: ClientAddressInterface::class)] #[ORM\JoinColumn(name: 'client_delivery_address_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] - #[Groups(['carrier:item:read'])] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?ClientAddressInterface $clientDeliveryAddress = null; /** Adresse de depart = un des 3 sites (86/17/82). */ #[ORM\ManyToOne(targetEntity: SiteInterface::class)] #[ORM\JoinColumn(name: 'departure_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] - #[Groups(['carrier:item:read'])] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?SiteInterface $departureSite = null; // === Branche FOURNISSEUR (RG-4.11) === #[ORM\ManyToOne(targetEntity: SupplierInterface::class)] #[ORM\JoinColumn(name: 'supplier_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] - #[Groups(['carrier:item:read'])] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?SupplierInterface $supplier = null; #[ORM\ManyToOne(targetEntity: SupplierAddressInterface::class)] #[ORM\JoinColumn(name: 'supplier_supply_address_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] - #[Groups(['carrier:item:read'])] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?SupplierAddressInterface $supplierSupplyAddress = null; /** Adresse de livraison = un des 3 sites (86/17/82). */ #[ORM\ManyToOne(targetEntity: SiteInterface::class)] #[ORM\JoinColumn(name: 'delivery_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] - #[Groups(['carrier:item:read'])] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?SiteInterface $deliverySite = null; - // === Commun === + // === Commun (toujours obligatoires, RG-4.10/4.11) === /** BENNE|FOND_MOUVANT. */ #[ORM\Column(name: 'container_type', length: 12)] - #[Groups(['carrier:item:read'])] + #[Assert\NotBlank(message: 'Le type de contenant est obligatoire.')] + #[Assert\Choice(choices: ['BENNE', 'FOND_MOUVANT'], message: 'Le type de contenant est invalide.')] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?string $containerType = null; /** FORFAIT|TONNE. */ #[ORM\Column(name: 'pricing_unit', length: 8)] - #[Groups(['carrier:item:read'])] + #[Assert\NotBlank(message: 'L\'unite de tarification est obligatoire.')] + #[Assert\Choice(choices: ['FORFAIT', 'TONNE'], message: 'L\'unite de tarification est invalide.')] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?string $pricingUnit = null; #[ORM\Column(type: 'decimal', precision: 12, scale: 2)] - #[Groups(['carrier:item:read'])] + #[Assert\NotBlank(message: 'Le prix est obligatoire.')] + #[Assert\PositiveOrZero(message: 'Le prix ne peut pas etre negatif.')] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?string $price = null; /** EN_COURS|VALIDE|NON_VALIDE. */ #[ORM\Column(name: 'price_state', length: 12)] - #[Groups(['carrier:item:read'])] + #[Assert\NotBlank(message: 'L\'etat du prix est obligatoire.')] + #[Assert\Choice(choices: ['EN_COURS', 'VALIDE', 'NON_VALIDE'], message: 'L\'etat du prix est invalide.')] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?string $priceState = null; #[ORM\Column(options: ['default' => 0])] diff --git a/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierPriceProcessor.php b/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierPriceProcessor.php new file mode 100644 index 0000000..adefa85 --- /dev/null +++ b/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierPriceProcessor.php @@ -0,0 +1,170 @@ + + */ +final class CarrierPriceProcessor implements ProcessorInterface +{ + private const string DIRECTION_CLIENT = 'CLIENT'; + + private const string DIRECTION_SUPPLIER = 'FOURNISSEUR'; + + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private readonly ProcessorInterface $removeProcessor, + private readonly EntityManagerInterface $em, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof CarrierPrice) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof DeleteOperationInterface) { + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } + + $this->linkParent($data, $uriVariables); + $this->validateBranch($data); + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + /** + * Rattache le prix au transporteur parent de la sous-ressource POST + * (/carriers/{carrierId}/prices) : la relation n'est pas peuplee + * automatiquement par le Link sur une ecriture. Sur PATCH (entite existante), + * le transporteur est deja present -> no-op. + */ + private function linkParent(CarrierPrice $price, array $uriVariables): void + { + if (null !== $price->getCarrier()) { + return; + } + + $carrierId = $uriVariables['carrierId'] ?? null; + if (null === $carrierId) { + return; + } + + $carrier = $carrierId instanceof Carrier + ? $carrierId + : $this->em->getRepository(Carrier::class)->find($carrierId); + + // read:false sur le POST : sans stade lecture, un parent introuvable n'est + // plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la + // contrainte carrier_id NOT NULL). + if (!$carrier instanceof Carrier) { + throw new NotFoundHttpException('Transporteur introuvable.'); + } + + $price->setCarrier($carrier); + } + + /** + * RG-4.09→4.11 : valide la coherence de la branche active (CLIENT vs + * FOURNISSEUR) et nettoie la branche opposee (les CHECK BDD imposent ses + * colonnes nulles). Toutes les violations sont collectees puis renvoyees d'un + * coup (un seul aller-retour, mapping inline par champ — ERP-101). La direction + * elle-meme est deja garantie CLIENT|FOURNISSEUR par Assert\NotBlank + Choice. + */ + private function validateBranch(CarrierPrice $price): void + { + $violations = new ConstraintViolationList(); + + if (self::DIRECTION_CLIENT === $price->getDirection()) { + $this->requireField($violations, $price, 'client', $price->getClient(), 'Le client est obligatoire pour un prix client.'); + $this->requireField($violations, $price, 'clientDeliveryAddress', $price->getClientDeliveryAddress(), 'L\'adresse de livraison du client est obligatoire pour un prix client.'); + $this->requireField($violations, $price, 'departureSite', $price->getDepartureSite(), 'Le site de depart est obligatoire pour un prix client.'); + + // RG-4.10 : l'adresse de livraison doit appartenir au client choisi. + $client = $price->getClient(); + $address = $price->getClientDeliveryAddress(); + if (null !== $client && null !== $address && $address->getClient()?->getId() !== $client->getId()) { + $violations->add($this->violation($price, 'clientDeliveryAddress', 'L\'adresse de livraison doit appartenir au client selectionne.')); + } + + // Coherence CHECK chk_carrier_price_client_branch : branche fournisseur nulle. + $price->setSupplier(null); + $price->setSupplierSupplyAddress(null); + $price->setDeliverySite(null); + } elseif (self::DIRECTION_SUPPLIER === $price->getDirection()) { + $this->requireField($violations, $price, 'supplier', $price->getSupplier(), 'Le fournisseur est obligatoire pour un prix fournisseur.'); + $this->requireField($violations, $price, 'supplierSupplyAddress', $price->getSupplierSupplyAddress(), 'L\'adresse d\'approvisionnement est obligatoire pour un prix fournisseur.'); + $this->requireField($violations, $price, 'deliverySite', $price->getDeliverySite(), 'Le site de livraison est obligatoire pour un prix fournisseur.'); + + // RG-4.11 : l'adresse d'appro doit appartenir au fournisseur choisi. + $supplier = $price->getSupplier(); + $address = $price->getSupplierSupplyAddress(); + if (null !== $supplier && null !== $address && $address->getSupplier()?->getId() !== $supplier->getId()) { + $violations->add($this->violation($price, 'supplierSupplyAddress', 'L\'adresse d\'approvisionnement doit appartenir au fournisseur selectionne.')); + } + + // Coherence CHECK chk_carrier_price_supplier_branch : branche client nulle. + $price->setClient(null); + $price->setClientDeliveryAddress(null); + $price->setDepartureSite(null); + } + + if (0 < $violations->count()) { + throw new ValidationException($violations); + } + } + + /** + * Ajoute une violation « champ obligatoire » sur `$path` si la relation est + * absente (branche active, RG-4.10/4.11). + */ + private function requireField(ConstraintViolationList $violations, CarrierPrice $price, string $path, ?object $value, string $message): void + { + if (null === $value) { + $violations->add($this->violation($price, $path, $message)); + } + } + + private function violation(CarrierPrice $price, string $path, string $message): ConstraintViolation + { + return new ConstraintViolation($message, null, [], $price, $path, null); + } +} diff --git a/src/Shared/Domain/Contract/ClientAddressInterface.php b/src/Shared/Domain/Contract/ClientAddressInterface.php index 4f5f6a4..0c52e84 100644 --- a/src/Shared/Domain/Contract/ClientAddressInterface.php +++ b/src/Shared/Domain/Contract/ClientAddressInterface.php @@ -17,4 +17,12 @@ namespace App\Shared\Domain\Contract; interface ClientAddressInterface { public function getId(): ?int; + + /** + * Client parent de l'adresse. Expose le lien inverse sans coupler au module + * Commercial : permet a un autre module de verifier l'appartenance d'une + * adresse a un client (ex: CarrierPrice, RG-4.10 — l'adresse de livraison + * doit appartenir au client choisi). Retour covariant ?Client cote entite. + */ + public function getClient(): ?ClientInterface; } diff --git a/src/Shared/Domain/Contract/SupplierAddressInterface.php b/src/Shared/Domain/Contract/SupplierAddressInterface.php index 4e32c7f..2c5a141 100644 --- a/src/Shared/Domain/Contract/SupplierAddressInterface.php +++ b/src/Shared/Domain/Contract/SupplierAddressInterface.php @@ -17,4 +17,12 @@ namespace App\Shared\Domain\Contract; interface SupplierAddressInterface { public function getId(): ?int; + + /** + * Fournisseur parent de l'adresse. Expose le lien inverse sans coupler au + * module Commercial : permet a un autre module de verifier l'appartenance + * d'une adresse a un fournisseur (ex: CarrierPrice, RG-4.11 — l'adresse + * d'appro doit appartenir au fournisseur choisi). Retour covariant ?Supplier. + */ + public function getSupplier(): ?SupplierInterface; } diff --git a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php index 799ff09..74374e7 100644 --- a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php +++ b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php @@ -64,6 +64,11 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase 'Carrier::certificationType' => 'Choice des 5 certifications borne deja les valeurs.', // Le Choice {BENNE,FOND_MOUVANT} borne les valeurs (<= 12). 'Carrier::containerType' => 'Choice {BENNE,FOND_MOUVANT} borne deja les valeurs.', + // Colonnes enum du prix transporteur (M4) : le Choice borne deja les valeurs. + 'CarrierPrice::direction' => 'Choice {CLIENT,FOURNISSEUR} borne deja les valeurs.', + 'CarrierPrice::containerType' => 'Choice {BENNE,FOND_MOUVANT} borne deja les valeurs.', + 'CarrierPrice::pricingUnit' => 'Choice {FORFAIT,TONNE} borne deja les valeurs.', + 'CarrierPrice::priceState' => 'Choice {EN_COURS,VALIDE,NON_VALIDE} borne deja les valeurs.', // Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres. 'Site::color' => 'Regex code hex #RRGGBB borne deja la longueur.', ]; @@ -109,7 +114,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase } /** @var Constraint $constraint */ - $constraint = $attribute->newInstance(); + $constraint = $attribute->newInstance(); $messageProps = $this->messagePropertiesFor($constraint); self::assertNotNull( @@ -180,6 +185,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase foreach ($constraints as $c) { if ($c instanceof Assert\Length) { $length = $c->max; + break; } } @@ -251,7 +257,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase * Liste des proprietes de message a verifier pour une contrainte donnee, ou * null si la contrainte n'est pas geree (le test echoue alors explicitement). * - * @return list|null + * @return null|list */ private function messagePropertiesFor(Constraint $constraint): ?array { @@ -325,7 +331,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase } /** - * @param list $constraints + * @param list $constraints * @param list> $classes */ private function hasAnyConstraint(array $constraints, array $classes): bool diff --git a/tests/Module/Transport/Api/CarrierPriceApiTest.php b/tests/Module/Transport/Api/CarrierPriceApiTest.php new file mode 100644 index 0000000..399430d --- /dev/null +++ b/tests/Module/Transport/Api/CarrierPriceApiTest.php @@ -0,0 +1,276 @@ + 422 ; + * - branche FOURNISSEUR incomplete -> 422 ; + * - adresse de livraison etrangere au client -> 422 ; + * - adresse d'appro etrangere au fournisseur -> 422 ; + * - prix CLIENT / FOURNISSEUR complets -> 201 ; + * - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul). + * + * @internal + */ +final class CarrierPriceApiTest extends AbstractCarrierApiTestCase +{ + private const string PWD = RbacDemoFixtures::DEMO_PASSWORD; + + protected function setUp(): void + { + parent::setUp(); + + // Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin + // qu'en recette), requis pour les tests de permission (bureau/commerciale). + self::bootKernel(); + $application = new Application(self::$kernel); + $application->setAutoExit(false); + $exit = $application->run( + new ArrayInput([ + 'command' => 'app:seed-rbac', + '--with-demo-users' => true, + '--password' => self::PWD, + ]), + new NullOutput(), + ); + self::assertSame(0, $exit, 'app:seed-rbac a echoue (permissions transport.carriers.* synchronisees ?).'); + + self::ensureKernelShutdown(); + } + + public function testIncompleteClientBranchReturns422(): void + { + // RG-4.10 : direction CLIENT sans client / adresse / site de depart -> 422. + $carrier = $this->seedCarrier('Prix Client Incomplet'); + $client = $this->createAdminClient(); + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'direction' => 'CLIENT', + 'containerType' => 'BENNE', + 'pricingUnit' => 'TONNE', + 'price' => '42.50', + 'priceState' => 'VALIDE', + ], + ]); + self::assertResponseStatusCodeSame(422); + } + + public function testIncompleteSupplierBranchReturns422(): void + { + // RG-4.11 : direction FOURNISSEUR sans fournisseur / adresse / site -> 422. + $carrier = $this->seedCarrier('Prix Fournisseur Incomplet'); + $client = $this->createAdminClient(); + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'direction' => 'FOURNISSEUR', + 'containerType' => 'FOND_MOUVANT', + 'pricingUnit' => 'FORFAIT', + 'price' => '320.00', + 'priceState' => 'EN_COURS', + ], + ]); + self::assertResponseStatusCodeSame(422); + } + + public function testForeignClientAddressReturns422(): void + { + // RG-4.10 : l'adresse de livraison doit appartenir au client choisi. + $carrier = $this->seedCarrier('Prix Adresse Etrangere Client'); + $addrA = $this->seedClientWithAddress('Client A'); + $addrB = $this->seedClientWithAddress('Client B'); + $this->getEm()->flush(); + $siteId = $this->aSiteId(); + + $client = $this->createAdminClient(); + $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'direction' => 'CLIENT', + 'client' => '/api/clients/'.$addrA->getClient()?->getId(), + 'clientDeliveryAddress' => '/api/client_addresses/'.$addrB->getId(), // adresse du client B + 'departureSite' => '/api/sites/'.$siteId, + 'containerType' => 'BENNE', + 'pricingUnit' => 'TONNE', + 'price' => '42.50', + 'priceState' => 'VALIDE', + ], + ]); + self::assertResponseStatusCodeSame(422); + } + + public function testForeignSupplierAddressReturns422(): void + { + // RG-4.11 : l'adresse d'appro doit appartenir au fournisseur choisi. + $carrier = $this->seedCarrier('Prix Adresse Etrangere Fournisseur'); + $addrA = $this->seedSupplierWithAddress('Fournisseur A'); + $addrB = $this->seedSupplierWithAddress('Fournisseur B'); + $this->getEm()->flush(); + $siteId = $this->aSiteId(); + + $client = $this->createAdminClient(); + $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'direction' => 'FOURNISSEUR', + 'supplier' => '/api/suppliers/'.$addrA->getSupplier()?->getId(), + 'supplierSupplyAddress' => '/api/supplier_addresses/'.$addrB->getId(), // adresse du fournisseur B + 'deliverySite' => '/api/sites/'.$siteId, + 'containerType' => 'FOND_MOUVANT', + 'pricingUnit' => 'FORFAIT', + 'price' => '320.00', + 'priceState' => 'EN_COURS', + ], + ]); + self::assertResponseStatusCodeSame(422); + } + + public function testValidClientPriceIsCreated(): void + { + $carrier = $this->seedCarrier('Prix Client Valide'); + $addr = $this->seedClientWithAddress('Client OK'); + $this->getEm()->flush(); + $siteId = $this->aSiteId(); + + $client = $this->createAdminClient(); + $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'direction' => 'CLIENT', + 'client' => '/api/clients/'.$addr->getClient()?->getId(), + 'clientDeliveryAddress' => '/api/client_addresses/'.$addr->getId(), + 'departureSite' => '/api/sites/'.$siteId, + 'containerType' => 'BENNE', + 'pricingUnit' => 'TONNE', + 'price' => '42.50', + 'priceState' => 'VALIDE', + ], + ]); + self::assertResponseStatusCodeSame(201); + self::assertJsonContains(['direction' => 'CLIENT', 'priceState' => 'VALIDE']); + } + + public function testValidSupplierPriceIsCreated(): void + { + $carrier = $this->seedCarrier('Prix Fournisseur Valide'); + $addr = $this->seedSupplierWithAddress('Fournisseur OK'); + $this->getEm()->flush(); + $siteId = $this->aSiteId(); + + $client = $this->createAdminClient(); + $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'direction' => 'FOURNISSEUR', + 'supplier' => '/api/suppliers/'.$addr->getSupplier()?->getId(), + 'supplierSupplyAddress' => '/api/supplier_addresses/'.$addr->getId(), + 'deliverySite' => '/api/sites/'.$siteId, + 'containerType' => 'FOND_MOUVANT', + 'pricingUnit' => 'FORFAIT', + 'price' => '320.00', + 'priceState' => 'EN_COURS', + ], + ]); + self::assertResponseStatusCodeSame(201); + self::assertJsonContains(['direction' => 'FOURNISSEUR', 'priceState' => 'EN_COURS']); + } + + public function testPatchAndDeleteSucceedWithManage(): void + { + $price = $this->seedClientPrice('Patch Delete'); + $client = $this->authenticatedClient('bureau', self::PWD); // manage (matrice § 5.2) + + // PATCH (manage) -> 200 + $client->request('PATCH', '/api/carrier_prices/'.$price->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['priceState' => 'NON_VALIDE'], + ]); + self::assertResponseStatusCodeSame(200); + self::assertJsonContains(['priceState' => 'NON_VALIDE']); + + // DELETE (manage) -> 204 + $client->request('DELETE', '/api/carrier_prices/'.$price->getId()); + self::assertResponseStatusCodeSame(204); + } + + public function testWriteForbiddenWithoutManage(): void + { + $price = $this->seedClientPrice('Forbidden'); + $carrier = $price->getCarrier(); + self::assertNotNull($carrier); + $client = $this->authenticatedClient('commerciale', self::PWD); // view seul + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['direction' => 'CLIENT'], + ]); + self::assertResponseStatusCodeSame(403); + + $client->request('PATCH', '/api/carrier_prices/'.$price->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['priceState' => 'VALIDE'], + ]); + self::assertResponseStatusCodeSame(403); + + $client->request('DELETE', '/api/carrier_prices/'.$price->getId()); + self::assertResponseStatusCodeSame(403); + } + + /** Id d'un site fixture (adresse de depart / livraison des prix). */ + private function aSiteId(): int + { + $site = $this->getEm()->getRepository(Site::class)->findOneBy([]); + self::assertNotNull($site, 'Un site fixture est requis (SitesFixtures).'); + $id = $site->getId(); + self::assertNotNull($id); + + return $id; + } + + /** + * Seede un transporteur + un prix CLIENT complet rattache (pour les tests + * PATCH / DELETE). Passe par l'EM directement (le flux d'ecriture est teste + * via l'API ailleurs). + */ + private function seedClientPrice(string $name): CarrierPrice + { + $em = $this->getEm(); + $carrier = $this->seedCarrier($name); + + /** @var ClientAddress $addr */ + $addr = $this->seedClientWithAddress($name); + + $price = new CarrierPrice(); + $price->setCarrier($carrier); + $price->setDirection('CLIENT'); + $price->setClient($addr->getClient()); + $price->setClientDeliveryAddress($addr); + $price->setDepartureSite($em->getRepository(Site::class)->findOneBy([])); + $price->setContainerType('BENNE'); + $price->setPricingUnit('TONNE'); + $price->setPrice('42.50'); + $price->setPriceState('VALIDE'); + $carrier->addPrice($price); + $em->persist($price); + $em->flush(); + + return $price; + } +} From e688fe7e0b06e6a7f3bd31bd98279556b3960f1f Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 16 Jun 2026 11:07:46 +0200 Subject: [PATCH 09/13] =?UTF-8?q?feat(transport)=20:=20export=20XLSX=20r?= =?UTF-8?q?=C3=A9pertoire=20+=20prix=20transporteur=20(ERP-162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/carriers/export.xlsx (mêmes filtres que la liste : includeArchived, search, certificationType) et GET /api/carriers/{id}/prices/export.xlsx (tableau Prix regroupé Benne / Fond Mouvant). Controllers Symfony custom avec #[Route(priority: 1)] pour éviter le conflit API Platform {id}, génération déléguée au service Shared SpreadsheetExporterInterface. --- .../Controller/CarrierExportController.php | 158 ++++++++++++++++ .../CarrierPriceExportController.php | 170 ++++++++++++++++++ .../Api/CarrierExportControllerTest.php | 157 ++++++++++++++++ .../Api/CarrierPriceExportControllerTest.php | 161 +++++++++++++++++ 4 files changed, 646 insertions(+) create mode 100644 src/Module/Transport/Infrastructure/Controller/CarrierExportController.php create mode 100644 src/Module/Transport/Infrastructure/Controller/CarrierPriceExportController.php create mode 100644 tests/Module/Transport/Api/CarrierExportControllerTest.php create mode 100644 tests/Module/Transport/Api/CarrierPriceExportControllerTest.php diff --git a/src/Module/Transport/Infrastructure/Controller/CarrierExportController.php b/src/Module/Transport/Infrastructure/Controller/CarrierExportController.php new file mode 100644 index 0000000..a5c7d03 --- /dev/null +++ b/src/Module/Transport/Infrastructure/Controller/CarrierExportController.php @@ -0,0 +1,158 @@ +readBool($request->query->get('includeArchived')); + $search = $request->query->getString('search') ?: null; + $certificationTypes = $this->readStringList($request->query->all()['certificationType'] ?? []); + + /** @var list $carriers */ + $carriers = $this->repository + ->createListQueryBuilder($includeArchived, $search, $certificationTypes) + ->getQuery() + ->getResult() + ; + + $binary = $this->exporter->export( + 'Répertoire transporteurs', + $this->buildHeaders(), + $this->buildRows($carriers), + ); + + return $this->buildResponse($binary); + } + + /** + * Colonnes de l'export (spec § 4.6). + * + * @return list + */ + private function buildHeaders(): array + { + return [ + 'Nom', + 'Certification', + 'Statut QUALIMAT', + 'Date de validité', + 'Affrété', + 'Volume m³', + 'Date de création', + ]; + } + + /** + * @param list $carriers + * + * @return iterable> + */ + private function buildRows(array $carriers): iterable + { + foreach ($carriers as $carrier) { + // Statut / date de validite proviennent du referentiel QUALIMAT lie + // (RG-4.04), deja fetch-joine par le repository (anti N+1, § 2.11). + $qualimat = $carrier->getQualimatCarrier(); + + yield [ + $carrier->getName(), + $carrier->getCertificationType() ?? '', + $qualimat?->getStatus() ?? '', + $qualimat?->getValidityDate()?->format('d/m/Y') ?? '', + $carrier->isChartered() ? 'Oui' : 'Non', + $carrier->getVolumeM3() ?? '', + $carrier->getCreatedAt()?->format('d/m/Y'), + ]; + } + } + + private function buildResponse(string $binary): Response + { + $filename = sprintf('repertoire-transporteurs-%s.xlsx', new DateTimeImmutable()->format('Ymd')); + + $response = new Response($binary); + $response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + $response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename)); + + return $response; + } + + /** + * Lit un flag booleen issu des query params. Accepte true / "true" / "1". + * Aligne sur CarrierProvider pour un comportement identique a la liste. + */ + private function readBool(mixed $raw): bool + { + return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true); + } + + /** + * Normalise un filtre en liste de chaines (valeur unique ou liste). + * Aligne sur CarrierProvider pour un comportement identique a la liste. + * + * @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/Controller/CarrierPriceExportController.php b/src/Module/Transport/Infrastructure/Controller/CarrierPriceExportController.php new file mode 100644 index 0000000..2abb99e --- /dev/null +++ b/src/Module/Transport/Infrastructure/Controller/CarrierPriceExportController.php @@ -0,0 +1,170 @@ + 'Benne', 'FOND_MOUVANT' => 'Fond Mouvant']; + + private const array PRICE_STATE_LABELS = [ + 'EN_COURS' => 'En cours', + 'VALIDE' => 'Validé', + 'NON_VALIDE' => 'Non validé', + ]; + + public function __construct( + #[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository')] + private readonly CarrierRepositoryInterface $repository, + private readonly SpreadsheetExporterInterface $exporter, + ) {} + + #[Route('/api/carriers/{id}/prices/export.xlsx', name: 'transport_carrier_prices_export_xlsx', requirements: ['id' => '\d+'], methods: ['GET'], priority: 1)] + #[IsGranted('transport.carriers.view')] + public function __invoke(int $id): Response + { + $carrier = $this->repository->findById($id); + // Soft-delete jamais expose (comme CarrierProvider::provideItem) : 404. + if (null === $carrier || null !== $carrier->getDeletedAt()) { + throw new NotFoundHttpException('Transporteur introuvable.'); + } + + $binary = $this->exporter->export( + 'Prix transporteur', + $this->buildHeaders(), + $this->buildRows($carrier), + ); + + return $this->buildResponse($carrier, $binary); + } + + /** + * Colonnes du tableau Prix regroupe (spec-front « Onglet Prix » / docx p.10). + * + * @return list + */ + private function buildHeaders(): array + { + return [ + 'Type de contenant', + 'Transporteurs', + 'Adresse APRO ou Adresse Sites', + 'Adresse livraisons', + 'Forfait €', + 'Tonne €', + 'Indexation', + 'État du prix', + ]; + } + + /** + * Lignes regroupees par type de contenant (Fond Mouvant / Benne). On trie les + * prix par contenant puis position pour materialiser le regroupement. + * + * @return iterable> + */ + private function buildRows(Carrier $carrier): iterable + { + $prices = $carrier->getPrices()->toArray(); + usort( + $prices, + static fn (CarrierPrice $a, CarrierPrice $b): int => [$a->getContainerType(), $a->getPosition()] + <=> [$b->getContainerType(), $b->getPosition()], + ); + + // Indexation : portee par le transporteur (RG-4.03), identique pour toutes + // ses lignes de prix. Vide si non renseigne (spec-front). + $indexation = $carrier->getIndexationRate() ?? ''; + + foreach ($prices as $price) { + $isForfait = 'FORFAIT' === $price->getPricingUnit(); + + yield [ + self::CONTAINER_LABELS[$price->getContainerType()] ?? $price->getContainerType(), + $carrier->getName(), + $this->formatDeparture($price), + $this->formatDelivery($price), + $isForfait ? $price->getPrice() : '', + $isForfait ? '' : $price->getPrice(), + $indexation, + self::PRICE_STATE_LABELS[$price->getPriceState()] ?? $price->getPriceState(), + ]; + } + } + + /** + * Point de depart du prix (colonne « Adresse APRO ou Adresse Sites ») : + * - branche CLIENT : le site de depart (un des 3 sites 86/17/82) ; + * - branche FOURNISSEUR : l'adresse d'approvisionnement, identifiee par la + * raison sociale du fournisseur (cf. note de classe sur les contrats Shared). + */ + private function formatDeparture(CarrierPrice $price): string + { + if ('CLIENT' === $price->getDirection()) { + return $price->getDepartureSite()?->getName() ?? ''; + } + + return $price->getSupplierSupplyAddress()?->getSupplier()?->getCompanyName() ?? ''; + } + + /** + * Point de livraison du prix (colonne « Adresse livraisons ») : + * - branche CLIENT : l'adresse de livraison, identifiee par la raison sociale + * du client ; + * - branche FOURNISSEUR : le site de livraison (un des 3 sites 86/17/82). + */ + private function formatDelivery(CarrierPrice $price): string + { + if ('CLIENT' === $price->getDirection()) { + return $price->getClientDeliveryAddress()?->getClient()?->getCompanyName() ?? ''; + } + + return $price->getDeliverySite()?->getName() ?? ''; + } + + private function buildResponse(Carrier $carrier, string $binary): Response + { + $filename = sprintf('prix-transporteur-%d-%s.xlsx', (int) $carrier->getId(), new DateTimeImmutable()->format('Ymd')); + + $response = new Response($binary); + $response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + $response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename)); + + return $response; + } +} diff --git a/tests/Module/Transport/Api/CarrierExportControllerTest.php b/tests/Module/Transport/Api/CarrierExportControllerTest.php new file mode 100644 index 0000000..5e18abe --- /dev/null +++ b/tests/Module/Transport/Api/CarrierExportControllerTest.php @@ -0,0 +1,157 @@ +createAdminClient(); + $this->seedCarrier('Export Alpha'); + + $response = $client->request('GET', self::EXPORT_URL); + + self::assertResponseIsSuccessful(); + $headers = $response->getHeaders(false); + self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? ''); + + $disposition = $headers['content-disposition'][0] ?? ''; + self::assertStringContainsString('attachment; filename="repertoire-transporteurs-', $disposition); + self::assertMatchesRegularExpression( + '/filename="repertoire-transporteurs-\d{8}\.xlsx"/', + $disposition, + ); + + // Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes. + $headers = $this->gridFromResponse($response->getContent())[0]; + self::assertSame('Nom', $headers[0]); + self::assertContains('Certification', $headers); + self::assertContains('Statut QUALIMAT', $headers); + self::assertContains('Date de validité', $headers); + self::assertContains('Affrété', $headers); + self::assertContains('Volume m³', $headers); + self::assertContains('Date de création', $headers); + } + + public function testExportExcludesArchivedByDefault(): void + { + $client = $this->createAdminClient(); + $this->seedCarrier('Active One'); + $this->seedCarrier('Archived One', true); + + $names = $this->carrierNames($client->request('GET', self::EXPORT_URL)->getContent()); + + self::assertContains('ACTIVE ONE', $names); + self::assertNotContains('ARCHIVED ONE', $names); + } + + public function testExportRespectsSearchFilter(): void + { + $client = $this->createAdminClient(); + $this->seedCarrier('Searchable Alpha'); + $this->seedCarrier('Other Beta'); + + $names = $this->carrierNames( + $client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(), + ); + + self::assertContains('SEARCHABLE ALPHA', $names); + self::assertNotContains('OTHER BETA', $names); + } + + /** + * Colonnes « Statut QUALIMAT » et « Date de validite » : alimentees par le + * referentiel QUALIMAT lie (RG-4.04). Un transporteur complet seede un lien + * QUALIMAT (statut « Valide », validite 31/12/2027). + */ + public function testExportPopulatesQualimatColumns(): void + { + $client = $this->createAdminClient(); + $this->seedCompleteCarrier('Grelillier'); + + $flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent())); + + self::assertStringContainsString('QUALIMAT', $flat); + self::assertStringContainsString('Valide', $flat); + self::assertStringContainsString('31/12/2027', $flat); + } + + public function testForbiddenWithoutCarriersViewPermission(): void + { + $creds = $this->createUserWithPermission('core.users.view'); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $client->request('GET', self::EXPORT_URL); + + self::assertResponseStatusCodeSame(403); + } + + public function testUnauthorizedWhenAnonymous(): void + { + $client = self::createClient(); + $client->request('GET', self::EXPORT_URL); + + self::assertResponseStatusCodeSame(401); + } + + /** + * Relit le binaire XLSX d'une reponse et renvoie la grille de cellules. + * + * @return array> + */ + private function gridFromResponse(string $binary): array + { + $tmp = tempnam(sys_get_temp_dir(), 'xlsx_carrier_export_test_'); + self::assertIsString($tmp); + file_put_contents($tmp, $binary); + + try { + return IOFactory::load($tmp)->getActiveSheet()->toArray(); + } finally { + @unlink($tmp); + } + } + + /** + * Extrait la colonne « Nom » (1re colonne) des lignes de donnees. + * + * @return list + */ + private function carrierNames(string $binary): array + { + $rows = array_slice($this->gridFromResponse($binary), 1); // saute l'en-tete + + return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows)); + } + + /** + * Aplatit toute la grille en une chaine, pour les assertions de presence. + * + * @param array> $grid + */ + private function flatten(array $grid): string + { + return implode('|', array_map( + static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)), + $grid, + )); + } +} diff --git a/tests/Module/Transport/Api/CarrierPriceExportControllerTest.php b/tests/Module/Transport/Api/CarrierPriceExportControllerTest.php new file mode 100644 index 0000000..92e7c48 --- /dev/null +++ b/tests/Module/Transport/Api/CarrierPriceExportControllerTest.php @@ -0,0 +1,161 @@ +createAdminClient(); + $carrier = $this->seedCompleteCarrier('Price Alpha'); + + $response = $client->request('GET', $this->exportUrl($carrier)); + + self::assertResponseIsSuccessful(); + $headers = $response->getHeaders(false); + self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? ''); + + $disposition = $headers['content-disposition'][0] ?? ''; + self::assertStringContainsString('attachment; filename="prix-transporteur-', $disposition); + self::assertMatchesRegularExpression( + '/filename="prix-transporteur-\d+-\d{8}\.xlsx"/', + $disposition, + ); + + $headerRow = $this->gridFromResponse($response->getContent())[0]; + self::assertSame('Type de contenant', $headerRow[0]); + self::assertContains('Transporteurs', $headerRow); + self::assertContains('Adresse APRO ou Adresse Sites', $headerRow); + self::assertContains('Adresse livraisons', $headerRow); + self::assertContains('Forfait €', $headerRow); + self::assertContains('Tonne €', $headerRow); + self::assertContains('Indexation', $headerRow); + self::assertContains('État du prix', $headerRow); + } + + /** + * Le transporteur complet seede 2 prix : une branche CLIENT (Benne / Tonne / + * 42.50 / Valide) et une branche FOURNISSEUR (Fond Mouvant / Forfait / 320.00 / + * En cours). On verifie le regroupement par contenant, la ventilation + * Forfait/Tonne, les libelles d'etat FR et les points de depart/livraison + * cross-module (le prix CLIENT livre chez le client, le prix FOURNISSEUR part + * de l'adresse du fournisseur). + */ + public function testExportRendersGroupedPriceRows(): void + { + $client = $this->createAdminClient(); + $carrier = $this->seedCompleteCarrier('Price Grouping'); + + $grid = $this->gridFromResponse($client->request('GET', $this->exportUrl($carrier))->getContent()); + + $benne = $this->rowForContainer($grid, 'Benne'); + self::assertNotNull($benne, 'Ligne « Benne » introuvable dans l\'export prix.'); + self::assertSame($carrier->getName(), $benne[1]); + // Branche CLIENT : prix en Tonne (42.50 -> 42.5 apres typage numerique du + // classeur), colonne Forfait vide, etat « Valide », livraison chez le client. + self::assertEmpty($benne[4]); + self::assertEqualsWithDelta(42.5, (float) $benne[5], 0.001); + self::assertSame('Validé', $benne[7]); + self::assertStringContainsString('TESTCARRIERREF CLI', (string) $benne[3]); + + $fondMouvant = $this->rowForContainer($grid, 'Fond Mouvant'); + self::assertNotNull($fondMouvant, 'Ligne « Fond Mouvant » introuvable dans l\'export prix.'); + // Branche FOURNISSEUR : prix au Forfait (320.00 -> 320), colonne Tonne vide, + // etat « En cours », depart depuis l'adresse du fournisseur (APRO). + self::assertEqualsWithDelta(320.0, (float) $fondMouvant[4], 0.001); + self::assertEmpty($fondMouvant[5]); + self::assertSame('En cours', $fondMouvant[7]); + self::assertStringContainsString('TESTCARRIERREF FRN', (string) $fondMouvant[2]); + } + + public function testNotFoundForUnknownCarrier(): void + { + $client = $this->createAdminClient(); + + $client->request('GET', '/api/carriers/99999999/prices/export.xlsx'); + + self::assertResponseStatusCodeSame(404); + } + + public function testForbiddenWithoutCarriersViewPermission(): void + { + $carrier = $this->seedCompleteCarrier('Price Forbidden'); + + $creds = $this->createUserWithPermission('core.users.view'); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $client->request('GET', $this->exportUrl($carrier)); + + self::assertResponseStatusCodeSame(403); + } + + public function testUnauthorizedWhenAnonymous(): void + { + $carrier = $this->seedCompleteCarrier('Price Anonymous'); + + $client = self::createClient(); + $client->request('GET', $this->exportUrl($carrier)); + + self::assertResponseStatusCodeSame(401); + } + + private function exportUrl(Carrier $carrier): string + { + return sprintf('/api/carriers/%d/prices/export.xlsx', (int) $carrier->getId()); + } + + /** + * Relit le binaire XLSX d'une reponse et renvoie la grille de cellules. + * + * @return array> + */ + private function gridFromResponse(string $binary): array + { + $tmp = tempnam(sys_get_temp_dir(), 'xlsx_carrier_price_export_test_'); + self::assertIsString($tmp); + file_put_contents($tmp, $binary); + + try { + return IOFactory::load($tmp)->getActiveSheet()->toArray(); + } finally { + @unlink($tmp); + } + } + + /** + * Renvoie la 1re ligne de donnees dont la colonne « Type de contenant » + * (1re colonne) vaut $container, ou null. + * + * @param array> $grid + * + * @return null|array + */ + private function rowForContainer(array $grid, string $container): ?array + { + foreach (array_slice($grid, 1) as $row) { + if ((string) ($row[0] ?? '') === $container) { + return $row; + } + } + + return null; + } +} From c0fa00c9c5fc1000a9b621300af0689b4b866c9d Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 16 Jun 2026 14:39:10 +0200 Subject: [PATCH 10/13] feat(transport) : filtre archivedOnly sur l'export repertoire (coherence liste) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'export XLSX du repertoire reflete la vue liste : il propage desormais ?archivedOnly comme CarrierProvider (sinon l'export divergerait de l'ecran quand le toggle « Voir les archives » est actif). --- .../Infrastructure/Controller/CarrierExportController.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Module/Transport/Infrastructure/Controller/CarrierExportController.php b/src/Module/Transport/Infrastructure/Controller/CarrierExportController.php index a5c7d03..3df86a6 100644 --- a/src/Module/Transport/Infrastructure/Controller/CarrierExportController.php +++ b/src/Module/Transport/Infrastructure/Controller/CarrierExportController.php @@ -52,15 +52,18 @@ final class CarrierExportController // Memes filtres que la vue liste (CarrierProvider) pour que l'export // reflete exactement ce que l'utilisateur voit a l'ecran : // - includeArchived : reintegre les archives en plus des actifs ; + // - archivedOnly : n'exporte QUE les archives (prioritaire sur + // includeArchived, aligne sur le provider — toggle « Voir les archives ») ; // - search : recherche fuzzy sur le nom ; // - certificationType : filtre repetable (?certificationType[]=A&...). $includeArchived = $this->readBool($request->query->get('includeArchived')); + $archivedOnly = $this->readBool($request->query->get('archivedOnly')); $search = $request->query->getString('search') ?: null; $certificationTypes = $this->readStringList($request->query->all()['certificationType'] ?? []); /** @var list $carriers */ $carriers = $this->repository - ->createListQueryBuilder($includeArchived, $search, $certificationTypes) + ->createListQueryBuilder($includeArchived, $search, $certificationTypes, $archivedOnly) ->getQuery() ->getResult() ; From 18c88156e5c14d3409cfddaf5ededaff95728ded Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 16 Jun 2026 11:40:47 +0200 Subject: [PATCH 11/13] =?UTF-8?q?test(transport)=20:=20couverture=20RG-4.0?= =?UTF-8?q?1=E2=86=924.14=20+=20contrat=20+=20fixtures=20(ERP-163)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CarrierListTest : anti-N+1 liste (fetch-join qualimat), tri name ASC, echappatoire ?pagination=false (regle n°13) - CarrierAuditTest : POST/PATCH/archive -> audit_log entity_type='transport.Carrier' - CarrierAddressApiTest : CP/ville incoherents acceptes (RG-4.06, pas de controle de coherence serveur) - CarrierFixtures : fixtures dev completes et idempotentes (QUALIMAT validite passee, AUTRE+decharge, affrete, LIOT, complet prix CLIENT+FOURNISSEUR, archive) ; env-gated dev uniquement - spec-back § 4.0.bis : JSON reel capture (liste + detail) via CarrierSerializationContractTest --- docs/specs/M4-transporteurs/spec-back.md | 193 +++++++++-- .../DataFixtures/CarrierFixtures.php | 319 ++++++++++++++++-- .../Transport/Api/CarrierAddressApiTest.php | 20 ++ .../Module/Transport/Api/CarrierAuditTest.php | 107 ++++++ .../Module/Transport/Api/CarrierListTest.php | 105 ++++++ 5 files changed, 688 insertions(+), 56 deletions(-) create mode 100644 tests/Module/Transport/Api/CarrierAuditTest.php create mode 100644 tests/Module/Transport/Api/CarrierListTest.php diff --git a/docs/specs/M4-transporteurs/spec-back.md b/docs/specs/M4-transporteurs/spec-back.md index 6a251a5..d6dd36a 100644 --- a/docs/specs/M4-transporteurs/spec-back.md +++ b/docs/specs/M4-transporteurs/spec-back.md @@ -586,7 +586,7 @@ class Carrier implements TimestampableInterface, BlamableInterface > 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.** +> ✅ **CAPTURÉ (ERP-163)** — JSON **RÉEL** produit par `CarrierSerializationContractTest::testDodReferenceJsonShape` (transporteur complet seedé : lien QUALIMAT, 1 adresse, 1 contact, 2 prix CLIENT + FOURNISSEUR), dumpé via la variable d'env `CARRIER_DOD_DUMP=1`. Les 3 pièges sont vérifiés verts. **Le front peut démarrer sur ce contrat.** Les valeurs cosmétiques (noms, SIRET) sont nettoyées du bruit de seed ; **toutes les clés ci-dessous sont présentes telles quelles dans la réponse réelle**. > > Contraintes d'architecture validées au passage : > - Relations cross-module des prix (`client`/`supplier`/adresses) câblées **sans import inter-module** (règle n°1) via des contrats `Shared/Domain/Contract/*Interface` + `resolve_target_entities`. L'embed JSON passe par les read-groups des entités concrètes (`client:read`, `client_address:read`, `supplier:read`, `supplier_address:read`, `site:read`). Un groupe `supplier_address:read` a été **ajouté aux champs scalaires de `SupplierAddress`** (M2) pour que `supplierSupplyAddress` s'embarque comme `clientDeliveryAddress` (M1 avait déjà `client_address:read`). @@ -596,19 +596,31 @@ class Carrier implements TimestampableInterface, BlamableInterface ```jsonc { - "@context": "/api/contexts/Carrier", "@id": "/api/carriers", "@type": "Collection", + "@context": "/api/contexts/Carrier", + "@id": "/api/carriers", + "@type": "Collection", "totalItems": 1, "member": [ { - "@id": "/api/carriers/12", "@type": "Carrier", "id": 12, + "@id": "/api/carriers/26", + "@type": "Carrier", + "id": 26, "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" + "@id": "/api/qualimat_carriers/22", + "@type": "QualimatCarrier", + "id": "22", + "siret": "80012345600017", + "name": "TRANSPORTS GRELILLIER", + "address": "12 rue des Acacias", + "postalCode": "86000", + "city": "Poitiers", + "status": "Valide", + "validityDate": "2027-12-31T00:00:00+01:00" }, "certificationType": "QUALIMAT", - "createdAt": "…", "updatedAt": "…", + "createdAt": "2026-06-15T19:12:39+02:00", + "updatedAt": "2026-06-15T19:12:39+02:00", "isChartered": false, // bool présent (getter + SerializedName) "isArchived": false // bool présent (piège #3) } @@ -617,44 +629,169 @@ class Carrier implements TimestampableInterface, BlamableInterface } ``` -**`GET /api/carriers/{id}` (DÉTAIL)** — `qualimatCarrier` + `addresses[]` + `contacts[]` + `prices[]` avec relations cross-module embarquées en objet : +**`GET /api/carriers/{id}` (DÉTAIL)** — `qualimatCarrier` + `addresses[]` + `contacts[]` + `prices[]` avec relations cross-module embarquées en objet (les `@id` des sous-collections sortent en `/.well-known/genid/…` : ce sont des IRI anonymes API Platform, normal pour des entités non exposées en ressource racine) : ```jsonc { - "@id": "/api/carriers/12", "@type": "Carrier", "id": 12, + "@context": "/api/contexts/Carrier", + "@id": "/api/carriers/26", + "@type": "Carrier", + "id": 26, "name": "TRANSPORTS GRELILLIER", - "qualimatCarrier": { "@type": "QualimatCarrier", "status": "Valide", "validityDate": "…", "...": "…" }, + "qualimatCarrier": { // embarqué (statut + validité) — RG-4.04 + "@id": "/api/qualimat_carriers/22", + "@type": "QualimatCarrier", + "id": "22", + "siret": "80012345600017", + "name": "TRANSPORTS GRELILLIER", + "address": "12 rue des Acacias", + "postalCode": "86000", + "city": "Poitiers", + "status": "Valide", + "validityDate": "2027-12-31T00:00:00+01:00" + }, "certificationType": "QUALIMAT", "addresses": [ - { "@type": "CarrierAddress", "id": 4, "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "…", "createdAt": "…", "updatedAt": "…" } + { + "@type": "CarrierAddress", + "@id": "/api/.well-known/genid/9f597da33f73776f1c25", + "id": 12, + "country": "France", + "postalCode": "86000", + "city": "Poitiers", + "street": "12 rue des Acacias", + "createdAt": "2026-06-15T19:12:39+02:00", + "updatedAt": "2026-06-15T19:12:39+02:00" + } ], "contacts": [ - { "@type": "CarrierContact", "id": 5, "firstName": "Marie", "lastName": "Martin", "phonePrimary": "0612345678", "email": "…", "createdAt": "…", "updatedAt": "…" } + { + "@type": "CarrierContact", + "@id": "/api/.well-known/genid/6c6335ead4557062774f", + "id": 13, + "firstName": "Marie", + "lastName": "Martin", + "phonePrimary": "0612345678", + "email": "marie.martin@grelillier.fr", + "createdAt": "2026-06-15T19:12:39+02:00", + "updatedAt": "2026-06-15T19:12:39+02:00" + } ], "prices": [ { - "@type": "CarrierPrice", "id": 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": "/api/.well-known/genid/ac0305352bb3751a5b76", + "id": 23, + "direction": "CLIENT", + "client": { // OBJET embarqué (client:read), pas IRI nu — piège #1 + "@type": "Client", + "@id": "/api/clients/117", + "id": 117, + "companyName": "NÉGOCE MÉTAUX ATLANTIQUE", + "triageService": false, + "categories": [], + "createdAt": "2026-06-15T19:12:39+02:00", + "updatedAt": "2026-06-15T19:12:39+02:00", + "sites": [], + "isArchived": false + }, + "clientDeliveryAddress": { // OBJET embarqué (client_address:read) + "@type": "ClientAddress", + "@id": "/api/client_addresses/32", + "id": 32, + "country": "France", + "postalCode": "86000", + "city": "Poitiers", + "street": "1 rue de la Livraison", + "position": 0, + "sites": [], + "contacts": [], + "categories": [], + "createdAt": "2026-06-15T19:12:39+02:00", + "updatedAt": "2026-06-15T19:12:39+02:00", + "isProspect": false, + "isDelivery": true, + "isBilling": false, + "isBroker": false, + "isDistributor": false + }, + "departureSite": { // OBJET embarqué (site:read) + "@type": "Site", + "@id": "/api/sites/1", + "id": 1, + "name": "Chatellerault", + "street": "14 All. d'Argenson", + "postalCode": "86100", + "city": "Châtellerault", + "color": "#056CF2", + "createdAt": "2026-06-15T18:57:56+02:00", + "updatedAt": "2026-06-15T18:57:56+02:00", + "fullAddress": "14 All. d'Argenson\n86100 Châtellerault" + }, + "containerType": "BENNE", + "pricingUnit": "TONNE", + "price": "42.50", + "priceState": "VALIDE", + "createdAt": "2026-06-15T19:12:39+02:00", + "updatedAt": "2026-06-15T19:12:39+02:00" }, { - "@type": "CarrierPrice", "id": 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": "…" + "@type": "CarrierPrice", + "@id": "/api/.well-known/genid/cfee3c4dda8fb899ff3e", + "id": 24, + "direction": "FOURNISSEUR", + "supplier": { // OBJET embarqué (supplier:read), pas IRI nu — piège #1 + "@type": "Supplier", + "@id": "/api/suppliers/102", + "id": 102, + "companyName": "FERRAILLEUR GRAND OUEST", + "categories": [], + "createdAt": "2026-06-15T19:12:39+02:00", + "updatedAt": "2026-06-15T19:12:39+02:00", + "sites": [], + "isArchived": false + }, + "supplierSupplyAddress": { // OBJET embarqué (supplier_address:read) + "@type": "SupplierAddress", + "@id": "/api/supplier_addresses/38", + "id": 38, + "addressType": "DEPART", + "country": "France", + "postalCode": "17000", + "city": "La Rochelle", + "street": "2 quai de l Appro", + "createdAt": "2026-06-15T19:12:39+02:00", + "updatedAt": "2026-06-15T19:12:39+02:00" + }, + "deliverySite": { // OBJET embarqué (site:read) + "@type": "Site", + "@id": "/api/sites/1", + "id": 1, + "name": "Chatellerault", + "street": "14 All. d'Argenson", + "postalCode": "86100", + "city": "Châtellerault", + "color": "#056CF2", + "createdAt": "2026-06-15T18:57:56+02:00", + "updatedAt": "2026-06-15T18:57:56+02:00", + "fullAddress": "14 All. d'Argenson\n86100 Châtellerault" + }, + "containerType": "FOND_MOUVANT", + "pricingUnit": "FORFAIT", + "price": "320.00", + "priceState": "EN_COURS", + "createdAt": "2026-06-15T19:12:39+02:00", + "updatedAt": "2026-06-15T19:12:39+02:00" } ], - "createdAt": "…", "updatedAt": "…", - "isChartered": false, "isArchived": false + "createdAt": "2026-06-15T19:12:39+02:00", + "updatedAt": "2026-06-15T19:12:39+02:00", + "isChartered": false, // bool présent (getter + SerializedName) + "isArchived": false // bool présent (piège #3) } ``` -> Note 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+). +> Note (ERP-163) : opérations exposées = `GetCollection` + `Get` (lecture) **et** `POST`/`PATCH` (`CarrierProcessor` : normalisation RG-4.13, RG-4.01→4.14, 409 doublon, gating archive mode strict) **et** les sous-ressources d'écriture adresses/contacts/prix (`Carrier*Processor`). La couverture RG-4.01→4.14 + RBAC + audit + anti-N+1 est portée par la matrice de tests `tests/Module/Transport/Api/` (ERP-163). ### 4.1 `GET /api/carriers` — Liste @@ -909,7 +1046,7 @@ Synchronisation : `php bin/console app:sync-permissions`. - [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] **Réponses JSON RÉELLES** capturées (§ 4.0.bis) — produites par `CarrierSerializationContractTest` (ERP-163, dump `CARRIER_DOD_DUMP=1`) - [x] Matrice RBAC rôle × permission + mode strict archive (§ 5.2 / RG-4.14) - [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit + i18n, routes à plat : rappelés - [x] Réutilisations identifiées (référentiel QUALIMAT, Client/Supplier/Site partagés, `usePaginatedList`, blocs, archive, normalisation, `useAddressAutocomplete`) diff --git a/src/Module/Transport/Infrastructure/DataFixtures/CarrierFixtures.php b/src/Module/Transport/Infrastructure/DataFixtures/CarrierFixtures.php index 4ffaed0..bd4c27e 100644 --- a/src/Module/Transport/Infrastructure/DataFixtures/CarrierFixtures.php +++ b/src/Module/Transport/Infrastructure/DataFixtures/CarrierFixtures.php @@ -4,48 +4,311 @@ declare(strict_types=1); namespace App\Module\Transport\Infrastructure\DataFixtures; +use App\Module\Commercial\Infrastructure\DataFixtures\ClientFixtures; +use App\Module\Commercial\Infrastructure\DataFixtures\SupplierFixtures; +use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures; +use App\Module\Transport\Application\Service\CarrierFieldNormalizer; use App\Module\Transport\Domain\Entity\Carrier; +use App\Module\Transport\Domain\Entity\CarrierAddress; use App\Module\Transport\Domain\Entity\CarrierContact; +use App\Module\Transport\Domain\Entity\CarrierPrice; +use App\Module\Transport\Domain\Entity\QualimatCarrier; +use App\Shared\Domain\Contract\ClientAddressInterface; +use App\Shared\Domain\Contract\SiteInterface; +use App\Shared\Domain\Contract\SiteProviderInterface; +use App\Shared\Domain\Contract\SupplierAddressInterface; +use App\Shared\Domain\Entity\UploadedDocument; +use DateTimeImmutable; use Doctrine\Bundle\FixturesBundle\Fixture; +use Doctrine\Common\DataFixtures\DependentFixtureInterface; +use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ObjectManager; +use Symfony\Component\DependencyInjection\Attribute\Autowire; /** - * Fixtures dev/test MINIMALES du repertoire transporteurs (M4, ERP-155/157) : - * 2 transporteurs de demonstration suffisant a faire tourner les ecrans de - * lecture (liste + detail). Les fixtures completes (cas QUALIMAT, affrete, - * LIOT, prix CLIENT/FOURNISSEUR...) sont livrees par le worktree dedie (WT10) — - * ne pas les developper ici (scope WT3 : contrat de lecture). + * Fixtures dev/demo du repertoire transporteurs (M4) couvrant l'ensemble des cas + * metier RG-4.xx, jumelles des fixtures fournisseurs (M2). C'est ICI que vivent + * les fixtures COMPLETES (les maillons WT precedents s'etaient limites a un stub + * de lecture). Cas pivots seedes (§ 8.4) : + * - 1 transporteur QUALIMAT (lien `qualimat_carrier` + adresse copiee + + * validityDate PASSEE pour exercer le fond rouge RG-4.04) ; + * - 1 transporteur AUTRE + Decharge (UploadedDocument, RG-4.02) ; + * - 1 transporteur affrete (indexation + benne + volume obligatoires, RG-4.03) ; + * - 1 transporteur LIOT (immatriculations, certification non requise, RG-4.01) ; + * - 1 transporteur COMPLET : contacts + adresses + prix CLIENT et FOURNISSEUR ; + * - 1 transporteur archive (exclusion liste + restauration, RG-4.14). * - * Aucune dependance cross-module (pas de prix, pas de lien QUALIMAT) : la - * fixture reste autonome et joue en fin de chaine sans contrainte d'ordre. + * Resolution inter-modules conforme a la regle n°1 (pas d'import de logique) : + * - sites resolus via le contrat Shared SiteProviderInterface ; + * - client/adresse et fournisseur/adresse des prix resolus via les contrats + * Shared ClientAddressInterface / SupplierAddressInterface (relations ORM + * partagees, RG-4.10/4.11). Si la demo Commercial/Sites n'est pas chargee, les + * prix sont simplement omis (le reste de la fiche reste seede). + * + * Normalisation : valeurs fournies BRUTES puis normalisees par + * CarrierFieldNormalizer avant persist, comme le ferait le CarrierProcessor via + * l'API (name UPPERCASE, first/last Capitalize, telephones chiffres seuls, email + * lowercase, liotPlates « ; »-normalise). + * + * Idempotence : lookup par `name` normalise (coherent avec l'index unique partiel + * uq_carrier_name_active). Un transporteur deja present n'est pas reconstruit (ses + * sous-collections ne sont pas redupliquees). Rejouable sans doublon. + * + * Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by + * restent null (« Systeme » cote front), c'est attendu. + * + * Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`, la + * fixture ne charge rien : les tests seedent et nettoient leurs propres + * transporteurs et comptent sur une table `carrier` vierge — y injecter des + * transporteurs de demo casserait les comptages de liste et les cleanups. Meme + * garde-fou que ClientFixtures / SupplierFixtures. */ -final class CarrierFixtures extends Fixture +class CarrierFixtures extends Fixture implements DependentFixtureInterface { + /** SIRET de la ligne qualimat_carrier de demo (cle naturelle, insert idempotent). */ + private const string QUALIMAT_DEMO_SIRET = '90000000000017'; + + public function __construct( + private readonly CarrierFieldNormalizer $normalizer, + private readonly SiteProviderInterface $siteProvider, + #[Autowire('%kernel.environment%')] + private readonly string $environment, + ) {} + + /** + * @return array + */ + public function getDependencies(): array + { + // Les prix referencent des Client/Supplier/Site de demo (relations ORM + // partagees) : ces fixtures doivent tourner avant. + return [ + SitesFixtures::class, + ClientFixtures::class, + SupplierFixtures::class, + ]; + } + public function load(ObjectManager $manager): void { - // Transporteur certifie « classique ». - $alpha = new Carrier(); - $alpha->setName('TRANSPORTS ALPHA'); - $alpha->setCertificationType('GMP_PLUS'); - $manager->persist($alpha); + // Donnees de demo : dev uniquement. En test, on laisse la table vierge. + if ('test' === $this->environment) { + return; + } - $contact = new CarrierContact(); - $contact->setCarrier($alpha); - $contact->setLastName('Durand'); - $contact->setPhonePrimary('0612345678'); - $alpha->addContact($contact); - $manager->persist($contact); + // === Transporteur QUALIMAT (RG-4.01) — adresse copiee + validite PASSEE (RG-4.04) === + [$grelillier, $isNew] = $this->ensureCarrier($manager, 'Transports Grelillier'); + if ($isNew) { + $grelillier->setQualimatCarrier($this->ensureQualimatDemoLine($manager)); + $grelillier->setCertificationType('QUALIMAT'); + // Adresse pre-remplie depuis la copie QUALIMAT (RG-4.05). + $this->addAddress($grelillier, '86000', 'Poitiers', '12 rue des Acacias'); + $this->addContact($grelillier, 'Marie', 'Martin', 'Exploitation', '06 12 34 56 78', null, 'marie.martin@grelillier.fr'); + } - // 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); + // === Transporteur AUTRE + Decharge (RG-4.02) === + [$pandele, $isNew] = $this->ensureCarrier($manager, 'Transports Pandele'); + if ($isNew) { + $pandele->setCertificationType('AUTRE'); + $pandele->setDischargeDocument($this->buildDischargeDocument($manager)); + $this->addContact($pandele, 'Luc', 'Pandele', 'Gerant', '05 49 11 22 33', null, 'luc.pandele@pandele.fr'); + } + + // === Transporteur affrete (RG-4.03) — indexation + benne + volume === + [$affrete, $isNew] = $this->ensureCarrier($manager, 'Affreteurs Reunis'); + if ($isNew) { + $affrete->setCertificationType('GMP_PLUS'); + $affrete->setIsChartered(true); + $affrete->setIndexationRate('5.00'); + $affrete->setContainerType('BENNE'); + $affrete->setVolumeM3('90.00'); + $this->addAddress($affrete, '17000', 'La Rochelle', '4 quai des Affreteurs'); + } + + // === Cas LIOT (RG-4.01) — immatriculations, certification non requise === + [$liot, $isNew] = $this->ensureCarrier($manager, 'LIOT'); + if ($isNew) { + $liot->setLiotPlates($this->normalizer->normalizeLiotPlates('ab-123-cd ; ef-456-gh ; gh-789-ij')); + } + + // === Transporteur COMPLET — contacts + adresses + prix CLIENT et FOURNISSEUR === + [$complet, $isNew] = $this->ensureCarrier($manager, 'Transports Logistique Globale'); + if ($isNew) { + $complet->setCertificationType('OVOCOM'); + $this->addAddress($complet, '86100', 'Châtellerault', '20 zone des Transporteurs'); + $this->addContact($complet, 'Sophie', 'Bernard', 'Directrice', '05 49 44 55 66', '06 99 88 77 66', 'sophie.bernard@logistique-globale.fr', 0); + $this->addContact($complet, 'Marc', 'Lopez', 'Affretement', '05 49 44 55 67', null, 'marc.lopez@logistique-globale.fr', 1); + $this->addPrices($manager, $complet); + } + + // === Transporteur archive (RG-4.14) === + [$archive, $isNew] = $this->ensureCarrier($manager, 'Transports Anciens', isArchived: true); + if ($isNew) { + $archive->setCertificationType('COMPTE_PROPRE'); + $this->addContact($archive, 'Paul', 'Ancien', 'Ex-gerant', '05 49 00 00 00', null, 'paul.ancien@anciens.fr'); + } $manager->flush(); } + + /** + * Cree un transporteur (nom normalise UPPERCASE) s'il n'existe pas encore, + * sinon retourne l'existant. Retourne [Carrier, isNew] : isNew=false bloque la + * reconstruction des sous-collections (idempotence sans doublon). + * + * @return array{0: Carrier, 1: bool} + */ + private function ensureCarrier(ObjectManager $manager, string $name, bool $isArchived = false): array + { + $normalizedName = (string) $this->normalizer->normalizeName($name); + + $existing = $manager->getRepository(Carrier::class)->findOneBy(['name' => $normalizedName]); + if ($existing instanceof Carrier) { + return [$existing, false]; + } + + $carrier = new Carrier(); + $carrier->setName($normalizedName); + + if ($isArchived) { + $carrier->setIsArchived(true); + $carrier->setArchivedAt(new DateTimeImmutable()); + } + + $manager->persist($carrier); + + return [$carrier, true]; + } + + /** + * Ajoute une adresse au transporteur (cascade persist via Carrier.addresses). + */ + private function addAddress(Carrier $carrier, string $postalCode, string $city, string $street): void + { + $address = new CarrierAddress(); + $address->setPostalCode($postalCode); + $address->setCity($city); + $address->setStreet($street); + $carrier->addAddress($address); + } + + /** + * Ajoute un contact normalise au transporteur (cascade persist via + * Carrier.contacts). Au moins un champ est toujours fourni (RG-4.08). + */ + private function addContact( + Carrier $carrier, + ?string $firstName, + ?string $lastName, + ?string $jobTitle, + ?string $phonePrimary, + ?string $phoneSecondary, + ?string $email, + int $position = 0, + ): void { + $contact = new CarrierContact(); + $contact->setFirstName($this->normalizer->normalizePersonName($firstName)); + $contact->setLastName($this->normalizer->normalizePersonName($lastName)); + $contact->setJobTitle($jobTitle); + $contact->setPhonePrimary($this->normalizer->normalizePhone($phonePrimary)); + $contact->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary)); + $contact->setEmail($this->normalizer->normalizeEmail($email)); + $contact->setPosition($position); + + $carrier->addContact($contact); + } + + /** + * Ajoute un prix CLIENT et un prix FOURNISSEUR au transporteur (RG-4.10/4.11), + * en resolvant les relations cross-module (client/adresse de livraison + site + * de depart ; fournisseur/adresse d'appro + site de livraison) via les contrats + * Shared. Si la demo Commercial/Sites n'est pas disponible, les prix sont omis. + */ + private function addPrices(ObjectManager $manager, Carrier $carrier): void + { + $site = $this->siteProvider->findByName('Chatellerault'); + + // Branche CLIENT (RG-4.10) : 1ere adresse de livraison de la demo M1. + $clientAddress = $manager->getRepository(ClientAddressInterface::class)->findOneBy(['isDelivery' => true]); + if ($site instanceof SiteInterface && $clientAddress instanceof ClientAddressInterface && null !== $clientAddress->getClient()) { + $clientPrice = new CarrierPrice(); + $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); + } + + // Branche FOURNISSEUR (RG-4.11) : 1ere adresse de DEPART de la demo M2. + $supplierAddress = $manager->getRepository(SupplierAddressInterface::class)->findOneBy(['addressType' => 'DEPART']); + if ($site instanceof SiteInterface && $supplierAddress instanceof SupplierAddressInterface && null !== $supplierAddress->getSupplier()) { + $supplierPrice = new CarrierPrice(); + $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); + } + } + + /** + * Construit (non persiste explicitement — cascade via la FK Carrier) un + * UploadedDocument de demo pour la Decharge (RG-4.02). Pas de fichier reel sur + * disque : metadonnees factices suffisantes pour la demo. + */ + private function buildDischargeDocument(ObjectManager $manager): UploadedDocument + { + $document = new UploadedDocument( + 'decharge-demo.pdf', + 'demo/decharge-demo.pdf', + 'application/pdf', + 12_345, + str_repeat('0', 64), + new DateTimeImmutable(), + ); + $manager->persist($document); + + return $document; + } + + /** + * Insere (idempotent, par SIRET) une ligne `qualimat_carrier` de demo a + * validite PASSEE (RG-4.04) puis retourne l'entite (lecture seule) rechargee. + * La table est normalement alimentee par `app:qualimat:sync` ; en demo on pose + * une ligne directe en DBAL (l'entite mappee n'expose aucune ecriture API). + */ + private function ensureQualimatDemoLine(ObjectManager $manager): QualimatCarrier + { + $repository = $manager->getRepository(QualimatCarrier::class); + $existing = $repository->findOneBy(['siret' => self::QUALIMAT_DEMO_SIRET]); + if ($existing instanceof QualimatCarrier) { + return $existing; + } + + if ($manager instanceof EntityManagerInterface) { + $manager->getConnection()->insert('qualimat_carrier', [ + 'siret' => self::QUALIMAT_DEMO_SIRET, + 'name' => 'TRANSPORTS GRELILLIER', + 'address' => '12 rue des Acacias', + 'postal_code' => '86000', + 'city' => 'Poitiers', + 'status' => 'Valide', + // Validite PASSEE : exerce le fond rouge RG-4.04 cote front. + 'validity_date' => '2024-12-31', + 'is_active' => 'true', + 'last_synced_at' => new DateTimeImmutable()->format('Y-m-d H:i:s'), + ]); + } + + /** @var QualimatCarrier $line */ + return $repository->findOneBy(['siret' => self::QUALIMAT_DEMO_SIRET]); + } } diff --git a/tests/Module/Transport/Api/CarrierAddressApiTest.php b/tests/Module/Transport/Api/CarrierAddressApiTest.php index 53ea6f3..6d163b8 100644 --- a/tests/Module/Transport/Api/CarrierAddressApiTest.php +++ b/tests/Module/Transport/Api/CarrierAddressApiTest.php @@ -63,6 +63,26 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase self::assertResponseStatusCodeSame(422); } + public function testInconsistentPostalCodeAndCityIsAccepted(): void + { + // RG-4.06 : la validation serveur borne le FORMAT du code postal + // (^[0-9]{4,5}$) mais ne controle PAS la coherence CP <-> ville (deleguee + // a l'autocomplete BAN cote front). Un CP valide avec une ville qui ne lui + // correspond pas est donc accepte (201). + $carrier = $this->seedCarrierWithChartered('Cp Ville Incoherents', false); + $client = $this->createAdminClient(); + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'postalCode' => '86000', // Poitiers + 'city' => 'Marseille', // incoherent, mais non controle + 'street' => '1 rue de la Coherence', + ], + ]); + self::assertResponseStatusCodeSame(201); + } + public function testCharteredCarrierIncompleteAddressReturns422(): void { // Transporteur affrete : RG-4.05 exige Pays/CP/Ville/Adresse. CP valide mais diff --git a/tests/Module/Transport/Api/CarrierAuditTest.php b/tests/Module/Transport/Api/CarrierAuditTest.php new file mode 100644 index 0000000..d8ad120 --- /dev/null +++ b/tests/Module/Transport/Api/CarrierAuditTest.php @@ -0,0 +1,107 @@ + ligne audit_log entity_type='transport.Carrier' + * avec l'action et le diff attendus ; + * - le diff d'archivage trace bien le champ `isArchived` (RG-4.14). + * + * Jumeau de {@see \App\Tests\Module\Commercial\Api\SupplierAuditTest}. + * + * @internal + */ +final class CarrierAuditTest extends AbstractCarrierApiTestCase +{ + private const string CARRIER_TYPE = 'transport.Carrier'; + + private ?Connection $auditConnection = null; + + protected function setUp(): void + { + parent::setUp(); + self::bootKernel(); + + /** @var Connection $conn */ + $conn = self::getContainer()->get('doctrine.dbal.audit_connection'); + $this->auditConnection = $conn; + } + + protected function tearDown(): void + { + if (null !== $this->auditConnection) { + $this->auditConnection->close(); + } + parent::tearDown(); + } + + public function testPostCarrierIsAudited(): void + { + $admin = $this->createAdminClient(); + + $created = $admin->request('POST', '/api/carriers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Audit Created Co'), + ])->toArray(); + self::assertResponseStatusCodeSame(201); + + self::assertGreaterThanOrEqual( + 1, + $this->countAudit(self::CARRIER_TYPE, (string) $created['id'], 'create'), + 'Un audit_log "create" doit etre genere pour le transporteur.', + ); + } + + public function testPatchCarrierIsAudited(): void + { + $admin = $this->createAdminClient(); + $seed = $this->seedCarrier('Audit Patch Co'); + + $admin->request('PATCH', '/api/carriers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['name' => 'Audit Patch Renamed'], + ]); + self::assertResponseStatusCodeSame(200); + + self::assertGreaterThanOrEqual( + 1, + $this->countAudit(self::CARRIER_TYPE, (string) $seed->getId(), 'update'), + 'Un audit_log "update" doit etre genere pour le PATCH.', + ); + } + + public function testArchiveCarrierIsAudited(): void + { + $admin = $this->createAdminClient(); + $seed = $this->seedCarrier('Audit Archive Co'); + + $admin->request('PATCH', '/api/carriers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + self::assertResponseStatusCodeSame(200); + + $rows = $this->auditConnection->fetchAllAssociative( + 'SELECT changes FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action ORDER BY performed_at DESC', + ['type' => self::CARRIER_TYPE, 'id' => (string) $seed->getId(), 'action' => 'update'], + ); + self::assertGreaterThanOrEqual(1, count($rows)); + + /** @var array $changes */ + $changes = json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR); + self::assertArrayHasKey('isArchived', $changes, 'Le diff d\'archivage doit tracer isArchived (RG-4.14).'); + } + + private function countAudit(string $type, string $id, string $action): int + { + return (int) $this->auditConnection->fetchOne( + 'SELECT COUNT(*) FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action', + ['type' => $type, 'id' => $id, 'action' => $action], + ); + } +} diff --git a/tests/Module/Transport/Api/CarrierListTest.php b/tests/Module/Transport/Api/CarrierListTest.php new file mode 100644 index 0000000..f9b1856 --- /dev/null +++ b/tests/Module/Transport/Api/CarrierListTest.php @@ -0,0 +1,105 @@ +createAdminClient(); + $token = $this->token(); + + // Inseres dans le desordre ; le tri par defaut doit remonter ALPHA avant ZETA. + $this->seedCarrier($token.' Zeta'); + $this->seedCarrier($token.' Alpha'); + + $names = array_map( + static fn (array $m): string => (string) $m['name'], + $http->request('GET', '/api/carriers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray()['member'], + ); + + self::assertCount(2, $names); + self::assertStringContainsString('ALPHA', $names[0], 'Tri name ASC (spec § 4.1).'); + self::assertStringContainsString('ZETA', $names[1]); + } + + public function testPaginationDisabledReturnsFullCollection(): void + { + $http = $this->createAdminClient(); + $token = $this->token(); + + for ($i = 0; $i < 3; ++$i) { + $this->seedCarrier($token.' Item'.$i); + } + + // ?pagination=false : echappatoire pour alimenter un