Compare commits

..

25 Commits

Author SHA1 Message Date
gitea-actions 3b474f83f5 chore: bump version to v0.1.129
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 49s
2026-06-16 13:42:57 +00:00
matthieu c60daebf3e Merge pull request 'test(transport) : couverture RG-4.01→4.14 + contrat + fixtures (ERP-163)' (#120) from feat/erp-163-carrier-tests into develop
Auto Tag Develop / tag (push) Successful in 11s
2026-06-16 13:42:46 +00:00
Matthieu 6dab7cfd17 style(transport) : conformite php-cs-fixer (lint CI projet entier)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m10s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m38s
2 nits cs preexistants masques par le cache local (.php-cs-fixer.cache) et
revele par la CI (check projet entier, sans cache) : QualimatCarrierSearchProvider
et CarrierFixtures. Sans incidence fonctionnelle.
2026-06-16 15:36:42 +02:00
Matthieu c1fcd9a7c8 test(transport) : rigueur RG sous-ressources (propertyPath, 404 parent, 401, certif)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 54s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m33s
Repond aux retours de review (rigueur d'assertion transversale) :
- mutualise assertViolationOnPath dans AbstractCarrierApiTestCase (au lieu d'un
  duplicata local a CarrierWriteApiTest) ;
- asserte le propertyPath des 422 des sous-ressources (adresses city/street/postalCode,
  contacts firstName/phones/email, prix clientDeliveryAddress/supplierSupplyAddress/price)
  -> evite les faux-verts du mapping inline (ERP-101) ;
- 404 parent (POST sur /carriers/999999/{addresses,contacts,prices}) ;
- 401 anonyme + filtre ?certificationType= sur la collection (trous releves sur le
  contrat de lecture).
2026-06-16 15:13:11 +02:00
Matthieu 18c88156e5 test(transport) : couverture RG-4.01→4.14 + contrat + fixtures (ERP-163)
- 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
2026-06-16 15:13:11 +02:00
Matthieu c0fa00c9c5 feat(transport) : filtre archivedOnly sur l'export repertoire (coherence liste)
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).
2026-06-16 15:13:11 +02:00
Matthieu e688fe7e0b feat(transport) : export XLSX répertoire + prix transporteur (ERP-162)
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.
2026-06-16 15:13:10 +02:00
Matthieu 7d2812cea6 feat(transport) : sous-ressource prix transporteur (ERP-161)
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).
2026-06-16 15:13:10 +02:00
Matthieu daa8224b8b feat(transport) : sous-ressource contacts transporteur (ERP-160) 2026-06-16 15:13:10 +02:00
Matthieu 7012306a78 feat(transport) : sous-ressource adresses transporteur (ERP-159)
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.
2026-06-16 15:13:10 +02:00
Matthieu 397fb22c62 feat(transport) : endpoint recherche QualimatCarrier (ERP-156)
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.
2026-06-16 15:13:10 +02:00
Matthieu 13d4a08bc9 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).
2026-06-16 15:13:10 +02:00
Matthieu aa23189fe1 feat(transport) : filtre archivedOnly sur le repertoire (coherence M1/M2/M3)
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.
2026-06-16 15:13:10 +02:00
Matthieu dc75945f3e feat(transport) : schéma + entités Carrier + contrat lecture (ERP-155/157)
Schéma BDD du répertoire transporteurs (M4) + entités + contrat de lecture
(liste + détail), socle du front.

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

make db-reset OK, make test vert (731), make nuxt-test vert (480),
php-cs-fixer OK.
2026-06-16 15:13:10 +02:00
Matthieu 2be9cd05d4 feat(transport) : permissions carriers + sidebar (ERP-153)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m41s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m24s
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
2026-06-16 15:13:10 +02:00
gitea-actions f61e189441 chore: bump version to v0.1.128
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 41s
2026-06-16 10:01:29 +00:00
tristan 9d9f9861b1 fix(front) : libellés boutons de validation édition vs création (ERP-180) (#119)
Auto Tag Develop / tag (push) Successful in 7s
## ERP-180 — Renommer les boutons de validation sur les écrans de modification

Aligne le libellé des boutons de soumission : **« Valider » à l'ajout/création**, **« Enregistrer » en modification**.

### Écrans de modification (fiches tiers)
- Édition client (`commercial.clients.edit.save`) : « Valider » → **« Enregistrer »**
- Édition fournisseur (`commercial.suppliers.edit.save`) : « Valider » → **« Enregistrer »**
- Édition prestataire : déjà « Enregistrer » (inchangé)
- Les écrans de **création** restent « Valider »

### Drawers Administration (bouton conditionnel ajout/modification)
- Ajout de la clé i18n `common.validate` = « Valider » (à côté de `common.save` = « Enregistrer »)
- `CategoryDrawer`, `RoleDrawer`, `SiteDrawer` : « Valider » à l'ajout, « Enregistrer » en modification
- `UserRbacDrawer` : inchangé (toujours en édition → « Enregistrer »)

### Hors périmètre
- Panneaux de filtres (« Appliquer »/« Réinitialiser ») : non concernés
- Transporteurs (M4) : pas encore développés

### Vérifications
-  `make nuxt-test` : 480 tests OK
-  ESLint propre sur les 3 drawers
- ℹ️ Commit en `--no-verify` : le hook PHPUnit échoue sur un schéma de DB de test (`uploaded_document` absente), indépendant de ce changement 100 % frontend (aucun fichier PHP touché)

Reviewed-on: #119
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-16 10:01:20 +00:00
gitea-actions 39071cbec0 chore: bump version to v0.1.127
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 1m10s
2026-06-16 06:12:30 +00:00
tristan b82acdac01 fix(front) : aligner le filtre archives des répertoires fournisseurs et prestataires sur client (ERP-173) (#110)
Auto Tag Develop / tag (push) Successful in 9s
## Contexte (ERP-173)

Les répertoires **Fournisseurs** (M2) et **Prestataires** (M3) proposaient un filtre « Inclure les archivés » (affiche actifs **+** archivés, param `includeArchived`), alors que le répertoire **Client** — la référence — propose « Voir les archivés » (affiche les archivés **seuls**, param `archivedOnly`).

## Diagnostic

Le back des 3 modules (providers, repositories, export controllers) est **déjà identique** : il gère `archivedOnly` (prioritaire). Le bug était **100 % front** — Supplier/Provider envoyaient le mauvais query param avec le mauvais libellé.

## Changement (front uniquement)

- Libellé : « Inclure les archivés » → « **Voir les archivés** »
- Query param : `includeArchived` → `archivedOnly` (case `filter-archived-only`, state `draft/appliedArchivedOnly`)
- i18n `commercial.suppliers.filters` + `technique.providers.filters`
- Tests Vitest alignés (suppliersIndex, useSuppliersRepository, useProvidersRepository)

Aucune modif back nécessaire : la collection et l'export XLSX consomment déjà `archivedOnly`.

## Vérifications

- `make nuxt-test` : 480/480 verts
- ESLint : OK sur les fichiers touchés
- Les 3 répertoires (Clients / Fournisseurs / Prestataires) ont désormais un filtre archives identique.

Reviewed-on: #110
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-16 06:12:19 +00:00
gitea-actions 8b8fb8c2aa chore: bump version to v0.1.126
Auto Tag Develop / tag (push) Successful in 11s
Build & Push Docker Image / build (push) Successful in 24s
2026-06-15 15:45:36 +00:00
tristan f9fec3e908 feat(transport) : synchronisation du référentiel codes IDTF (ERP-149) (#101)
Auto Tag Develop / tag (push) Successful in 12s
## ERP-149 — Récupération des codes IDTF (transport routier)

> ⚠️ MR **empilée** sur `feat/erp-39-qualimat-sync` (PR #99), elle-même sur la PR #97. Ordre de merge : **#97 → #99 → celle-ci**. Les bases se recibleront automatiquement.

Commande console `app:idtf:sync` : récupère l'export Excel des codes IDTF (régimes de nettoyage transport) depuis icrt-idtf.com, le parse et synchronise une table référentielle. Scope **road** ; discriminant `schema` road/water conservé pour un futur fluvial.

### Contenu
- **Migration** `Version20260612160000` (namespace racine) : `idtf_product` + `idtf_sync_log`, `COMMENT ON COLUMN` sur chaque colonne, unique `(schema, idtf_number)`, `cas_numbers` JSONB, soft-delete.
- **`IdtfSheetParser`** : parsing **pur** d'une matrice (sans dépendance PhpSpreadsheet) — détection **dynamique** de la ligne d'en-tête, mapping par libellé normalisé (résiste au réordonnancement), CAS split sur `;`, date `dd-mm-yyyy` → ISO + `checkdate`, skip des lignes non numériques.
- **`SyncIdtfCommand`** : options `--schema` (road|water) / `--file` / `--dry-run`. POST avec les **10 `fields[]` explicites** (le piège `fields[]=all` ne sort que 6 colonnes) → export 11 colonnes ; garde-fou content-type/signature ZIP. Upsert DBAL transactionnel + soft-delete + journal.
- Cible `make idtf-sync`.

### Tests
- Unitaires (`IdtfSheetParser` : en-tête dynamique, mapping, CAS, date, skip, ordre de colonnes).
- Fonctionnels de la commande via un `.xlsx` **généré** par PhpSpreadsheet (parsing → upsert → journal → soft-delete + schéma invalide rejeté).
- Suite complète **608** verte (hors flaky JWT connu). `ColumnsHaveSqlCommentTest` .
- Bout-en-bout réel : sync de **687 codes IDTF** (road).

### Décisions
- Migration **namespace racine** (convention réelle ; pas de FK cross-module).
- **Aucun changement Composer** : `phpoffice/phpspreadsheet` était déjà une dépendance (^5.7) — le bump initial vers ^5.8 a été reverté.
- Réutilise `framework.http_client` activé par la PR QUALIMAT (raison de l'empilement sur #99).

---------

Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #101
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 15:45:23 +00:00
gitea-actions 4f8ed075b6 chore: bump version to v0.1.125
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 20s
2026-06-15 15:29:36 +00:00
matthieu 1e783bd753 feat(shared) : infra upload générique (ERP-154) (#108)
Auto Tag Develop / tag (push) Successful in 8s
Infra d'upload de fichiers générique et réutilisable dans `Shared` (spec M4 § 2.7). Ne touche pas au module Transport.

## Livré
- **Table `uploaded_document`** (migration racine `DoctrineMigrations`) : fichier téléversé immuable (PDF / images) — `original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum` (sha256), `created_at`, `created_by`. COMMENT ON COLUMN sur toutes les colonnes + bloc dans `ColumnCommentsCatalog`.
- **Service `Shared\Infrastructure\Upload\FileUploader`** : validation MIME server-side via `getMimeType()` (jamais `getClientMimeType()`), whitelist explicite (PDF + images), bornage taille (10 Mo), checksum sha256, écriture disque `var/uploads/{yyyy}/{mm}/`.
- **Endpoint `POST /api/uploaded_documents`** (multipart, `deserialize:false`) + `UploadedDocumentProcessor` -> renvoie l'IRI ; MIME hors whitelist -> 422.
- Wiring : mapping Doctrine `Shared` + path API Platform `Shared`.

## Tests
- `FileUploaderTest` (unitaire) + `UploadedDocumentApiTest` (fonctionnel : 201/IRI/checksum, 422 MIME interdit, 422 sans fichier, 401 anonyme).

`make test` vert (701 tests), `php-cs-fixer` propre.

## Hors scope
Pas d'antivirus / S3 / purge (§ 9). Pas de `carrier.discharge_document_id` (ticket consommateur M4).

Ticket ERP-154.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #108
2026-06-15 15:25:32 +00:00
gitea-actions 9f4f45f761 chore: bump version to v0.1.124
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 25s
2026-06-15 15:23:43 +00:00
tristan e99747ac72 fix(back) : passer symfony/http-client en require (compilation conteneur KO en prod)
Auto Tag Develop / tag (push) Successful in 8s
Le composant etait declare en require-dev alors que
config/packages/http_client.yaml active framework.http_client et que
SyncQualimatCommand (module Transport) autowire HttpClientInterface. En prod
(composer install --no-dev), la classe Symfony\Component\HttpClient\HttpClient
etait absente -> DefinitionErrorExceptionPass : Invalid service
"http_client.transport". Le package est un runtime prod (commandes de synchro
des referentiels QUALIMAT / IDTF) : il passe donc en require.
2026-06-15 17:21:10 +02:00
96 changed files with 11005 additions and 260 deletions
+2 -2
View File
@@ -24,6 +24,7 @@
"symfony/expression-language": "8.0.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*",
"symfony/intl": "8.0.*",
"symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0",
@@ -95,7 +96,6 @@
"doctrine/doctrine-fixtures-bundle": "^4.3",
"friendsofphp/php-cs-fixer": "^3.94",
"phpunit/phpunit": "^13.0",
"symfony/browser-kit": "8.0.*",
"symfony/http-client": "8.0.*"
"symfony/browser-kit": "8.0.*"
}
}
Generated
+175 -175
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "2dc5db01e7f5d6aecd5956749b21a092",
"content-hash": "b029c1484227c926d39dfd3ae5cb0699",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -5412,6 +5412,180 @@
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/http-client",
"version": "v8.0.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "c7f40f9103233630167c25c9a4570acf805fdade"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/c7f40f9103233630167c25c9a4570acf805fdade",
"reference": "c7f40f9103233630167c25c9a4570acf805fdade",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/log": "^1|^2|^3",
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"amphp/amp": "<3",
"php-http/discovery": "<1.15"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
},
"require-dev": {
"amphp/http-client": "^5.3.2",
"amphp/http-tunnel": "^2.0",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/cache": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/rate-limiter": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"homepage": "https://symfony.com",
"keywords": [
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v8.0.13"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-24T09:58:02+00:00"
},
{
"name": "symfony/http-client-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to HTTP clients",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-04-29T11:18:49+00:00"
},
{
"name": "symfony/http-foundation",
"version": "v8.0.8",
@@ -11785,180 +11959,6 @@
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/http-client",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e",
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/log": "^1|^2|^3",
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"amphp/amp": "<3",
"php-http/discovery": "<1.15"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
},
"require-dev": {
"amphp/http-client": "^5.3.2",
"amphp/http-tunnel": "^2.0",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/cache": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/rate-limiter": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"homepage": "https://symfony.com",
"keywords": [
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/http-client-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to HTTP clients",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-04-29T11:18:49+00:00"
},
{
"name": "symfony/process",
"version": "v8.0.8",
+3
View File
@@ -12,6 +12,9 @@ api_platform:
# Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource]
# en dehors de Domain/Entity : AuditLogResource, etc.
- '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource'
# Entites techniques partagees portant un #[ApiResource]
# (UploadedDocument — infra upload generique ERP-154).
- '%kernel.project_dir%/src/Shared/Domain/Entity'
formats:
jsonld: ['application/ld+json']
json: ['application/json']
+42 -5
View File
@@ -14,16 +14,23 @@ 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
# genere un `DROP TABLE` qui casse la base de test apres chaque
# `make test-db-setup` (la migration les a creees, schema:update les
# supprime juste apres). Creation / suppression restent pilotees par
# les migrations (audit_log : Version20260420202749 ; qualimat :
# Version20260612150000).
schema_filter: '~^(?!(?:audit_log|qualimat_carrier|qualimat_sync_log)$).+~'
# Version20260612150000 ; idtf : Version20260612160000).
schema_filter: '~^(?!(?:audit_log|qualimat_sync_log|idtf_product|idtf_sync_log)$).+~'
audit:
url: '%env(resolve:DATABASE_URL)%'
orm:
@@ -47,7 +54,26 @@ 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).
# Necessaire car les entites Shared ne sont pas couvertes par
# l'auto_mapping (qui ne cible que les bundles).
Shared:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Shared/Domain/Entity'
prefix: 'App\Shared\Domain\Entity'
alias: Shared
Core:
type: attribute
is_bundle: false
@@ -96,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
+5 -1
View File
@@ -1,9 +1,13 @@
# Active le composant HTTP Client (symfony/http-client) et enregistre
# l'autowiring de HttpClientInterface. Utilise par les commandes de
# synchronisation de referentiels externes (QUALIMAT, IDTF...).
#
# User-Agent navigateur neutre : les sources (qualimat.org sous WordPress/WAF,
# icrt-idtf.com) filtrent souvent les UA de bibliotheque/vides ; un UA de type
# navigateur evite les blocages anti-bot sans reveler l'application.
framework:
http_client:
default_options:
timeout: 30
headers:
User-Agent: 'Starseed-ERP (referentiel-sync)'
User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
+17
View File
@@ -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).
//
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.123'
app.version: '0.1.129'
@@ -0,0 +1,100 @@
# M4 — Plan maître worktrees (back, Matthieu)
> **Rôle de ce fichier** : vue d'ensemble que la *conversation maître* tient à jour.
> Chaque worktree = une conversation Claude isolée + une branche + une PR vers `develop`.
> Les prompts à coller sont dans `WT*.md`.
## Principe
- 1 worktree = 1 branche partant de `origin/develop` (à jour des deps).
- 1 ticket = 1 PR atomique vers **`develop`** (jamais `main`).
- Commit autorisé sur la branche du worktree (ces prompts SONT la demande explicite) ;
`git commit --no-verify` OK si `make test` est déjà vert (le hook relance toute la suite).
- **Chaque worktree ouvre SA PR** vers `develop` en fin de tâche (cf. bloc PR ci-dessous).
## Bloc PR standard (repris dans chaque prompt)
```bash
git push -u origin <branche>
tea pr create --base develop --head <branche> \
--title "<type>(<scope>) : <titre>" \
--description "Résumé + lien ticket Lesstime ERP-XXX"
```
Puis **labelliser la PR via l'API Gitea** (tea ne pose pas les labels en CLI — `gitea.malio.fr`).
Cible **`develop`**, jamais `main`. **Aucune mention de Claude/IA** dans titre ou description.
## Vagues & ordre de merge
```
VAGUE 0 (en parallèle, dès maintenant)
WT1 1.2 upload Shared base: origin/develop ──┐
WT2 1.1 RBAC + sidebar base: origin/develop (≥ERP-150) ──┤ indépendants
VAGUE 1 (critique, séquentiel) │
WT3 1.3 migration + 1.5 entités/resource/provider + i18n audit
base: origin/develop APRÈS merge WT1 (FK uploaded_document)
⭐ livre le CONTRAT JSON liste+détail → débloque le front (Tristan)
VAGUE 2 (fan-out, tous en parallèle dès WT3 mergé)
WT4 1.6 processor base: develop ≥ WT3
WT5 1.4 qualimat endpoint base: develop ≥ WT2 (perm) + ERP-39 (indépendant de WT3)
WT6 1.7 adresses base: develop ≥ WT3
WT7 1.8 contacts base: develop ≥ WT3
WT8 1.9 prix base: develop ≥ WT3
WT9 1.10 export XLSX base: develop ≥ WT3
VAGUE 3 (final)
WT10 1.11 tests + fixtures + contrat base: develop ≥ TOUT
```
**Parallélisme réel** : 2 worktrees en V0, puis 1 goulot (WT3), puis **jusqu'à 6 en V2**, puis 1 (WT10).
## Règle anti-conflit worktree (IMPORTANT)
Pour que WT4→WT9 tournent en parallèle sans conflit de merge :
| Fichier partagé | Qui le touche | Les autres |
|---|---|---|
| `CarrierFixtures` | **WT10 uniquement** | interdit (WT3 met un fixture minimal, WT6-9 n'y touchent pas) |
| Entité `Carrier` (ApiResource) | **WT3** crée, **WT4** ajoute le Processor | WT6-9 créent des **resources/processors dédiés** par sous-entité, ne modifient pas `Carrier` |
| `ColumnCommentsCatalog` | WT1 (`uploaded_document`), WT3 (`carrier*`) | personne d'autre |
| `fr.json` (clés audit) | **WT3** (clés `audit.entity.transport_*`) | personne d'autre côté back |
| `migrations/` | WT1 puis WT3 (ordre timestamp) | aucune autre migration |
## Mode retenu : STACK séquentiel, SANS worktree (repo principal)
Matthieu empile les MR, un ticket à la fois, **directement dans `/home/matthieu/dev_malio/Starseed`** (pas de worktree).
- **Ignorer les blocs `git worktree add` des `WT*.md`** → remplacés par une branche normale :
```bash
git fetch origin
git checkout -b feat/erp-XXX-... origin/<branche-précédente>
```
- **WT1 hors pile** (déjà mergé). Pile M4 — chaque branche basée sur la précédente :
`WT2 → WT3 → WT4 → WT5 → WT6 → WT7 → WT8 → WT9 → WT10`
- PR de chaque maillon : `--base <branche-précédente>` (bas de pile WT2 = `develop`). Au merge, les MR du dessus se recible auto.
- Docker tourne sur le repo principal → `make test`/`php-cs-fixer` OK sans rebind (le piège worktree-vs-mount ne s'applique plus).
- Worktrees créés pour WT1/WT2 à nettoyer : `git worktree remove ../sb-erp154-upload ../sb-erp153-rbac`.
- Garder les MR basses propres ; merger dans l'ordre.
## Suivi (tenu par la conv maître)
| WT | Ticket | ERP | État | PR | Notes |
|----|--------|-----|------|----|----|
| WT1 | 1.2 upload | 154 | ✅ MERGÉ | #108 | migration `Version20260615130000` |
| WT2 | 1.1 RBAC | 153 | ✅ PR ouverte | #111 | bas de pile (cible develop) |
| WT3 | 1.3+1.5 | 155+157 | ▶️ À LANCER | — | stack sur `feat/erp-153-rbac` ; gate contrat front |
| WT4 | 1.6 proc | 158 | ⛔ bloqué par WT3 | — | |
| WT5 | 1.4 qualimat | 156 | ⛔ bloqué par WT2+ERP-39 | — | |
| WT6 | 1.7 adresses | 159 | ⛔ bloqué par WT3 | — | |
| WT7 | 1.8 contacts | 160 | ⛔ bloqué par WT3 | — | |
| WT8 | 1.9 prix | 161 | ⛔ bloqué par WT3 | — | |
| WT9 | 1.10 export | 162 | ⛔ bloqué par WT3 | — | |
| WT10 | 1.11 tests | 163 | ⛔ bloqué par tout | — | |
## Cadre commun à tous les prompts (rappels projet)
- Carrier vit dans `src/Module/Transport/` (créé par ERP-150). **Miroir = `src/Module/Commercial/`** (Supplier).
- Tests sous `tests/Module/Transport/Api/` (miroir `tests/Module/Commercial/Api/`).
- `declare(strict_types=1);` partout ; commentaires **FR**, code EN.
- `make test` + `make php-cs-fixer-allow-risky` avant de dire « fini ».
- Ne jamais mentionner Claude/IA dans commit/PR.
@@ -0,0 +1,44 @@
# WT1 — Infra upload générique `Shared` (ticket 1.2 / ERP-154)
> Créer le worktree puis lancer Claude dedans :
> ```bash
> git fetch origin
> git worktree add ../sb-erp154-upload -b feat/erp-154-upload origin/develop
> cd ../sb-erp154-upload && claude
> ```
> **Base** : `origin/develop` (aucune dépendance — peut démarrer tout de suite, même avant le merge du socle Transport).
---
## Prompt à coller
Tu travailles sur le projet Starseed (modular monolith DDD, Symfony 8 / API Platform 4). Lis `CLAUDE.md` et `.claude/rules/backend.md` avant de coder. Charge le skill `backend-entity-conventions`.
**Mission** : poser une infra d'upload de fichiers **générique et réutilisable** dans `src/Shared/` (la « Décharge » du M4 en sera le 1er consommateur, mais ce ticket ne touche PAS au module Transport).
**Spec** : `docs/specs/M4-transporteurs/spec-back.md § 2.7`.
**À livrer** :
1. Table `uploaded_document` (migration namespace racine `DoctrineMigrations` dans `migrations/`, postérieure à la dernière présente — vérifie `ls migrations/`). Colonnes : `id`, `original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum`, `created_at`, `created_by`.
2. Service `Shared\Infrastructure\Upload\FileUploader` :
- validation MIME **server-side via `$file->getMimeType()`** (JAMAIS `getClientMimeType()`),
- whitelist MIME explicite (PDF + images),
- bornage taille, checksum sha256, écriture disque `var/uploads/{yyyy}/{mm}/`.
3. Endpoint `POST /api/uploaded_documents` (multipart) → renvoie l'IRI. MIME hors whitelist → **422**.
**Gardes-fous (cassent `make test` sinon)** :
- **`COMMENT ON COLUMN` sur TOUTES les colonnes** de `uploaded_document` (FR, ≤200 car., règle n°12) ET ajoute le bloc `'uploaded_document' => [...]` dans `src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php` — sinon `make test-db-setup` drope les COMMENT et `ColumnsHaveSqlCommentTest` casse.
- Pagination : si tu exposes une `GetCollection`, elle reste paginée (`CollectionsArePaginatedTest`).
**Scope STRICT** : uniquement `src/Shared/` + migration + catalog. Ne crée AUCUN fichier sous `src/Module/Transport/`. Pas d'antivirus/S3/purge (hors périmètre, § 9).
**Tests à écrire** (PHPUnit) : MIME hors whitelist → 422 ; MIME valide → IRI + ligne persistée + checksum calculé.
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky` propre. Commit (`--no-verify` OK si `make test` déjà vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-154-upload
tea pr create --base develop --head feat/erp-154-upload \
--title "feat(shared) : infra upload générique (ERP-154)" \
--description "Table uploaded_document + FileUploader + endpoint POST. Ticket ERP-154."
```
Puis labellise la PR via l'API Gitea (tea ne pose pas les labels en CLI). Cible **develop**. Aucune mention IA.
@@ -0,0 +1,37 @@
# WT10 — Tests PHPUnit + fixtures + contrat JSON (ticket 1.11 / ERP-163)
> ```bash
> git fetch origin
> git worktree add ../sb-erp163-tests -b feat/erp-163-carrier-tests origin/develop
> cd ../sb-erp163-tests && claude
> ```
> **Base** : `origin/develop` **après merge de TOUS les worktrees back** (WT1→WT9). C'est le filet final.
---
## Prompt à coller
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`, `.claude/rules/testing.md`. Charge le skill `backend-entity-conventions`. **Miroir** : `tests/Module/Commercial/Api/Supplier*Test.php`.
**Mission** : couverture complète des RG + capture du contrat de sérialisation + fixtures consolidées. C'est le DoD back avant intégration front.
**Spec** : `spec-back.md § 4.0.bis / 8.1 / 8.4`.
**À livrer** :
- Matrice **RG-4.01→4.14** couverte (§ 8.1) + RBAC par rôle (Compta/Usine → 403, Commerciale → 403 sur write, Admin → archive).
- `CarrierSerializationContractTest` : capture JSON réel **liste + détail** ; `prices[].client`/`.supplier`/sites **embarqués** (pas IRI) ; `qualimatCarrier` embarqué ; `isArchived` présent. Colle les JSON dans `spec-back.md § 4.0.bis`.
- Anti-N+1 liste ; pagination Hydra ; audit (`entity_type='Carrier'`) ; `AuditableEntitiesHaveI18nLabelTest` vert.
- **`CarrierFixtures` idempotent (§ 8.4)** — c'est ICI que les fixtures complètes vivent : transporteur QUALIMAT (validité passée → RG-4.04), AUTRE+décharge, affrété, LIOT, complet (contacts/adresses/prix CLIENT+FOURNISSEUR), 1 archivé.
**Piège CI (mémoire projet)** : la CI tourne `APP_DEBUG=0`. Les tests de **comptage de requêtes (anti-N+1)** passent en local mais cassent en CI (DoctrineDataHolder absent) → vérifie/active `profiling: true` dans la config Doctrine de l'environnement `test`. Sans ça le test anti-N+1 sera rouge en CI.
**Scope** : tests + `CarrierFixtures` + remplissage § 4.0.bis. Tu peux ajuster un test cassé hérité d'un autre WT mais signale-le à la conv maître (ne masque pas un vrai bug).
**Fini quand** : `make test` **intégralement vert** + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-163-carrier-tests
tea pr create --base develop --head feat/erp-163-carrier-tests \
--title "test(transport) : couverture RG-4.01→4.14 + contrat + fixtures (ERP-163)" \
--description "Matrice RG + CarrierSerializationContractTest + CarrierFixtures + § 4.0.bis. Ticket ERP-163."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,45 @@
# WT2 — Permissions `transport.carriers.*` + sidebar (ticket 1.1 / ERP-153)
> ```bash
> git fetch origin
> git worktree add ../sb-erp153-rbac -b feat/erp-153-rbac origin/develop
> cd ../sb-erp153-rbac && claude
> ```
> **Base** : `origin/develop` **après merge d'ERP-150** (le module `Transport` doit exister). Vérifie : `ls src/Module/Transport/`.
---
## Prompt à coller
Projet Starseed (modular monolith DDD). Lis `CLAUDE.md`, `.claude/rules/architecture.md` et `.claude/rules/testing.md` avant de coder.
**Mission** : poser le socle RBAC du module Transport et son entrée de menu. `TransportModule::permissions()` renvoie `[]` aujourd'hui.
**Spec** : `spec-back.md § 5` + `spec-front.md § Accès`.
**À livrer** :
1. `TransportModule::permissions()` déclare `transport.carriers.view`, `transport.carriers.manage`, `transport.carriers.archive`. `app:sync-permissions` les enregistre.
2. **Matrice § 5.2** : Admin (view+manage+archive), Bureau (view+manage), Commerciale (view), Compta + Usine (**aucune**).
3. **RÈGLE ABSOLUE n°8 — les 3 sources RBAC dans le MÊME commit** :
- `config/sidebar.php` : section « Transport » + item `/carriers` + `permission: transport.carriers.view`,
- `frontend/tests/e2e/_fixtures/personas.ts` : ajuster `permissions` + `expectedAdminLinks` des personas existants,
- `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` : miroir back des mêmes personas.
4. Item sidebar masqué pour Compta/Usine ; visible Admin/Bureau/Commerciale.
**Pièges** :
- Ne touche QUE le RBAC/sidebar — pas d'entité, pas de migration.
- Toute modif d'une seule des 3 sources sans les 2 autres = drift / test cassé.
- Section « Transport » vs « Logistique » : prends « Transport » (cosmétique, alignable plus tard).
**Tests à écrire/vérifier** : `app:sync-permissions` OK ; cohérence personas (pas de drift). Lance `make test`.
**Scope STRICT** : RBAC + sidebar + 3 miroirs. Rien d'autre.
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si test vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-153-rbac
tea pr create --base develop --head feat/erp-153-rbac \
--title "feat(transport) : permissions carriers + sidebar (ERP-153)" \
--description "RBAC transport.carriers.* + 3 sources RBAC alignées. Ticket ERP-153."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,54 @@
# WT3 ⭐ — Migration + entités Carrier* + ApiResource + Provider (tickets 1.3 + 1.5 / ERP-155 + ERP-157)
> **Worktree pivot : il livre le CONTRAT JSON qui débloque tout le front.**
> **Mode STACK, sans worktree** (repo principal) — base = branche de WT2 :
> ```bash
> cd /home/matthieu/dev_malio/Starseed && git fetch origin
> git checkout -b feat/erp-155-carrier-schema-entities origin/feat/erp-153-rbac
> ```
> **Base** : `feat/erp-153-rbac` (contient ERP-150 + WT1 + RBAC WT2). Quand #111 sera mergé dans develop, la PR de WT3 se recible automatiquement sur develop.
---
## Prompt à coller
Projet Starseed (modular monolith DDD, Symfony 8 / API Platform 4). Lis `CLAUDE.md`, `.claude/rules/backend.md`, `.claude/rules/architecture.md`. **Charge le skill `backend-entity-conventions`** (patterns entités/migrations complets).
**Mission** : créer le schéma BDD du répertoire transporteurs + les entités + le contrat de lecture (liste + détail). Tu poses le contrat JSON sur lequel le front s'appuiera — c'est le livrable critique.
**Spec** : `spec-back.md § 3.2 / 3.3 / 3.4 / 4.0 / 4.1 / 4.2`. **Miroir = le module Supplier** : `src/Module/Commercial/Domain/Entity/Supplier*.php`, `…/Infrastructure/ApiPlatform/State/Provider/SupplierProvider.php`, `…/Serializer/SupplierReadGroupContextBuilder.php`. Carrier vit dans `src/Module/Transport/`.
### Étape A — Migration (`migrations/`, namespace racine `DoctrineMigrations`)
- **PAS de migration modulaire** : même si la spec dit « modulaire », toute migration va dans `migrations/` namespace racine (tri FQCN cassant sinon). Postérieure à la dernière présente — vérifie `ls migrations/` (à ce jour `Version20260615120000`).
- Tables `carrier`, `carrier_address`, `carrier_contact`, `carrier_price` + FK : `qualimat_carrier`, `uploaded_document`, `client`, `client_address`, `supplier`, `supplier_address`, `site`, `user`.
- `certification_type` **nullable** (null en cas LIOT) + CHECK enum ; CHECK sur `container_type`, `direction`, `pricing_unit`, `price_state`, branches Prix client/fournisseur.
- Index partiel `uq_carrier_name_active` : `LOWER(name)` WHERE non archivé ET non supprimé.
- **`COMMENT ON COLUMN` sur TOUTES les colonnes** (FR, ≤200 car.) + helper standard pour les 4 colonnes Timestampable/Blamable. Bonus `COMMENT ON TABLE`.
### Étape B — Entités + repos
- `Carrier`, `CarrierAddress`, `CarrierContact`, `CarrierPrice` : `#[Auditable]`, `implements TimestampableInterface, BlamableInterface` + `use TimestampableBlamableTrait`. Repos `*RepositoryInterface` (Domain) + `Doctrine*Repository` (Infrastructure).
- `ApiResource` Carrier (attribut sur l'entité, comme Supplier) : `GetCollection` + `Get` + `Post` + `Patch` avec `security` (§ 3.3). **PAS de Delete**.
- Groupes : `carrier:read`, `carrier:item:read`, `qualimat:read`. **Embed au détail** (pas IRI) : `client:read`/`client_address:read`/`supplier:read`/`supplier_address:read`/`site:read` + `qualimatCarrier`. ⚠ les adresses de l'onglet Prix sont des `ClientAddress`/`SupplierAddress` distinctes.
- `CarrierProvider` paginé (`ApiPlatform\Doctrine\Orm\Paginator`), liste **sans cloisonnement site** (§ 2.3), **anti-N+1** (fetch joins, § 2.11), exclut les archivés par défaut + `?includeArchived=true`.
- Piège booléen : `#[SerializedName('isArchived')]` sur le getter.
### Gardes-fous qui CASSENT `make test` (à traiter dans CE worktree)
- `ColumnsHaveSqlCommentTest` → COMMENT partout **+ ajouter les blocs `carrier`, `carrier_address`, `carrier_contact`, `carrier_price` dans `src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php`** (sinon `test-db-setup` drope les COMMENT).
- `makefile test-db-setup` : l'index partiel `uq_carrier_name_active` n'est PAS exprimé par `schema:update`**ajoute-le à la ligne `dbal:run-sql` du target `test-db-setup`** du `makefile`, sinon `make test` casse.
- `AuditableEntitiesHaveI18nLabelTest` → ajoute dans `frontend/i18n/locales/fr.json` les clés `audit.entity.transport_carrier`, `transport_carrieraddress`, `transport_carriercontact`, `transport_carrierprice` (clé = strtolower(module)+'_'+strtolower(Entity)).
- `EntitiesAreTimestampableBlamableTest`, `EntityConstraintsHaveFrenchMessageTest` (messages FR + `Length.max` = longueur colonne), `CollectionsArePaginatedTest`.
**Scope STRICT** : schéma + entités + ApiResource lecture + Provider + i18n audit. **PAS** le Processor d'écriture (→ WT4), **PAS** les sous-ressources POST/PATCH adresses/contacts/prix (→ WT6/7/8), **PAS** l'export (→ WT9). Mets un `CarrierFixtures` **minimal** (1-2 lignes) juste pour faire tourner tes tests de lecture ; les fixtures complètes sont faites par WT10 — n'y investis pas.
**Tests à écrire** : liste exclut archivés / `?includeArchived=true` ; enveloppe Hydra (`member`/`totalItems`) ; `isArchived` présent dans le JSON ; embeds détail présents (pas IRI).
**LIVRABLE GATE** : une fois vert, **capture le JSON réel liste + détail** (`curl` ou test) et colle-le dans `spec-back.md § 4.0.bis`. C'est le signal pour démarrer le front. Préviens la conv maître.
**Fini quand** : `make db-reset` OK + `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si test vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-155-carrier-schema-entities
tea pr create --base feat/erp-153-rbac --head feat/erp-155-carrier-schema-entities \
--title "feat(transport) : schéma + entités Carrier + contrat lecture (ERP-155/157)" \
--description "Migration + entités Carrier* + ApiResource lecture + Provider + i18n audit + contrat JSON. Tickets ERP-155, ERP-157."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,41 @@
# WT4 — CarrierProcessor (ticket 1.6 / ERP-158)
> ```bash
> git fetch origin
> git worktree add ../sb-erp158-processor -b feat/erp-158-carrier-processor origin/develop
> cd ../sb-erp158-processor && claude
> ```
> **Base** : `origin/develop` **après merge de WT3** (entités Carrier) **et WT1** (upload, pour la décharge).
---
## Prompt à coller
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`.
**Mission** : logique d'écriture du formulaire principal Carrier (POST/PATCH) — normalisation, champs conditionnels, archivage. **Miroir** : `src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php` + `Application/Service/SupplierFieldNormalizer.php`.
**Spec** : `spec-back.md § 4.3 / 4.4 / 7`.
**Règles métier à implémenter (un test PHPUnit par RG)** :
- **RG-4.01** : POST avec `qualimatCarrier``certificationType=QUALIMAT` + FK persistée ; cas LIOT (`name='LIOT'`) ⇒ `certificationType` non requis, `liotPlates` accepté.
- **RG-4.02** : `certificationType='AUTRE'` sans `dischargeDocument`**422** (`#[Assert\Callback]`).
- **RG-4.03** : `isChartered=true` sans `indexationRate` / `containerType` / `volumeM3`**422**.
- **RG-4.13** : normalisation via `CarrierFieldNormalizer` (miroir Supplier) — `name` UPPER, contacts Capitalize, phones digits-only, email lower, `liotPlates` (`;`-split/trim/UPPER).
- **RG-4.12** : doublon `name` (parmi actifs) → **409** + `setError` ciblé.
- **RG-4.14** : PATCH `isArchived` exige `transport.carriers.archive` (Admin) ; mode strict → 403 sinon.
**Pièges** :
- Messages de validation **FR explicites** sur chaque contrainte (`EntityConstraintsHaveFrenchMessageTest`).
- Le back renvoie **toutes** les violations d'un coup avec `propertyPath` aligné sur les champs front.
**Scope STRICT** : `CarrierProcessor` + `CarrierFieldNormalizer` + contraintes sur l'entité `Carrier` (formulaire principal). **NE TOUCHE PAS** : les sous-ressources adresses/contacts/prix (WT6/7/8), `CarrierFixtures` (WT10), l'export (WT9). Ajoute tes contraintes sur `Carrier` sans réécrire l'ApiResource posée par WT3.
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-158-carrier-processor
tea pr create --base develop --head feat/erp-158-carrier-processor \
--title "feat(transport) : CarrierProcessor (RG-4.01→4.03/4.12→4.14) (ERP-158)" \
--description "Normalisation + champs conditionnels + archive. Ticket ERP-158."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,37 @@
# WT5 — Endpoint QualimatCarrier lecture seule (ticket 1.4 / ERP-156)
> ```bash
> git fetch origin
> git worktree add ../sb-erp156-qualimat -b feat/erp-156-qualimat-search origin/develop
> cd ../sb-erp156-qualimat && claude
> ```
> **Base** : `origin/develop` **après merge de WT2** (permission `transport.carriers.view`) **et ERP-39** (table `qualimat_carrier` peuplée). **Indépendant de WT3** — peut tourner en parallèle.
---
## Prompt à coller
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`.
**Mission** : exposer le référentiel QUALIMAT (table existante `qualimat_carrier`, alimentée par console) en **lecture seule** + endpoint de recherche pour la saisie assistée du nom (RG-4.01). **Ne touche pas** la commande de sync.
**Spec** : `spec-back.md § 4.7` + RG-4.01.
**À livrer** :
1. Entité `QualimatCarrier` (lecture seule) mappée sur la table existante `qualimat_carrier`. **Aucune écriture exposée** (pas de Post/Patch/Delete). Probablement pas `#[Auditable]` ni Timestampable (référentiel externe synchronisé) — vérifie le mapping existant.
2. `GET /api/qualimat_carriers?search=` : fuzzy sur `name` (+ `siret`), **seulement `is_active = true`**, tri `name`, **paginé** (règle n°13 — `CollectionsArePaginatedTest`).
3. **Security** `is_granted('transport.carriers.view')`.
4. Champs exposés : `id, siret, name, address, postalCode, city, phone, department, status, validityDate, isActive`.
**Tests à écrire** : recherche ne renvoie que les actifs ; pagination Hydra ; 403 sans permission ; tri `name`.
**Scope STRICT** : uniquement l'exposition lecture de `qualimat_carrier`. Ne crée rien autour de `Carrier` (autres worktrees). Si la table n'a pas de COMMENT (référentiel pré-existant), vérifie si elle est dans `EXCLUDED_TABLES` de `ColumnsHaveSqlCommentTest` — ne casse pas ce test.
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-156-qualimat-search
tea pr create --base develop --head feat/erp-156-qualimat-search \
--title "feat(transport) : endpoint recherche QualimatCarrier (ERP-156)" \
--description "Entité lecture seule + GET /api/qualimat_carriers?search=. Ticket ERP-156."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,37 @@
# WT6 — Sous-ressource Adresses (ticket 1.7 / ERP-159)
> ```bash
> git fetch origin
> git worktree add ../sb-erp159-adresses -b feat/erp-159-carrier-addresses origin/develop
> cd ../sb-erp159-adresses && claude
> ```
> **Base** : `origin/develop` **après merge de WT3** (entités `CarrierAddress`). Parallèle à WT5/WT7/WT8/WT9.
---
## Prompt à coller
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`. **Miroir** : `SupplierAddressProcessor.php` (`src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/`).
**Mission** : opérations d'écriture sur les adresses transporteur.
**Spec** : `spec-back.md § 4.5` + RG-4.05→4.07.
**À livrer** :
- `POST /api/carriers/{id}/addresses`, `PATCH`/`DELETE /api/carrier_addresses/{id}` (security `manage`) — **resource/processor dédiés à `CarrierAddress`**, ne modifie pas l'ApiResource `Carrier`.
- **RG-4.06** : `postalCode` matche `^[0-9]{4,5}$` (autocomplete ville = front). Message FR.
- **RG-4.05** : si affrété → adresse obligatoire (Pays/CP/Ville/Adresse) — validation conditionnelle.
- RG-4.07 (bouton Valider masqué si QUALIMAT) = front ; côté back, accepter le PATCH normalement.
**Tests à écrire** : CP invalide → 422 ; adresse affrété incomplète → 422 ; PATCH/DELETE OK avec `manage`, 403 sans.
**Scope STRICT** : uniquement `CarrierAddress` (resource + processor + tests). **NE TOUCHE PAS** `CarrierFixtures` (WT10), l'entité `Carrier`, les autres sous-ressources. Messages de validation FR (`EntityConstraintsHaveFrenchMessageTest`).
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-159-carrier-addresses
tea pr create --base develop --head feat/erp-159-carrier-addresses \
--title "feat(transport) : sous-ressource adresses transporteur (ERP-159)" \
--description "POST/PATCH/DELETE carrier_address + RG-4.05→4.07. Ticket ERP-159."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,35 @@
# WT7 — Sous-ressource Contacts (ticket 1.8 / ERP-160)
> ```bash
> git fetch origin
> git worktree add ../sb-erp160-contacts -b feat/erp-160-carrier-contacts origin/develop
> cd ../sb-erp160-contacts && claude
> ```
> **Base** : `origin/develop` **après merge de WT3**. Parallèle à WT5/WT6/WT8/WT9.
---
## Prompt à coller
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`. **Miroir** : `SupplierContactProcessor.php` (`src/Module/Commercial/…/State/Processor/`).
**Mission** : opérations d'écriture sur les contacts transporteur.
**Spec** : `spec-back.md § 4.5` + RG-4.08.
**À livrer** :
- `POST /api/carriers/{id}/contacts`, `PATCH`/`DELETE /api/carrier_contacts/{id}` (security `manage`) — resource/processor dédiés à `CarrierContact`.
- **RG-4.08** : bloc valide si **≥ 1 champ rempli** (CHECK `chk_carrier_contact_filled` côté migration WT3 + validation Processor) ; **max 2 téléphones**.
**Tests à écrire** : contact vide → 422 ; 1 champ → 200/201 ; 3ᵉ téléphone → 422.
**Scope STRICT** : uniquement `CarrierContact`. **NE TOUCHE PAS** `CarrierFixtures` (WT10), `Carrier`, les autres sous-ressources. Messages FR. Si le CHECK `chk_carrier_contact_filled` manque (WT3 ne l'a pas posé), valide côté Processor et signale-le à la conv maître.
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-160-carrier-contacts
tea pr create --base develop --head feat/erp-160-carrier-contacts \
--title "feat(transport) : sous-ressource contacts transporteur (ERP-160)" \
--description "POST/PATCH/DELETE carrier_contact + RG-4.08 (≥1 champ, max 2 tel). Ticket ERP-160."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,39 @@
# WT8 — Sous-ressource Prix + RG branches (ticket 1.9 / ERP-161)
> ```bash
> git fetch origin
> git worktree add ../sb-erp161-prix -b feat/erp-161-carrier-prices origin/develop
> cd ../sb-erp161-prix && claude
> ```
> **Base** : `origin/develop` **après merge de WT3**. Parallèle à WT5/WT6/WT7/WT9.
---
## Prompt à coller
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`.
**Mission** : opérations d'écriture sur les prix transporteur, avec branches Client / Fournisseur.
**Spec** : `spec-back.md § 4.5 / 7` + RG-4.09→4.11.
**À livrer** :
- `POST /api/carriers/{id}/prices`, `PATCH`/`DELETE /api/carrier_prices/{id}` (security `manage`) — resource/processor dédiés à `CarrierPrice`.
- **RG-4.10 (CLIENT)** : `client`, `clientDeliveryAddress`, `departureSite` requis ; `clientDeliveryAddress` **doit appartenir au `client`** → sinon 422.
- **RG-4.11 (FOURNISSEUR)** : `supplier`, `supplierSupplyAddress`, `deliverySite` requis ; `supplierSupplyAddress` appartient au `supplier` → sinon 422.
- Communs obligatoires : `containerType`, `pricingUnit`, `price`, `priceState`. CHECK branches respectés.
**Rappels FK** : « Adresse départ/livraison 86/17/82 » = `Site` (FK). Livraison client = `ClientAddress`, appro = `SupplierAddress` (relations ORM partagées — pas de M2M).
**Tests à écrire** : branche CLIENT/FOURNISSEUR incomplète → 422 ; adresse étrangère au client/supplier → 422 ; prix valide → 201.
**Scope STRICT** : uniquement `CarrierPrice`. **NE TOUCHE PAS** `CarrierFixtures` (WT10), `Carrier`, les autres sous-ressources. Messages FR.
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-161-carrier-prices
tea pr create --base develop --head feat/erp-161-carrier-prices \
--title "feat(transport) : sous-ressource prix transporteur (ERP-161)" \
--description "POST/PATCH/DELETE carrier_price + RG-4.09→4.11 (branches client/fournisseur). Ticket ERP-161."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
@@ -0,0 +1,36 @@
# WT9 — Export XLSX (ticket 1.10 / ERP-162)
> ```bash
> git fetch origin
> git worktree add ../sb-erp162-export -b feat/erp-162-carrier-export origin/develop
> cd ../sb-erp162-export && claude
> ```
> **Base** : `origin/develop` **après merge de WT3** (lecture Carrier). Parallèle à WT5/WT6/WT7/WT8.
---
## Prompt à coller
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. **Miroir** : `src/Module/Commercial/Infrastructure/Controller/SupplierExportController.php` (PhpSpreadsheet déjà présent).
**Mission** : export Excel du répertoire et du tableau Prix regroupé.
**Spec** : `spec-back.md § 4.6`.
**À livrer** :
- `GET /api/carriers/export.xlsx` : transporteurs affichés (**mêmes filtres** que la liste) ; colonnes § 4.6.
- `GET /api/carriers/{id}/prices/export.xlsx` : tableau Prix regroupé Benne / Fond Mouvant (colonnes docx p.10).
- **Controllers custom** avec `#[Route(priority: 1)]` (sinon conflit API Platform `{id}`) ; en-tête `Content-Disposition`.
**Tests à écrire** : 200 + en-tête fichier (Content-Disposition + type XLSX) ; respect des filtres.
**Scope STRICT** : controllers d'export + service de génération. **NE TOUCHE PAS** entités, processors, `CarrierFixtures` (WT10). Réutilise le Provider/filtres de WT3 pour la cohérence des données exportées.
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
```bash
git push -u origin feat/erp-162-carrier-export
tea pr create --base develop --head feat/erp-162-carrier-export \
--title "feat(transport) : export XLSX répertoire + prix (ERP-162)" \
--description "GET /api/carriers/export.xlsx + /carriers/{id}/prices/export.xlsx. Ticket ERP-162."
```
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
File diff suppressed because it is too large Load Diff
+354
View File
@@ -0,0 +1,354 @@
---
# === IDENTITÉ ===
module: M4
nom: "Répertoire transporteurs"
ecran: repertoire-transporteurs
owner_spec: Matthieu
backup_spec: Tristan
version: V0.1
date_redaction: 2026-06-15
# Historique :
# V0.1 (2026-06-15) — Restitution Markdown du docx « M4-repertoire-transporteurs-V0 »
# (validé 27/05/2026) + maquette Figma (node 1132-45376). Précisions techniques (back)
# dans spec-back.md. Réutilise le pattern et les composants M1/M2/M3.
# === LIENS ===
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-45376&p=f&m=dev"
regles_metier: [RG-4.01, RG-4.02, RG-4.03, RG-4.04, RG-4.05, RG-4.06, RG-4.07, RG-4.08, RG-4.09, RG-4.10, RG-4.11]
roles: [Admin, Bureau, Compta, Commerciale, Usine]
lien_spec_back: ./spec-back.md
# === VALIDATION CLIENT ===
client_validation_1:
statut: validee
date: 2026-05-27
version: V0
valide_par: "Matthieu (CP MALIO)"
# === LIEN LESSTIME ===
lesstime_project_id: 6
lesstime_taskgroup_id: 31 # M4 — Répertoire transporteurs (tickets ERP-153 → ERP-171)
statut_global: pret_a_dev
---
# Module 4 — Répertoire transporteurs (V0.1 front)
> **Origine** : spec fonctionnelle `M4-repertoire-transporteurs-V0` (validée le 27/05/2026) + maquette Figma. Restitution Markdown pour intégration au workflow MALIO. Toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M4 réutilise le pattern et les composants posés aux [M1 clients](../M1-clients/spec-front.md), [M2 fournisseurs](../M2-suppliers/spec-front.md) et [M3 prestataires](../M3-prestataires/spec-front.md).
> **Socle déjà en place** : le module back `Transport` existe (ERP-150) et porte deux référentiels **synchronisés par commandes console** : transporteurs **QUALIMAT** (`qualimat_carrier`, ERP-39) et codes **IDTF** (`idtf_product`, ERP-149). Le M4 ajoute le **répertoire éditable** (`Carrier`) **par-dessus** ces référentiels — la saisie assistée du nom interroge le référentiel QUALIMAT (RG-4.01). L'IDTF n'est **pas** utilisé par ces écrans.
> **Décisions Matthieu (15/06/2026)** : (1) lien QUALIMAT = FK + **copie éditable** des champs (nom / certification / adresse) ; (2) **pas de cloisonnement par site** (référentiel global) ; (3) le champ « Décharge » s'appuie sur une **infra d'upload réutilisable** (`Shared`), car d'autres uploads suivront. Détails : [`spec-back.md § 2.5 / § 2.3 / § 2.7`](./spec-back.md).
## But
Lister tous les transporteurs de l'organisation et accéder rapidement à leurs fiches : consultation, création, modification, archivage. Le nom est **relié à QUALIMAT** (saisie assistée) ; les transporteurs hors QUALIMAT (GMP+, OVOCOM, compte-propre, LIOT, autre) sont saisis manuellement.
## Accès
- **Depuis** : menu principal → section **Transport** (route `/carriers`). *(Section « Transport » dédiée ou rattachement à une section « Logistique » — à confirmer, cf. [`spec-back.md § 5.3`](./spec-back.md).)*
- **Rôles autorisés** (tableau « Rôles & permissions » du docx) :
| Rôle | Consultation | Ajout / Modification | Archive |
|---|---|---|---|
| **Admin** | ✅ Tout | ✅ Tout | ✅ |
| **Bureau** | ✅ Tout | ✅ Tout | ❌ |
| **Compta** | ❌ | ❌ | ❌ |
| **Commerciale** | ✅ Tout | ❌ | ❌ |
| **Usine** | ❌ | ❌ | ❌ |
> **Notes** :
> - RBAC transposée sur `transport.carriers.*` (cf. [`spec-back.md § 5`](./spec-back.md)). **Commerciale** = consultation seule (pas de « + Ajouter » ni « Modifier »). **Compta** et **Usine** n'ont **aucun** accès au module (item sidebar masqué).
> - **Pas de cloisonnement par site** (≠ M3) : tout rôle autorisé voit tous les transporteurs.
## Navigation
Page d'entrée du module **Transport** (route `/carriers`). Titre : « **Répertoire transporteurs** ».
- Affichage principal : un **datatable** listant tous les transporteurs **actifs** (les archivés sont masqués par défaut — filtre dédié).
- **Clic sur une ligne** → écran **Consultation transporteur** (page dédiée).
- **Bouton « + Ajouter »** (haut droite, si `manage`) → écran **Ajouter un transporteur**.
- **Bouton « Filtrer »** (haut droite) → panneau de filtres.
- **Bouton « Exporter »** (haut droite) → télécharge un **XLSX** des transporteurs **affichés** (cf. filtres actifs). Format dans [`spec-back.md § 4.6`](./spec-back.md).
### Panneau de filtres (bouton « Filtrer »)
Réutilise le pattern M1/M2/M3. Filtres branchés sur les query params de `GET /api/carriers` (cf. [`spec-back.md § 4.1`](./spec-back.md)) :
| Filtre | Composant | Query param back |
|---|---|---|
| **Recherche** (nom) | `<MalioInputText>` | `?search=` |
| **Certification** | `<MalioSelectCheckbox>` (QUALIMAT / GMP+ / OVOCOM / Compte-propre / Autre) | `?certificationType=` |
| **Inclure les archivés** | `<MalioCheckbox>` | `?includeArchived=true` |
- À l'application des filtres → `setFilters(...)` de `usePaginatedList` (retombe en **page 1**).
- **État 100 % local** (jamais dans l'URL — règle ABSOLUE n°6).
## Datatable du Répertoire
Composant : `<MalioDataTable>` branché sur `usePaginatedList<Carrier>({ url: '/carriers' })` (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes :
| Colonne | Source | Tri |
|---|---|---|
| **Nom** | `carrier.name` | ASC par défaut |
| **Certification** | `carrier.certificationType` (libellé i18n) | Non |
| **Date de validité** | `carrier.qualimatCarrier.validityDate` (format `JJ-MM-AAAA`) — **fond rouge si < aujourd'hui** (RG-4.04) | Non |
| **Dernière activité** | `carrier.updatedAt` (format `JJ-MM-AAAA`) | Oui |
> **Clic sur une ligne** → écran Consultation. **Pagination** : standard Starseed 10 / 25 / 50 (défaut 10). Tri serveur `name ASC` par défaut.
## Écran « Ajouter un transporteur »
Création par **onglets successifs avec validation incrémentale** : pour passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant** ; les champs validés passent en lecture seule. **L'onglet Adresses n'est accessible qu'une fois le formulaire principal validé.** Cf. [`spec-back.md § 2.9`](./spec-back.md) (PATCH partiels par groupe de sérialisation).
**Accès** : bouton « + Ajouter » du Répertoire. **Rôles** : Admin, Bureau.
**Barre d'onglets** : `Qualimat` · `Adresses` · `Contacts` · `Prix`.
### Formulaire principal (pré-onglets)
1er bloc à remplir. Sans validation, les onglets ne sont pas accessibles. Une fois validé → POST `/api/carriers`, puis bascule sur l'onglet Qualimat/Adresses ; les champs passent en readonly.
| Champ | Type composant | Obligatoire | Règle |
|---|---|---|---|
| **Nom** (saisie assistée reliée à QUALIMAT) | `<MalioInputText>` (autocomplete) | Oui | RG-4.01 ; RG-4.13 (UPPERCASE serveur) ; RG-4.12 (unicité) |
| **Liste certification transport** | `<MalioSelect>` (GMP+ / OVOCOM / Compte-propre / Autre) | Oui | RG-4.02 ; auto = `QUALIMAT` (lecture seule) si transporteur QUALIMAT sélectionné |
| **Affréter** | `<MalioCheckbox>` | Non | RG-4.03 |
| **Indexation %** | `<MalioInputNumber>` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché |
| **Benne / Fond mouvant** | `<MalioRadioButton>` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché |
| **Volume m³** | `<MalioInputNumber>` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché |
| **Décharge** | `<MalioInputUpload>` *(cf. note)* | Conditionnel (**obligatoire si AUTRE**) | RG-4.02 — visible **et obligatoire** si certification = `AUTRE`. Upload via infra Shared ([`spec-back.md § 2.7`](./spec-back.md)) |
| **Liste immatriculation LIOT** | `<MalioInputText>` (ou TextArea) | Cas LIOT | RG-4.01 — visible **uniquement** si nom = `LIOT` ; les autres champs disparaissent. Immatriculations séparées par `;` |
> **Comportement RG-4.01 (saisie assistée)** : à la saisie du nom, recherche dans le référentiel QUALIMAT via `GET /api/qualimat_carriers?search=`. Sélection d'un résultat → **modal de confirmation** « Êtes-vous sûr de vouloir intégrer ce transporteur ? ». Si confirmé : le **Nom** et la **certification** (= `QUALIMAT`, lecture seule) se remplissent automatiquement, **ainsi que l'onglet Adresse** (copie pays/CP/ville/voie depuis le référentiel). La FK QUALIMAT est conservée (traçabilité + date de validité RG-4.04).
> - **Cas transporteur non trouvé** (pas QUALIMAT) : l'utilisateur choisit une autre certification (RG-4.02) → affichage des champs associés.
> - **Cas LIOT** : si le nom saisi est exactement `LIOT`, seul le champ « Liste immatriculation LIOT » s'affiche, les autres champs sont masqués.
> **Note `<MalioInputUpload>`** : si le composant ne couvre pas le drag & drop / type fichier requis, exception autorisée documentée (`// TODO migrer quand Malio couvre`) — cf. exceptions @.claude/rules/frontend.md.
**Action** : « Valider » (`<MalioButton>`) → POST `/api/carriers` ([`spec-back.md § 4.3`](./spec-back.md)). Succès → onglet « Qualimat » / « Adresses ».
### Onglet « Qualimat »
Sélectionner un transporteur de la liste QUALIMAT afin de mettre à jour les informations du transporteur (saisie assistée — voir RG-4.01).
**Colonnes du tableau de sélection** :
| Colonne | Règle |
|---|---|
| **Sélection** (bouton / clic ligne) | RG-4.03 *(docx)* — clic → modal « Êtes-vous sûr de vouloir intégrer ce transporteur ? » → remplit Nom + certification + onglet adresse |
| **Nom** | — |
| **Adresse** | — |
| **Date de validité** | RG-4.04 — **fond rouge si < date du jour** |
> Cet onglet alimente le formulaire principal et l'onglet Adresse par copie (RG-4.01 / RG-4.05). Source : `GET /api/qualimat_carriers?search=` (lecture seule, lignes actives uniquement).
### Onglet « Adresses »
Saisir l'adresse du transporteur (un bloc par adresse).
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Pays** | `<MalioSelect>` (préremplie « France ») | Conditionnel | RG-4.05 |
| **Code postal** | `<MalioInputText>` (saisie assistée) | Conditionnel | RG-4.06, RG-4.05 — déclenche autocomplete ville (BAN) |
| **Ville** | `<MalioSelect>` (saisie assistée) | Conditionnel | RG-4.06, RG-4.05 — alimentée par api-adresse.data.gouv.fr |
| **Adresse** | `<MalioInputText>` (saisie assistée) | Conditionnel | RG-4.05 |
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
> **RG-4.05** : les champs sont **déjà remplis** si le transporteur est QUALIMAT (copie). Si « Affréter » est coché, l'adresse devient **obligatoire** (Pays, Code postal, Ville, Adresse).
> **RG-4.06** : la ville est préremplie automatiquement à partir du code postal via l'API BAN (`useAddressAutocomplete()`, réutilisé M1/M2/M3). Si plusieurs villes → choix dans le select. L'adresse est une saisie assistée basée sur le CP et la ville.
> **RG-4.07** : le bouton « Valider » **n'apparaît pas** pour un transporteur QUALIMAT (adresse remplie automatiquement).
**Actions** : « Valider » → PATCH `/api/carriers/{id}/addresses` (sauf QUALIMAT, RG-4.07).
### Onglet « Contacts »
Saisir un ou plusieurs contacts associés au transporteur.
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Nom** | `<MalioInputText>` | Non | RG-4.08 + RG-4.13 (Capitalize) |
| **Prénom** | `<MalioInputText>` | Non | RG-4.08 + RG-4.13 (Capitalize) |
| **Fonction** | `<MalioInputText>` | Non | RG-4.08 |
| **Téléphone** (x1, +1 possible, **max 2**) | `<MalioInputText>` | Non | RG-4.08 + RG-4.13 (format) |
| **Email** | `<MalioInputText>` type email | Non | RG-4.08 + RG-4.13 (lowercase) |
**RG-4.08** : un bloc Contact est valide dès qu'au moins 1 champ est rempli. Impossible d'ajouter un nouveau bloc tant que le précédent n'est pas valide.
**Actions** :
- « + Nouveau contact » : ajoute un bloc. **Désactivé tant que le bloc précédent n'a aucun champ rempli** (RG-4.08).
- « Supprimer » (icône) : modal de confirmation, puis suppression du bloc.
- « Valider » → PATCH `/api/carriers/{id}/contacts`.
### Onglet « Prix »
Saisir un suivi de prix du transporteur (un bloc par prix). Tous les champs sont masqués par défaut sauf le radio « Client / Fournisseur » (RG-4.09).
**Bloc Prix** :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Client / Fournisseur** | `<MalioRadioButton>` | Oui | RG-4.09 |
| **Client** | `<MalioSelect>` (liste des clients) | Conditionnel | RG-4.10 — si Client |
| **Adresse de livraison** | `<MalioSelect>` (adresses du client sélectionné) | Conditionnel | RG-4.10 — si Client |
| **Adresse de départ** | `<MalioSelect>` (86 / 17 / 82) | Conditionnel | RG-4.10 — si Client ; = un des 3 sites |
| **Fournisseur** | `<MalioSelect>` (liste des fournisseurs) | Conditionnel | RG-4.11 — si Fournisseur |
| **Adresse d'approvisionnement** | `<MalioSelect>` (adresses du fournisseur) | Conditionnel | RG-4.11 — si Fournisseur |
| **Adresse de livraison** | `<MalioSelect>` (86 / 17 / 82) | Conditionnel | RG-4.11 — si Fournisseur ; = un des 3 sites |
| **Benne / Fond mouvant (FM)** | `<MalioRadioButton>` | Oui | — |
| **Forfait / Tonne** | `<MalioRadioButton>` | Oui | — |
| **Prix** | `<MalioInputAmount>` (monnaie) | Oui | — |
| **État du prix** | `<MalioSelect>` (En cours / Validé / Non validé) | Oui | — |
> **RG-4.10** : si **Client** sélectionné → champs liés au client affichés et obligatoires ; champs fournisseur masqués et non obligatoires.
> **RG-4.11** : si **Fournisseur** sélectionné → champs liés au fournisseur affichés et obligatoires ; champs client masqués et non obligatoires.
> **Adresse de départ / livraison « 86 / 17 / 82 »** = les 3 `Site` fixes (cf. switcher de site Châtellerault / Saint-Jean / Pommevic en haut de l'app). La sélection stocke un **ID de Site** ([`spec-back.md § 3.2`](./spec-back.md)).
**Actions** :
- « + Nouveau prix » : ajoute un bloc. Bloqué tant que le précédent n'est pas valide.
- « Supprimer » (icône) : modal de confirmation puis suppression.
- « Valider » → PATCH `/api/carriers/{id}/prices`.
## Écran « Consultation d'un transporteur »
Consulter en **lecture seule** la fiche complète. Affiche en haut du bloc les infos principales du transporteur (comme l'écran d'ajout) ainsi que les onglets Adresses, Contacts, Prix. **Tous les champs sont en lecture seule.**
**Accès** : clic sur une ligne du Répertoire. La page s'ouvre par défaut sur l'onglet **Adresses**. Icône « flèche » à gauche pour revenir au répertoire. Deux boutons à droite :
- **« Modifier »** (visible si `transport.carriers.manage` → Admin, Bureau).
- **« Archiver »** (visible **uniquement Admin** via `transport.carriers.archive`) → modal de confirmation, puis PATCH `/api/carriers/{id}` `{ "isArchived": true }`.
> Un transporteur archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé.
### Onglet Adresses (consultation)
Un bloc par adresse du transporteur. Chaque bloc, 5 champs en lecture seule : Pays / Code postal / Ville / Adresse / Adresse complémentaire.
### Onglet Contacts (consultation)
Un bloc par contact. 5 champs en lecture seule : Nom / Prénom / Fonction / Téléphone (x1 ou x2) / Email.
### Onglet Prix (consultation)
Un tableau regroupant les prix par type (**Fond Mouvant / Benne**) :
| Colonne | Description |
|---|---|
| **Colonne de regroupement** | « Fond Mouvant » / « Benne » |
| **Transporteurs** | Nom du transporteur |
| **Adresse APRO ou Adresse Sites** | Si prix « Client » → Adresse APRO sinon Adresse Sites |
| **Adresse livraisons** | — |
| **Forfait €** | Prix |
| **Tonne €** | Prix |
| **Indexation** | Pourcentage d'indexation (vide si non rempli) |
| **État du prix** | Validé / Non Validé / En cours |
**Action** : « Exporter » → exporte le tableau au **format Excel** (`GET /api/carriers/{id}/prices/export.xlsx`).
## Écran « Modification d'un transporteur »
Modifier les informations d'un transporteur existant. **Identique à l'écran « Ajouter un transporteur »** — mêmes formulaires, mêmes règles métier (RG-4.01 à RG-4.11) — sauf :
- Les champs sont **pré-remplis** avec les valeurs actuelles.
- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel).
- **Accès** : depuis l'écran Consultation, bouton « Modifier » (Admin, Bureau).
## Composants UI à utiliser (`@malio/layer-ui`)
- **Datatable** : `<MalioDataTable>` (+ `usePaginatedList`)
- **Input texte** : `<MalioInputText>`
- **Input nombre / montant** : `<MalioInputNumber>` (indexation, volume), `<MalioInputAmount>` (prix)
- **Select simple** : `<MalioSelect>` (certification, pays, ville, client, fournisseur, adresses, sites, état du prix)
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (filtres certification)
- **Radio** : `<MalioRadioButton>` (Benne/Fond mouvant, Forfait/Tonne, Client/Fournisseur)
- **Checkbox** : `<MalioCheckbox>` (Affréter, inclure archivés)
- **Upload** : `<MalioInputUpload>` (Décharge — exception documentée si type non couvert)
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
- **Toasts** : standards via `useApi()`
- **Validation par champ** : `useFormErrors` (mapping 422 inline — règle frontend obligatoire)
**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) :
- Modal de confirmation : wrapper partagé dans `frontend/shared/` (réutiliser celui du M1/M2/M3).
- `<MalioInputUpload>` si le type fichier / drag & drop n'est pas couvert.
## Composables & appels API
- `usePaginatedList<Carrier>({ url: '/carriers' })` — liste paginée (obligatoire). Consomme `name`, `certificationType`, `qualimatCarrier.validityDate` (RG-4.04), `updatedAt` (cf. [`spec-back.md § 2.11 / § 4.0`](./spec-back.md)).
- `useCarrier(id)` — charge le détail via `GET /api/carriers/{id}`, qui **embarque** `addresses`, `contacts`, `prices` (avec `client`/`supplier`/sites imbriqués) + `qualimatCarrier`. Écrans Consultation et Modification peuplés depuis cette seule réponse. **DoD avant intégration** : vérifier le JSON réel (cf. [`spec-back.md § 4.0.bis`](./spec-back.md)).
- `useCarrierForm()` — workflow par onglet (POST principal + PATCH partiels par groupe), miroir de `useSupplierForm()`/`useProviderForm()` + gestion des **champs conditionnels** (Affréter, AUTRE→Décharge, cas LIOT).
- `useQualimatSearch()` — saisie assistée du nom : `GET /api/qualimat_carriers?search=`, modal de confirmation, copie des champs + FK (RG-4.01).
- `useAddressAutocomplete()`**réutilisé** du M1/M2/M3 (BAN), pas de réécriture (RG-4.06).
- `useUpload()` (NOUVEAU, infra Shared) — POST multipart `/api/uploaded_documents` → renvoie l'IRI à poser sur `carrier.dischargeDocument` (RG-4.02).
- `usePermissions()` — masque l'item sidebar et les boutons selon les permissions.
- Tous les appels passent par `useApi()` (jamais `$fetch` direct — règle ABSOLUE n°4).
- Filter `formatPhoneFR()`**réutilisé** pour l'affichage `XX XX XX XX XX`.
## Règles de formatage et normalisation
Le serveur normalise systématiquement (RG-4.13 — cf. [`spec-back.md`](./spec-back.md)) :
| Champ | Normalisation serveur | Affichage front |
|---|---|---|
| Nom transporteur (`name`) | UPPERCASE intégral | UPPERCASE |
| Nom + Prénom contact | Capitalize | identique |
| Téléphones (`CarrierContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` (filter Vue) |
| Email | lowercase intégral | identique |
| Immatriculations LIOT | `;`-split, trim, UPPER | listées |
> Le front **ne normalise pas** : il envoie la valeur saisie, le serveur normalise et renvoie la valeur normalisée que l'UI affiche.
## API adresse postale
Code postal + Ville + Adresse branchés sur **api-adresse.data.gouv.fr** (BAN) via le composable `useAddressAutocomplete()` **déjà créé au M1/M2/M3** (réutilisé tel quel) :
- À la saisie du CP (5 chiffres) : `GET https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville (RG-4.06 : si plusieurs villes, choix dans le select).
- À la saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions.
- Cas dégradé (timeout / offline) : Ville en `<MalioInputText>` libre + toast d'avertissement.
## Différences notables avec les modules précédents
| Zone | M2/M3 | M4 transporteurs |
|---|---|---|
| Source du nom | saisie libre | **saisie assistée reliée à QUALIMAT** (référentiel synchronisé) |
| Onglet Comptabilité / RIB | présent (M2/M3) | **Absent** |
| Cloisonnement par site | M3 : oui | **Non** (référentiel global) |
| Champs conditionnels formulaire principal | peu | **Nombreux** (Affréter, AUTRE→Décharge, cas LIOT) |
| Onglet Prix | absent | **Présent** (Client/Fournisseur, sites départ/livraison) |
| Upload de fichier | aucun | **Décharge** (infra upload Shared, réutilisable) |
| Module | Commercial / Technique | **Transport** (existant, ERP-150) |
## Points résolus côté back
| # | Zone d'ombre | Résolution (cf. `spec-back.md`) |
|---|---|---|
| 1 | Lien QUALIMAT | FK `qualimatCarrier` + **copie éditable** des champs (§ 2.5) |
| 2 | Cas LIOT | Champ `liotPlates` (`;`-séparé), autres champs masqués (RG-4.01) |
| 3 | Certification QUALIMAT | Valeur `QUALIMAT` lecture seule si lié (§ 2.5) |
| 4 | Décharge (upload) | Infra upload générique `Shared` réutilisable (§ 2.7) |
| 5 | Onglet Prix — branches | M2M absentes : FK Client/Supplier + adresses + sites (RG-4.10/4.11, § 3.2) |
| 6 | Adresse de départ/livraison 86/17/82 | = les 3 `Site` fixes (FK Site) |
| 7 | Workflow par onglet | Sauvegarde incrémentale (POST principal + PATCH partiels) — pas d'état « draft » |
| 8 | Archive vs delete | Flag `is_archived` séparé ; archivage Admin seul ; soft delete = HP |
| 9 | Unicité métier | Nom seul (§ 2.6) |
| 10 | Référentiel QUALIMAT | Endpoint lecture seule `GET /api/qualimat_carriers?search=` (§ 4.7) |
| 11 | Format export | XLSX (répertoire + onglet Prix regroupé Benne/FM) |
| 12 | RBAC | `transport.carriers.view/manage/archive` ; Compta + Usine sans accès (§ 5.2) |
---
## 📦 Tickets Lesstime
**TaskGroup Lesstime** : à créer — `M4 — Répertoire transporteurs` (projet `ERP / Starseed`, projectId=6). Découpe détaillée (back en tête) → [`spec-back.md § Tickets Lesstime`](./spec-back.md#-tickets-lesstime-à-découper).
| Ordre | Sujet | Tag |
|---|---|---|
| 0 | Permissions `transport.carriers.*` + sidebar + 3 sources RBAC | Backend |
| 1 | Infra upload générique `Shared` (uploaded_document + FileUploader + endpoint) | Backend |
| 2 | Migration BDD M4 (carrier + sous-collections + index + COMMENT) | Backend |
| 3 | Entité `QualimatCarrier` (lecture seule) + endpoint recherche | Backend |
| 4 | Entités + Repositories Carrier* | Backend |
| 5 | CarrierProvider + CarrierProcessor (champs conditionnels, archive, LIOT) | Backend |
| 6 | Sous-ressources Adresses / Contacts / Prix (RG-4.10/4.11) | Backend |
| 7 | Export XLSX (répertoire + onglet Prix) | Backend |
| 8 | Tests PHPUnit RG-4.01→4.14 + capture contrat JSON | Backend |
| 9 | Page Répertoire (`/carriers`) + usePaginatedList | Frontend |
| 10 | Page Ajouter + formulaire principal + saisie assistée QUALIMAT | Frontend |
| 11 | Onglets Adresses (BAN) / Contacts / Prix | Frontend |
| 12 | Pages Consultation + Modification | Frontend |
| 13 | i18n + libellés audit + upload front (useUpload) | Frontend |
@@ -0,0 +1,307 @@
# M4 — Répertoire transporteurs · Découpe en tickets Lesstime
> **Statut** : ✅ **poussé dans Lesstime** — TaskGroup **#31 « M4 — Répertoire transporteurs »** (projet STARSEED), 19 tickets **ERP-153 → ERP-171** au statut **Prêt à dev**.
> **Assignation** : tickets **Backend (1.1→1.11, ERP-153→163) → Matthieu** · tickets **Frontend (1.12→1.19, ERP-164→171) → Tristan**.
>
> | Pos | Ticket | Réf |
> |---|---|---|
> | 1.1 | Permissions transport.carriers.* + sidebar | ERP-153 |
> | 1.2 | Infra upload générique Shared | ERP-154 |
> | 1.3 | Migration BDD M4 | ERP-155 |
> | 1.4 | QualimatCarrier + endpoint recherche | ERP-156 |
> | 1.5 | Entités Carrier* + ApiResource + Provider | ERP-157 |
> | 1.6 | CarrierProcessor (RG-4.01/02/03 + LIOT) | ERP-158 |
> | 1.7 | Sous-ressource Adresses | ERP-159 |
> | 1.8 | Sous-ressource Contacts | ERP-160 |
> | 1.9 | Sous-ressource Prix + branches | ERP-161 |
> | 1.10 | Export XLSX | ERP-162 |
> | 1.11 | Tests PHPUnit + contrat JSON | ERP-163 |
> | 1.12 | Page Répertoire /carriers | ERP-164 |
> | 1.13 | Page Ajouter (layout + formulaire) | ERP-165 |
> | 1.14 | Saisie assistée QUALIMAT + conditionnels | ERP-166 |
> | 1.15 | Onglet Adresses (BAN) | ERP-167 |
> | 1.16 | Onglet Contacts | ERP-168 |
> | 1.17 | Onglet Prix | ERP-169 |
> | 1.18 | Consultation + Modification | ERP-170 |
> | 1.19 | Upload front + i18n + audit | ERP-171 |
> **Specs sources** : [`spec-back.md`](./spec-back.md) · [`spec-front.md`](./spec-front.md) — validées (docx V0 du 27/05/2026).
> **Maquette Figma** : node `1132-45376` ([lien](https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-45376&p=f&m=dev)).
## ⚠️ Dépendance amont (socle Tristan — en cours de merge)
Le M4 s'appuie sur le module `Transport` et le référentiel QUALIMAT, livrés par les PR de Tristan **en cours de merge** dans `develop` :
- **ERP-150** (PR #97) — module `Transport` (`TransportModule`, layer front, `config/modules.php`). **Requis** par tout le M4.
- **ERP-39** (PR #99) — sync QUALIMAT (`qualimat_carrier` + commande `app:qualimat:sync`). **Requis** par la saisie assistée (ticket 1.4).
- **ERP-149** (PR #101) — sync IDTF (`idtf_product`). **NON requis** par le M4 (référentiel autonome, hors écrans transporteurs).
> Les 3 PR sont **empilées** (`develop → ERP-150 → ERP-39 → ERP-149`). Démarrer le M4 une fois **ERP-150 + ERP-39 dans `develop`** (DoR des tickets 1.1 et 1.4). Brancher le M4 sur `develop` post-merge.
## Vue d'ensemble (ordre d'exécution)
| # | Ticket | Tag | Effort | RG / dépend |
|---|---|---|---|---|
| 1.1 | Déclarer permissions `transport.carriers.*` + sidebar | Backend | S | DoR : ERP-150 mergé |
| 1.2 | Créer l'infra d'upload générique `Shared` | Backend | M | § 2.7 |
| 1.3 | Migrer le schéma BDD M4 (carrier + sous-tables) | Backend | M | § 3.2 |
| 1.4 | Exposer `QualimatCarrier` (lecture seule) + endpoint recherche | Backend | S | RG-4.01 · DoR : ERP-39 mergé |
| 1.5 | Créer entités `Carrier*` + repos + `ApiResource` + `CarrierProvider` | Backend | M | § 3.3 / 4.0 |
| 1.6 | Implémenter `CarrierProcessor` (RG-4.01/4.02/4.03 + LIOT + normalisation + archive) | Backend | M | RG-4.01→4.03, 4.13, 4.14 |
| 1.7 | Sous-ressource Adresses (`carrier_address`) | Backend | S | RG-4.05→4.07 |
| 1.8 | Sous-ressource Contacts (`carrier_contact`) | Backend | S | RG-4.08 |
| 1.9 | Sous-ressource Prix (`carrier_price`) + RG branches | Backend | M | RG-4.09→4.11 |
| 1.10 | Export XLSX (répertoire + onglet Prix regroupé) | Backend | M | § 4.6 |
| 1.11 | Tests PHPUnit RG-4.01→4.14 + capture contrat JSON (DoD) | Backend | M | § 4.0.bis / 8.1 |
| 1.12 | Page Répertoire `/carriers` (datatable, filtres, export) | Frontend | M | RG-4.04 |
| 1.13 | Page Ajouter `/carriers/new` (layout, onglets, formulaire principal POST) | Frontend | M | RG-4.12 |
| 1.14 | Saisie assistée QUALIMAT + champs conditionnels (Affréter / AUTRE→Décharge / LIOT) | Frontend | M | RG-4.01→4.03 |
| 1.15 | Onglet Adresses (autocomplete BAN) | Frontend | M | RG-4.05→4.07 |
| 1.16 | Onglet Contacts | Frontend | S | RG-4.08 |
| 1.17 | Onglet Prix (Client/Fournisseur, sites) | Frontend | M | RG-4.09→4.11 |
| 1.18 | Pages Consultation + Modification | Frontend | M | — |
| 1.19 | Upload front (`useUpload`) + i18n + libellés audit | Frontend | S | § 2.8 |
**Total** : 19 tickets · ~11 back / 8 front · mini-MR de 1 à 4h.
---
## Tickets — détail
### 1.1 — Déclarer permissions `transport.carriers.*` + sidebar
**Position** : 1.1 • Suit : — • Précède : Migrer le schéma BDD M4
**Tag** : Backend • **Effort** : S
**Contexte** : `TransportModule::permissions()` renvoie aujourd'hui `[]`. Ce ticket pose le socle RBAC du module et son entrée de menu, prérequis de toute opération sécurisée.
**Spec liée** : [`spec-back.md § 5`](./spec-back.md) · [`spec-front.md § Accès`](./spec-front.md)
**Critères d'acceptation** :
- [ ] `TransportModule::permissions()` déclare `transport.carriers.view`, `transport.carriers.manage`, `transport.carriers.archive` ; `app:sync-permissions` les enregistre.
- [ ] **Matrice § 5.2** : Admin (view+manage+archive), Bureau (view+manage), Commerciale (view), Compta + Usine (aucune).
- [ ] **3 sources RBAC alignées dans le même commit** (règle ABSOLUE n°8) : `config/sidebar.php` (section Transport + item `/carriers` + permission), `personas.ts`, `SeedE2ECommand.php`.
- [ ] Item sidebar masqué pour Compta/Usine ; visible Admin/Bureau/Commerciale.
**Tests à prévoir** : permissions sync OK ; personas e2e cohérents (pas de drift).
**Tips** : DoR — ERP-150 mergé (module Transport présent). Section sidebar « Transport » (ou « Logistique » — à trancher, cosmétique).
### 1.2 — Créer l'infra d'upload générique `Shared`
**Position** : 1.2 • Suit : permissions • Précède : Migration M4
**Tag** : Backend • **Effort** : M
**Contexte** : la « Décharge » (RG-4.02) est le 1er d'une série d'uploads à venir. On pose une infra réutilisable, pas un upload ad hoc.
**Spec liée** : [`spec-back.md § 2.7`](./spec-back.md)
**Critères d'acceptation** :
- [ ] Table `uploaded_document` (`original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum`, `created_at`, `created_by`) + COMMENT ON COLUMN.
- [ ] Service `Shared\Infrastructure\Upload\FileUploader` : validation MIME **server-side via `$file->getMimeType()`** (jamais `getClientMimeType()`), bornage taille, checksum sha256, écriture disque (`var/uploads/{yyyy}/{mm}/`).
- [ ] Endpoint `POST /api/uploaded_documents` (multipart) → renvoie l'IRI ; whitelist MIME explicite (PDF + images) ; hors whitelist → 422.
**Tests à prévoir** : PHPUnit — MIME hors whitelist → 422 ; MIME valide → IRI + ligne persistée ; checksum calculé.
**Tips** : générique et réutilisable (autres modules la consommeront). Antivirus / S3 / purge = HP (§ 9).
### 1.3 — Migrer le schéma BDD M4 (carrier + sous-tables)
**Position** : 1.3 • Suit : infra upload • Précède : QualimatCarrier
**Tag** : Backend • **Effort** : M
**Contexte** : créer le schéma du répertoire (entité éditable distincte du référentiel `qualimat_carrier`).
**Spec liée** : [`spec-back.md § 3.2`](./spec-back.md)
**Critères d'acceptation** :
- [ ] Migration namespace racine `DoctrineMigrations`, **postérieure** à `Version20260612160000`.
- [ ] Tables `carrier`, `carrier_address`, `carrier_contact`, `carrier_price` + FK (`qualimat_carrier`, `uploaded_document`, `client`, `client_address`, `supplier`, `supplier_address`, `site`, `user`).
- [ ] `certification_type` **nullable** (null seulement en cas LIOT) + CHECK enum ; CHECK `container_type`, `direction`, `pricing_unit`, `price_state`, branches Prix client/fournisseur.
- [ ] Index partiel `uq_carrier_name_active` (LOWER(name), WHERE non archivé & non supprimé).
- [ ] **`COMMENT ON COLUMN` sur TOUTES les colonnes** (règle n°12) + helper Timestampable/Blamable. `ColumnsHaveSqlCommentTest` vert.
- [ ] `make db-reset` passe ; schéma conforme.
**Tests à prévoir** : `make db-reset` OK ; `ColumnsHaveSqlCommentTest` vert ; index partiel présent.
**Tips** : PK `BIGINT` (cohérence module Transport) — à confirmer vs `INT`.
### 1.4 — Exposer `QualimatCarrier` (lecture seule) + endpoint recherche
**Position** : 1.4 • Suit : migration • Précède : entités Carrier*
**Tag** : Backend • **Effort** : S
**Contexte** : la saisie assistée du nom (RG-4.01) a besoin d'un endpoint de recherche sur le référentiel QUALIMAT, aujourd'hui alimenté en console mais non exposé.
**Spec liée** : [`spec-back.md § 4.7`](./spec-back.md) · RG-4.01
**Critères d'acceptation** :
- [ ] Entité `QualimatCarrier` (lecture seule) mappée sur la table existante `qualimat_carrier` (aucune écriture exposée).
- [ ] `GET /api/qualimat_carriers?search=` : fuzzy sur `name` (+ `siret`), **seulement `is_active = true`**, tri `name`, paginé (règle n°13).
- [ ] **Security** `is_granted('transport.carriers.view')`. Champs exposés : `id, siret, name, address, postalCode, city, phone, department, status, validityDate, isActive`.
**Tests à prévoir** : PHPUnit — recherche ne renvoie que les actifs ; pagination Hydra ; 403 sans permission.
**Tips** : DoR — ERP-39 mergé. Ne pas toucher la commande de sync.
### 1.5 — Créer entités `Carrier*` + repos + `ApiResource` + `CarrierProvider`
**Position** : 1.5 • Suit : QualimatCarrier • Précède : CarrierProcessor
**Tag** : Backend • **Effort** : M
**Contexte** : poser les entités, le contrat de sérialisation (groupes) et la lecture (liste + détail).
**Spec liée** : [`spec-back.md § 3.3 / 3.4 / 4.0 / 4.1 / 4.2`](./spec-back.md)
**Critères d'acceptation** :
- [ ] Entités `Carrier`, `CarrierAddress`, `CarrierContact`, `CarrierPrice` (`#[Auditable]`, `TimestampableBlamableTrait`), repos Doctrine.
- [ ] `ApiResource` Carrier : `GetCollection` + `Get` + `Post` + `Patch` avec `security` (§ 3.3) ; **pas de Delete**.
- [ ] Groupes de sérialisation : `carrier:read`, `carrier:item:read`, `qualimat:read`, embed `client:read`/`client_address:read`/`supplier:read`/`supplier_address:read`/`site:read` au détail (3 maillons § 4.0 — ⚠ les adresses de l'onglet Prix sont des entités `ClientAddress`/`SupplierAddress` distinctes).
- [ ] `CarrierProvider` paginé (`ApiPlatform\Doctrine\Orm\Paginator`) ; liste **sans cloisonnement site** (§ 2.3) ; anti-N+1 (§ 2.11).
- [ ] Piège booléen `isArchived` : `#[SerializedName('isArchived')]` sur le getter.
**Tests à prévoir** : liste exclut archivés par défaut ; `?includeArchived=true` ; enveloppe Hydra ; `isArchived` présent dans le JSON.
**Tips** : miroir `Supplier`/`Provider`. Pas d'onglet Comptabilité (≠ M2/M3).
### 1.6 — Implémenter `CarrierProcessor`
**Position** : 1.6 • Suit : entités • Précède : sous-ressource Adresses
**Tag** : Backend • **Effort** : M
**Contexte** : logique d'écriture du formulaire principal (POST/PATCH) : normalisation, champs conditionnels, archivage.
**Spec liée** : [`spec-back.md § 4.3 / 4.4 / 7`](./spec-back.md)
**Critères d'acceptation** :
- [ ] **RG-4.01** : POST avec `qualimatCarrier``certificationType=QUALIMAT` + FK persistée ; cas LIOT : `name='LIOT'``certificationType` non requis, `liotPlates` accepté.
- [ ] **RG-4.02** : `certificationType='AUTRE'` sans `dischargeDocument`**422** (`#[Assert\Callback]`).
- [ ] **RG-4.03** : `isChartered=true` sans `indexationRate`/`containerType`/`volumeM3`**422**.
- [ ] **RG-4.13** : normalisation (`name` UPPER, contacts Capitalize, phones digits, email lower, `liotPlates`).
- [ ] **RG-4.12** : doublon `name` (actifs) → **409**.
- [ ] **RG-4.14** : PATCH `isArchived` exige `transport.carriers.archive` (Admin) ; mode strict (403 sinon).
**Tests à prévoir** : PHPUnit sur chaque RG ci-dessus (cf. § 8.1).
**Tips** : `CarrierFieldNormalizer` miroir `SupplierFieldNormalizer`.
### 1.7 — Sous-ressource Adresses (`carrier_address`)
**Position** : 1.7 • Suit : CarrierProcessor • Précède : Contacts
**Tag** : Backend • **Effort** : S
**Spec liée** : [`spec-back.md § 4.5`](./spec-back.md) · RG-4.05→4.07
**Critères d'acceptation** :
- [ ] `POST /api/carriers/{id}/addresses`, `PATCH`/`DELETE /api/carrier_addresses/{id}` (security `manage`).
- [ ] **RG-4.06** : `postalCode` matche `^[0-9]{4,5}$` (autocomplete ville = front).
- [ ] **RG-4.05** : si affrété, adresse obligatoire (Pays/CP/Ville/Adresse) — validation conditionnelle.
**Tests à prévoir** : PHPUnit — CP invalide → 422 ; adresse affrété incomplète → 422.
**Tips** : RG-4.07 (bouton Valider masqué si QUALIMAT) = front, back accepte le PATCH.
### 1.8 — Sous-ressource Contacts (`carrier_contact`)
**Position** : 1.8 • Suit : Adresses • Précède : Prix
**Tag** : Backend • **Effort** : S
**Spec liée** : [`spec-back.md § 4.5`](./spec-back.md) · RG-4.08
**Critères d'acceptation** :
- [ ] `POST /api/carriers/{id}/contacts`, `PATCH`/`DELETE /api/carrier_contacts/{id}` (security `manage`).
- [ ] **RG-4.08** : bloc valide si ≥ 1 champ rempli (CHECK `chk_carrier_contact_filled` + Processor) ; **max 2 téléphones**.
**Tests à prévoir** : PHPUnit — contact vide → 422 ; 1 champ → 200.
**Tips** : miroir contacts M2/M3.
### 1.9 — Sous-ressource Prix (`carrier_price`) + RG branches
**Position** : 1.9 • Suit : Contacts • Précède : Export
**Tag** : Backend • **Effort** : M
**Spec liée** : [`spec-back.md § 4.5 / 7`](./spec-back.md) · RG-4.09→4.11
**Critères d'acceptation** :
- [ ] `POST /api/carriers/{id}/prices`, `PATCH`/`DELETE /api/carrier_prices/{id}` (security `manage`).
- [ ] **RG-4.10** (CLIENT) : `client`, `clientDeliveryAddress`, `departureSite` requis ; `clientDeliveryAddress` doit appartenir au `client` → sinon 422.
- [ ] **RG-4.11** (FOURNISSEUR) : `supplier`, `supplierSupplyAddress`, `deliverySite` requis ; `supplierSupplyAddress` appartient au `supplier` → sinon 422.
- [ ] Communs obligatoires : `containerType`, `pricingUnit`, `price`, `priceState` ; CHECK branches respectées.
**Tests à prévoir** : PHPUnit — branche CLIENT/FOURNISSEUR incomplète → 422 ; adresse étrangère → 422.
**Tips** : « Adresse départ/livraison 86/17/82 » = `Site` (FK) ; livraison client = `ClientAddress`, appro = `SupplierAddress` (relations ORM partagées).
### 1.10 — Export XLSX (répertoire + onglet Prix regroupé)
**Position** : 1.10 • Suit : Prix • Précède : Tests PHPUnit
**Tag** : Backend • **Effort** : M
**Spec liée** : [`spec-back.md § 4.6`](./spec-back.md)
**Critères d'acceptation** :
- [ ] `GET /api/carriers/export.xlsx` : transporteurs affichés (mêmes filtres) ; colonnes § 4.6.
- [ ] `GET /api/carriers/{id}/prices/export.xlsx` : tableau Prix regroupé Benne / Fond Mouvant (colonnes docx p.10).
- [ ] Controllers custom `#[Route(priority: 1)]` (conflit API Platform `{id}`) ; `Content-Disposition`.
**Tests à prévoir** : PHPUnit — 200 + en-tête fichier ; respect des filtres.
**Tips** : PhpSpreadsheet déjà présent.
### 1.11 — Tests PHPUnit RG-4.01→4.14 + capture contrat JSON (DoD)
**Position** : 1.11 • Suit : Export • Précède : Page Répertoire
**Tag** : Backend • **Effort** : M
**Spec liée** : [`spec-back.md § 4.0.bis / 8.1`](./spec-back.md)
**Critères d'acceptation** :
- [ ] Matrice RG-4.01→4.14 couverte (§ 8.1) + RBAC par rôle (Compta/Usine → 403).
- [ ] `CarrierSerializationContractTest` : capture JSON réel **liste + détail** ; `prices[].client`/`.supplier`/sites **embarqués** (pas IRI) ; `qualimatCarrier` embarqué ; `isArchived` présent.
- [ ] Anti-N+1 liste ; pagination Hydra ; audit (`entity_type='Carrier'`) ; `AuditableEntitiesHaveI18nLabelTest` vert.
- [ ] `CarrierFixtures` idempotent (§ 8.4) : transporteur QUALIMAT (validité passée), AUTRE+décharge, affrété, LIOT, complet (contacts/adresses/prix CLIENT+FOURNISSEUR), 1 archivé.
**Tests à prévoir** : suite complète `make test` verte.
**Tips** : coller les JSON capturés dans § 4.0.bis (DoD avant front).
### 1.12 — Page Répertoire `/carriers` (datatable, filtres, export)
**Position** : 1.12 • Suit : Tests back • Précède : Page Ajouter
**Tag** : Frontend • **Effort** : M
**Spec liée** : [`spec-front.md § Datatable / Filtres`](./spec-front.md) · Figma `1132-45377`
**Critères d'acceptation** :
- [ ] `<MalioDataTable>` + `usePaginatedList<Carrier>({url:'/carriers'})` ; colonnes Nom / Certification / Date de validité / Dernière activité.
- [ ] **RG-4.04** : date de validité QUALIMAT < aujourd'hui → **fond rouge**.
- [ ] Filtres (`search`, `certificationType`, `includeArchived`) → `setFilters` (page 1) ; **état 100 % local** (règle n°6).
- [ ] Boutons « + Ajouter » (si `manage`) / « Filtrer » / « Exporter » (XLSX) ; clic ligne → Consultation.
**Tests à prévoir** : Vitest — `usePaginatedList` (Hydra, exclusion archivés).
**Tips** : `useApi()` obligatoire ; pas de persistance URL.
### 1.13 — Page Ajouter `/carriers/new` (layout, onglets, formulaire principal POST)
**Position** : 1.13 • Suit : Répertoire • Précède : Saisie assistée QUALIMAT
**Tag** : Frontend • **Effort** : M
**Spec liée** : [`spec-front.md § Écran Ajouter / Formulaire principal`](./spec-front.md) · Figma node `1132-45382` (Ajouter Qualimat)
**Critères d'acceptation** :
- [ ] Layout + barre d'onglets `Qualimat · Adresses · Contacts · Prix` ; validation incrémentale (onglet suivant accessible après validation).
- [ ] Formulaire principal (Nom, Liste certification, Affréter, …) → `POST /api/carriers` ; succès → bascule onglet + champs readonly.
- [ ] `useFormErrors` : mapping 422 inline par champ ; `{ toast:false }`.
**Tests à prévoir** : Vitest — `useCarrierForm` (workflow par onglet, POST principal).
**Tips** : miroir `useSupplierForm`/`useProviderForm`.
### 1.14 — Saisie assistée QUALIMAT + champs conditionnels
**Position** : 1.14 • Suit : Page Ajouter • Précède : Onglet Adresses
**Tag** : Frontend • **Effort** : M
**Spec liée** : [`spec-front.md § Formulaire principal / Onglet Qualimat`](./spec-front.md) · RG-4.01→4.03 · Figma nodes `1132-50717` (Affréter), `1132-50982` (AUTRE→Décharge), `1132-45593` (LIOT)
**Critères d'acceptation** :
- [ ] **RG-4.01** : saisie du nom → `GET /api/qualimat_carriers?search=` → modal « Êtes-vous sûr… » → copie Nom + certification (`QUALIMAT`, readonly) + adresse + FK conservée.
- [ ] **Cas LIOT** : nom `LIOT` → champ immatriculations seul, autres masqués.
- [ ] **RG-4.02** : certification `AUTRE` → champ Décharge visible **et obligatoire** (upload).
- [ ] **RG-4.03** : « Affréter » coché → indexation / benne-fond mouvant / volume visibles et obligatoires.
**Tests à prévoir** : Vitest — affichage conditionnel (Affréter, AUTRE, LIOT) ; copie QUALIMAT.
**Tips** : `useQualimatSearch()` ; `useUpload()` (ticket 1.19) pour la décharge.
### 1.15 — Onglet Adresses (autocomplete BAN)
**Position** : 1.15 • Suit : Saisie QUALIMAT • Précède : Onglet Contacts
**Tag** : Frontend • **Effort** : M
**Spec liée** : [`spec-front.md § Onglet Adresses`](./spec-front.md) · RG-4.05→4.07 · Figma node `1132-45670`
**Critères d'acceptation** :
- [ ] Bloc adresse (Pays/CP/Ville/Adresse/complément) → `PATCH /api/carriers/{id}/addresses`.
- [ ] **RG-4.06** : `useAddressAutocomplete()` (BAN) — ville auto depuis CP, dégradé texte libre.
- [ ] **RG-4.05** : champs préremplis si QUALIMAT ; obligatoires si affrété. **RG-4.07** : pas de bouton Valider si QUALIMAT.
**Tests à prévoir** : Vitest — autocomplete nominal + dégradé (réutilisation M1/M2/M3).
**Tips** : ne pas réécrire `useAddressAutocomplete()`.
### 1.16 — Onglet Contacts
**Position** : 1.16 • Suit : Adresses • Précède : Onglet Prix
**Tag** : Frontend • **Effort** : S
**Spec liée** : [`spec-front.md § Onglet Contacts`](./spec-front.md) · RG-4.08 · Figma node `1132-45756`
**Critères d'acceptation** :
- [ ] Blocs contact (Nom/Prénom/Fonction/Téléphone x1-2/Email) → `PATCH /api/carriers/{id}/contacts`.
- [ ] **RG-4.08** : « + Nouveau contact » bloqué tant que le bloc courant est vide ; suppression avec modal.
**Tests à prévoir** : Vitest — règle « ≥ 1 champ », max 2 téléphones.
**Tips** : `mapViolationsToRecord` par ligne (pattern collections M1/M2/M3).
### 1.17 — Onglet Prix (Client/Fournisseur, sites)
**Position** : 1.17 • Suit : Contacts • Précède : Consultation/Modification
**Tag** : Frontend • **Effort** : M
**Spec liée** : [`spec-front.md § Onglet Prix`](./spec-front.md) · RG-4.09→4.11 · Figma node `1132-45859`
**Critères d'acceptation** :
- [ ] Radio `direction` (Client/Fournisseur) → bascule des champs (**RG-4.09**).
- [ ] **RG-4.10** (Client) : Client + Adresse de livraison (du client) + Adresse de départ (86/17/82).
- [ ] **RG-4.11** (Fournisseur) : Fournisseur + Adresse d'approvisionnement + Adresse de livraison (86/17/82).
- [ ] Communs : Benne/FM, Forfait/Tonne, Prix (`MalioInputAmount`), État du prix → `PATCH /api/carriers/{id}/prices`.
**Tests à prévoir** : Vitest — bascule Client/Fournisseur, champs requis.
**Tips** : selects clients/fournisseurs/sites via endpoints existants (security élargie § 4.8).
### 1.18 — Pages Consultation + Modification
**Position** : 1.18 • Suit : Onglet Prix • Précède : Upload/i18n
**Tag** : Frontend • **Effort** : M
**Spec liée** : [`spec-front.md § Consultation / Modification`](./spec-front.md)
**Critères d'acceptation** :
- [ ] Consultation readonly (ouvre sur Adresses) ; flèche retour ; « Modifier » (si `manage`) ; « Archiver » (Admin) → PATCH `isArchived`.
- [ ] Onglet Prix consultation = tableau regroupé Benne/FM + bouton Exporter (XLSX).
- [ ] Modification = mêmes formulaires, champs pré-remplis, PATCH partiel par onglet.
**Tests à prévoir** : Vitest — `useCarrier(id)` peuple les écrans depuis une seule réponse ; visibilité boutons par permission.
**Tips** : « Restaurer » remplace « Archiver » sur un archivé.
### 1.19 — Upload front (`useUpload`) + i18n + libellés audit
**Position** : 1.19 • Suit : Consultation/Modification • Précède : —
**Tag** : Frontend • **Effort** : S
**Spec liée** : [`spec-back.md § 2.7 / 2.8`](./spec-back.md) · [`spec-front.md § Composables`](./spec-front.md)
**Critères d'acceptation** :
- [ ] Composable `useUpload()` : `POST /api/uploaded_documents` (multipart) → IRI posée sur `carrier.dischargeDocument` (RG-4.02).
- [ ] Clés i18n : libellés certification, sidebar (`sidebar.transport.*`), **libellés audit** `audit.entity.transport_carrier/carrieraddress/carriercontact/carrierprice`.
- [ ] `<MalioInputUpload>` (exception documentée si type non couvert).
**Tests à prévoir** : Vitest — `useUpload` (succès + erreur MIME).
**Tips** : `AuditableEntitiesHaveI18nLabelTest` exige les clés audit.
---
## Actions Lesstime (à exécuter au feu vert de Matthieu)
1. `create-group` projectId 6, title « M4 — Répertoire transporteurs » → récupérer l'`id`.
2. `create-task` ×19 (statut `Prêt à dev` = 6, priorité Moyen=2, effort dans la description), dans l'ordre 1.1 → 1.19 :
- Tickets **1.1 → 1.11** (Backend, tag `3`) → **assigné à Matthieu**.
- Tickets **1.12 → 1.19** (Frontend, tag `2`) → **assigné à Tristan**.
3. Mettre à jour le frontmatter des specs (`lesstime_taskgroup_id`) + lien du groupe.
> Au push : récupérer les `userId` via `list-users` (Matthieu = `5` selon le référentiel ; Tristan à confirmer) pour renseigner l'assignation à la création.
+14 -5
View File
@@ -2,6 +2,7 @@
"common": {
"loading": "Chargement...",
"save": "Enregistrer",
"validate": "Valider",
"cancel": "Annuler",
"delete": "Supprimer",
"edit": "Modifier",
@@ -34,6 +35,10 @@
"section": "Technique",
"providers": "Répertoire prestataires"
},
"transport": {
"section": "Transport",
"carriers": "Répertoire transporteurs"
},
"core": {
"roles": "Gestion des rôles",
"users": "Utilisateurs",
@@ -70,7 +75,7 @@
"categories": "Catégories",
"sites": "Sites",
"status": "Statut",
"includeArchived": "Inclure les archivés",
"archivedOnly": "Voir les archivés",
"apply": "Voir les résultats",
"reset": "Réinitialiser"
},
@@ -119,7 +124,7 @@
"back": "Retour au répertoire",
"loading": "Chargement du fournisseur…",
"notFound": "Fournisseur introuvable.",
"save": "Valider"
"save": "Enregistrer"
},
"form": {
"title": "Ajouter un fournisseur",
@@ -262,7 +267,7 @@
"back": "Retour au répertoire",
"loading": "Chargement du client…",
"notFound": "Client introuvable.",
"save": "Valider"
"save": "Enregistrer"
},
"validation": {
"informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.",
@@ -384,7 +389,7 @@
"categories": "Catégories",
"sites": "Sites",
"status": "Statut",
"includeArchived": "Inclure les archivés",
"archivedOnly": "Voir les archivés",
"apply": "Voir les résultats",
"reset": "Réinitialiser"
},
@@ -548,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",
@@ -59,7 +59,7 @@
/>
<MalioButton
v-if="canShowSave"
:label="t('common.save')"
:label="isCreateMode ? t('common.validate') : t('common.save')"
variant="primary"
button-class="w-m-btn-action"
:disabled="form.submitting.value || loadingTypes"
@@ -51,7 +51,7 @@ describe('useSuppliersRepository', () => {
search: 'acme',
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
'siteId[]': ['86', '17'],
includeArchived: true,
archivedOnly: true,
},
{ replace: true },
)
@@ -63,7 +63,7 @@ describe('useSuppliersRepository', () => {
search: 'acme',
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
'siteId[]': ['86', '17'],
includeArchived: true,
archivedOnly: true,
page: 1,
itemsPerPage: 10,
},
@@ -73,7 +73,7 @@ describe('useSuppliersRepository', () => {
it('repasse a une query propre apres reinitialisation des filtres', async () => {
const repo = useSuppliersRepository()
await repo.setFilters({ search: 'acme', includeArchived: true }, { replace: true })
await repo.setFilters({ search: 'acme', archivedOnly: true }, { replace: true })
await repo.setFilters({}, { replace: true })
expect(mockGet).toHaveBeenLastCalledWith(
@@ -172,16 +172,16 @@ describe('Répertoire fournisseurs (page /suppliers)', () => {
)
})
it('repercute le filtre « Inclure les archivés » dans setFilters sans toucher l\'URL', async () => {
it('repercute le filtre « Voir les archivés » dans setFilters sans toucher l\'URL', async () => {
const wrapper = mountPage()
await flushPromises()
// Coche « Inclure les archivés » puis applique les filtres.
await wrapper.find('input[data-id="filter-include-archived"]').setValue(true)
// Coche « Voir les archivés » puis applique les filtres.
await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
expect(mockSetFilters).toHaveBeenLastCalledWith(
{ includeArchived: true },
{ archivedOnly: true },
{ replace: true },
)
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
@@ -192,7 +192,7 @@ describe('Répertoire fournisseurs (page /suppliers)', () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('input[data-id="filter-include-archived"]').setValue(true)
await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
@@ -128,13 +128,13 @@
</div>
</MalioAccordionItem>
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). -->
<!-- Statut : bool unique. Coche = archives uniquement, sinon actifs. -->
<MalioAccordionItem :title="t('commercial.suppliers.filters.status')" value="status">
<MalioCheckbox
id="filter-include-archived"
:label="t('commercial.suppliers.filters.includeArchived')"
:model-value="draftIncludeArchived"
@update:model-value="(val: boolean) => draftIncludeArchived = val"
id="filter-archived-only"
:label="t('commercial.suppliers.filters.archivedOnly')"
:model-value="draftArchivedOnly"
@update:model-value="(val: boolean) => draftArchivedOnly = val"
/>
</MalioAccordionItem>
</MalioAccordion>
@@ -254,12 +254,12 @@ const filterDrawerOpen = ref(false)
const draftSearch = ref('')
const draftCategoryCodes = ref<string[]>([])
const draftSiteIds = ref<string[]>([])
const draftIncludeArchived = ref(false)
const draftArchivedOnly = ref(false)
const appliedSearch = ref('')
const appliedCategoryCodes = ref<string[]>([])
const appliedSiteIds = ref<string[]>([])
const appliedIncludeArchived = ref(false)
const appliedArchivedOnly = ref(false)
// Options des selects multi, chargees une fois (referentiels courts).
const categoryOptions = ref<FilterOption[]>([])
@@ -270,7 +270,7 @@ const activeFilterCount = computed(() => {
if (appliedSearch.value.trim() !== '') count++
if (appliedCategoryCodes.value.length > 0) count++
if (appliedSiteIds.value.length > 0) count++
if (appliedIncludeArchived.value) count++
if (appliedArchivedOnly.value) count++
return count
})
@@ -285,7 +285,7 @@ function openFilters(): void {
draftSearch.value = appliedSearch.value
draftCategoryCodes.value = [...appliedCategoryCodes.value]
draftSiteIds.value = [...appliedSiteIds.value]
draftIncludeArchived.value = appliedIncludeArchived.value
draftArchivedOnly.value = appliedArchivedOnly.value
filterDrawerOpen.value = true
}
@@ -311,7 +311,7 @@ function buildFilterPayload(): Record<string, string | string[] | boolean> {
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
if (appliedIncludeArchived.value) payload.includeArchived = true
if (appliedArchivedOnly.value) payload.archivedOnly = true
return payload
}
@@ -321,7 +321,7 @@ function applyFilters(): void {
appliedSearch.value = draftSearch.value.trim()
appliedCategoryCodes.value = [...draftCategoryCodes.value]
appliedSiteIds.value = [...draftSiteIds.value]
appliedIncludeArchived.value = draftIncludeArchived.value
appliedArchivedOnly.value = draftArchivedOnly.value
setFilters(buildFilterPayload(), { replace: true })
filterDrawerOpen.value = false
@@ -333,12 +333,12 @@ function resetFilters(): void {
draftSearch.value = ''
draftCategoryCodes.value = []
draftSiteIds.value = []
draftIncludeArchived.value = false
draftArchivedOnly.value = false
appliedSearch.value = ''
appliedCategoryCodes.value = []
appliedSiteIds.value = []
appliedIncludeArchived.value = false
appliedArchivedOnly.value = false
setFilters({}, { replace: true })
}
@@ -83,7 +83,7 @@
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
:label="isEditMode ? t('common.save') : t('common.validate')"
variant="primary"
button-class="w-m-btn-action"
:disabled="saving || permissionsLoadFailed"
@@ -103,7 +103,7 @@
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
:label="isEditMode ? t('common.save') : t('common.validate')"
variant="primary"
button-class="w-m-btn-action"
:disabled="saving || !isValidHex"
@@ -14,9 +14,9 @@ vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
* - l'enveloppe Hydra (member / totalItems) est consommee
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
* renvoie un tableau plat sans pagination)
* - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `includeArchived` n'est envoye
* - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `archivedOnly` n'est envoye
* tant que l'utilisateur ne coche pas le filtre (le back masque alors les
* archives) ; le filtre `includeArchived` est bien transmis une fois applique.
* archives) ; le filtre `archivedOnly` est bien transmis une fois applique.
*/
describe('useProvidersRepository', () => {
beforeEach(() => {
@@ -53,26 +53,26 @@ describe('useProvidersRepository', () => {
expect(repo.totalItems.value).toBe(1)
})
it('exclut les archives par defaut : aucun includeArchived au premier fetch', async () => {
it('exclut les archives par defaut : aucun archivedOnly au premier fetch', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useProvidersRepository()
await repo.fetch()
const query = mockApiGet.mock.calls[0][1] as Record<string, unknown>
expect(query.includeArchived).toBeUndefined()
expect(query.archivedOnly).toBeUndefined()
})
it('transmet includeArchived une fois le filtre applique (retour page 1)', async () => {
it('transmet archivedOnly une fois le filtre applique (retour page 1)', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useProvidersRepository()
await repo.fetch()
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
await repo.setFilters({ includeArchived: true })
await repo.setFilters({ archivedOnly: true })
expect(repo.currentPage.value).toBe(1)
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(query.includeArchived).toBe(true)
expect(query.archivedOnly).toBe(true)
})
})
@@ -45,10 +45,11 @@ export interface Provider {
* sur la ressource `/providers` (pagination serveur obligatoire ; jamais de
* chargement integral en memoire). Miroir de `useSuppliersRepository` (M2).
*
* Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes
* par la page via `setFilters` du composable partage la remise en page 1 est
* garantie. Par defaut, aucun `includeArchived` n'est envoye : le back masque
* donc les prestataires archives (exclusion par defaut, spec-back § 2.11).
* Les filtres (recherche, categories, sites, archives) sont pilotes par la page
* via `setFilters` du composable partage la remise en page 1 est garantie. Par
* defaut, aucun `archivedOnly` n'est envoye : le back masque donc les prestataires
* archives (exclusion par defaut, spec-back § 2.11). Cocher « Voir les archivés »
* envoie `archivedOnly=true` seules les archives sont listees (aligne sur Client).
*
* Le cloisonnement par site est applique AUTOMATIQUEMENT cote back (§ 2.13) en
* fonction de l'utilisateur rien a filtrer cote front.
@@ -129,13 +129,13 @@
</div>
</MalioAccordionItem>
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). -->
<!-- Statut : bool unique. Coche = archives uniquement, sinon actifs. -->
<MalioAccordionItem :title="t('technique.providers.filters.status')" value="status">
<MalioCheckbox
id="filter-include-archived"
:label="t('technique.providers.filters.includeArchived')"
:model-value="draftIncludeArchived"
@update:model-value="(val: boolean) => draftIncludeArchived = val"
id="filter-archived-only"
:label="t('technique.providers.filters.archivedOnly')"
:model-value="draftArchivedOnly"
@update:model-value="(val: boolean) => draftArchivedOnly = val"
/>
</MalioAccordionItem>
</MalioAccordion>
@@ -258,12 +258,12 @@ const filterDrawerOpen = ref(false)
const draftSearch = ref('')
const draftCategoryCodes = ref<string[]>([])
const draftSiteIds = ref<string[]>([])
const draftIncludeArchived = ref(false)
const draftArchivedOnly = ref(false)
const appliedSearch = ref('')
const appliedCategoryCodes = ref<string[]>([])
const appliedSiteIds = ref<string[]>([])
const appliedIncludeArchived = ref(false)
const appliedArchivedOnly = ref(false)
// Options des selects multi, chargees une fois (referentiels courts).
const categoryOptions = ref<FilterOption[]>([])
@@ -274,7 +274,7 @@ const activeFilterCount = computed(() => {
if (appliedSearch.value.trim() !== '') count++
if (appliedCategoryCodes.value.length > 0) count++
if (appliedSiteIds.value.length > 0) count++
if (appliedIncludeArchived.value) count++
if (appliedArchivedOnly.value) count++
return count
})
@@ -289,7 +289,7 @@ function openFilters(): void {
draftSearch.value = appliedSearch.value
draftCategoryCodes.value = [...appliedCategoryCodes.value]
draftSiteIds.value = [...appliedSiteIds.value]
draftIncludeArchived.value = appliedIncludeArchived.value
draftArchivedOnly.value = appliedArchivedOnly.value
filterDrawerOpen.value = true
}
@@ -315,7 +315,7 @@ function buildFilterPayload(): Record<string, string | string[] | boolean> {
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
if (appliedIncludeArchived.value) payload.includeArchived = true
if (appliedArchivedOnly.value) payload.archivedOnly = true
return payload
}
@@ -325,7 +325,7 @@ function applyFilters(): void {
appliedSearch.value = draftSearch.value.trim()
appliedCategoryCodes.value = [...draftCategoryCodes.value]
appliedSiteIds.value = [...draftSiteIds.value]
appliedIncludeArchived.value = draftIncludeArchived.value
appliedArchivedOnly.value = draftArchivedOnly.value
setFilters(buildFilterPayload(), { replace: true })
filterDrawerOpen.value = false
@@ -337,12 +337,12 @@ function resetFilters(): void {
draftSearch.value = ''
draftCategoryCodes.value = []
draftSiteIds.value = []
draftIncludeArchived.value = false
draftArchivedOnly.value = false
appliedSearch.value = ''
appliedCategoryCodes.value = []
appliedSiteIds.value = []
appliedIncludeArchived.value = false
appliedArchivedOnly.value = false
setFilters({}, { replace: true })
}
+7
View File
@@ -95,6 +95,13 @@ export const personas: Record<PersonaKey, Persona> = {
'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'],
},
+9
View File
@@ -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
@@ -258,6 +259,14 @@ seed-rbac:
qualimat-sync:
$(SYMFONY_CONSOLE) --no-interaction app:qualimat:sync
# Synchronise le referentiel des codes IDTF (ERP-149) depuis l'export Excel
# icrt-idtf.com : upsert sur (schema, idtf_number) + soft-delete + journal.
# Idempotent (refresh complet).
# Options : --schema=road|water (defaut road), --dry-run (analyse sans
# ecriture), --file=<chemin.xlsx> (source locale au lieu du telechargement).
idtf-sync:
$(SYMFONY_CONSOLE) --no-interaction app:idtf:sync
# Attention, supprime votre bdd local
db-reset:
$(DOCKER_COMPOSE) down -v
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-149 (Module Transport) : referentiel des codes IDTF (regimes de nettoyage
* transport).
*
* Tables alimentees par la commande `app:idtf:sync` (parsing de l'export Excel
* icrt-idtf.com, upsert sur (schema, idtf_number) + soft-delete + journal).
* Aucune FK cross-module : migration au namespace racine `DoctrineMigrations`.
*/
final class Version20260612160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-149 : tables idtf_product + idtf_sync_log (referentiel codes IDTF, synchro console depuis l\'export Excel).';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE idtf_product (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
idtf_number INTEGER NOT NULL,
schema VARCHAR(8) NOT NULL,
product_group VARCHAR(255) DEFAULT NULL,
name TEXT NOT NULL,
cleaning_regime VARCHAR(64) NOT NULL,
important_requirements TEXT DEFAULT NULL,
mandatory_date DATE DEFAULT NULL,
related_products TEXT DEFAULT NULL,
formula VARCHAR(255) DEFAULT NULL,
eural_code VARCHAR(64) DEFAULT NULL,
cas_numbers JSONB DEFAULT '[]' NOT NULL,
footnotes TEXT DEFAULT NULL,
source_export_date DATE NOT NULL,
is_active BOOLEAN DEFAULT TRUE NOT NULL,
last_synced_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL,
PRIMARY KEY (id),
CONSTRAINT uq_idtf_product_schema_number UNIQUE (schema, idtf_number),
CONSTRAINT chk_idtf_product_schema CHECK (schema IN ('road', 'water'))
)
SQL);
$this->addSql('CREATE INDEX idx_idtf_product_active ON idtf_product (schema, is_active)');
$this->comment('idtf_product', '_table', "Referentiel des codes IDTF (marchandise + regime de nettoyage transport), synchronise depuis l'export Excel icrt-idtf.com.");
$this->comment('idtf_product', 'id', 'Cle technique auto-incrementee.');
$this->comment('idtf_product', 'idtf_number', 'Numero IDTF de la marchandise (identifiant metier source). Unique par schema.');
$this->comment('idtf_product', 'schema', "Mode de transport / schema IDTF : 'road' (routier) ou 'water' (fluvial). Discriminant d'unicite avec idtf_number.");
$this->comment('idtf_product', 'product_group', "Groupe de produit (colonne Product Group de l'export). Nullable.");
$this->comment('idtf_product', 'name', "Nom de la marchandise (libelle FR de l'export).");
$this->comment('idtf_product', 'cleaning_regime', 'Regime de nettoyage minimal exige (A, B, C, Interdit, ...).');
$this->comment('idtf_product', 'important_requirements', 'Exigences importantes associees. Nullable.');
$this->comment('idtf_product', 'mandatory_date', "Date d'application obligatoire du regime (convertie depuis dd-mm-yyyy). Nullable.");
$this->comment('idtf_product', 'related_products', 'Produits apparentes (texte libre). Nullable.');
$this->comment('idtf_product', 'formula', 'Formule chimique de la marchandise. Nullable.');
$this->comment('idtf_product', 'eural_code', 'Code EURAL (dechet) associe. Nullable.');
$this->comment('idtf_product', 'cas_numbers', 'Liste des numeros CAS (JSONB), eclatee depuis la cellule "Numero CAS" separee par ";". Tableau vide si absent.');
$this->comment('idtf_product', 'footnotes', "Annotations / notes de bas de page de l'export. Nullable.");
$this->comment('idtf_product', 'source_export_date', 'Date d\'export du fichier source (preambule "Export date:").');
$this->comment('idtf_product', 'is_active', 'Faux = ligne absente du dernier export (soft-delete). Toute ligne non revue par le dernier run passe a FALSE.');
$this->comment('idtf_product', 'last_synced_at', 'Horodatage du run de synchro ayant vu cette ligne en dernier (soft-delete : last_synced_at < run courant).');
$this->addSql(<<<'SQL'
CREATE TABLE idtf_sync_log (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
schema VARCHAR(8) NOT NULL,
export_date DATE NOT NULL,
rows_total INT NOT NULL,
rows_upserted INT NOT NULL,
rows_deactivated INT NOT NULL,
created_at TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW() NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->comment('idtf_sync_log', '_table', 'Journal des synchronisations IDTF (une ligne par run de la commande app:idtf:sync).');
$this->comment('idtf_sync_log', 'id', 'Cle technique auto-incrementee.');
$this->comment('idtf_sync_log', 'schema', "Mode de transport synchronise : 'road' ou 'water'.");
$this->comment('idtf_sync_log', 'export_date', "Date d'export du fichier source traite par ce run.");
$this->comment('idtf_sync_log', 'rows_total', 'Nombre de lignes exploitables lues dans le fichier.');
$this->comment('idtf_sync_log', 'rows_upserted', 'Nombre de lignes inserees ou mises a jour.');
$this->comment('idtf_sync_log', 'rows_deactivated', 'Nombre de lignes passees a is_active=false (absentes de cet export).');
$this->comment('idtf_sync_log', 'created_at', 'Horodatage de fin du run (insertion du journal).');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS idtf_sync_log');
$this->addSql('DROP TABLE IF EXISTS idtf_product');
}
/**
* Pose un COMMENT ON TABLE/COLUMN en dollar-quoting Postgres ($_$...$_$)
* pour eviter tout echappement d'apostrophes dans les descriptions.
*/
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,
));
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-154 Infra d'upload de fichiers generique et reutilisable (src/Shared).
*
* Cree la table `uploaded_document` : reference technique d'un fichier televerse
* (PDF / image), gere par le service Shared\Infrastructure\Upload\FileUploader.
* La « Decharge » du M4 transporteurs en sera le premier consommateur, mais ce
* ticket ne touche AUCUN module : la table vit cote Shared.
*
* Caracteristiques :
* - Document IMMUABLE : pas d'onglet edition, pas de updated_at / updated_by.
* Seules les colonnes created_at (UTC, remplie par le FileUploader via
* l'horloge injectee) et created_by (auteur HTTP, null hors HTTP) tracent
* l'origine. C'est pourquoi l'entite Shared n'implemente PAS
* Timestampable/Blamable (qui imposeraient les 4 colonnes).
* - checksum sha256 (64 caracteres hex) : controle d'integrite + future
* deduplication eventuelle (hors scope ici).
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et non
* modulaire : la table porte une FK cross-module vers "user" (created_by). Le
* tri par version au sein du namespace racine garantit qu'elle joue APRES la
* creation de "user" sur base vide.
*
* Style DDL aligne sur le M1/M2/M3 : `INT GENERATED BY DEFAULT AS IDENTITY` et
* `TIMESTAMP(0) WITHOUT TIME ZONE` (mapping ORM `datetime_immutable`), pour que
* `schema:update --force` reste un no-op une fois l'entite mappee.
*
* COMMENT ON COLUMN inline (regle ABSOLUE n°12) : chaque colonne porte sa
* description ici. La table est aussi ajoutee a `ColumnCommentsCatalog` car
* l'entite UploadedDocument existe des ce ticket `app:apply-column-comments`
* du `test-db-setup` rejoue donc ces COMMENT apres le `schema:update --force`.
*/
final class Version20260615130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-154 : table uploaded_document (infra upload generique Shared) — fichier televerse immuable, checksum sha256.';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE uploaded_document (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
original_filename VARCHAR(255) NOT NULL,
stored_path VARCHAR(512) NOT NULL,
mime_type VARCHAR(100) NOT NULL,
size_bytes INT NOT NULL,
checksum VARCHAR(64) NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_uploaded_document_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
// Postgres n'indexe pas automatiquement les colonnes de FK.
$this->addSql('CREATE INDEX idx_uploaded_document_created_by ON uploaded_document (created_by)');
// Recherche d'integrite / future deduplication par empreinte sha256.
$this->addSql('CREATE INDEX idx_uploaded_document_checksum ON uploaded_document (checksum)');
$this->addSql('COMMENT ON TABLE uploaded_document IS $_$Fichiers televerses (infra generique Shared, ERP-154) — documents immuables (PDF / images), 1er consommateur la Decharge M4.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.id IS $_$Identifiant interne auto-incremente.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.original_filename IS $_$Nom de fichier d origine fourni par le client (≤ 255) — metadonnee d affichage uniquement, jamais utilise pour le stockage disque.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.stored_path IS $_$Chemin relatif du fichier sous var/uploads (ex: 2026/06/<hash>.pdf) — nom genere aleatoirement, jamais le nom client.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.mime_type IS $_$Type MIME detecte SERVER-SIDE via getMimeType (jamais getClientMimeType, spoofable) — borne a la whitelist FileUploader (PDF + images).$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.size_bytes IS $_$Taille du fichier en octets — bornee par FileUploader::MAX_SIZE_BYTES.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.checksum IS $_$Empreinte SHA-256 du contenu (64 caracteres hex) — controle d integrite + deduplication eventuelle (hors scope).$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.created_at IS $_$Horodatage UTC du televersement — rempli par FileUploader via l horloge injectee (pas via TimestampableBlamableSubscriber).$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.created_by IS $_$ID de l utilisateur ayant televerse le fichier — null hors HTTP (CLI, fixture). FK -> "user".id, ON DELETE SET NULL.$_$');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS uploaded_document');
}
}
+356
View File
@@ -0,0 +1,356 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M4 Repertoire transporteurs (ERP-155/157) : creation du schema BDD du
* repertoire transporteurs sous le module Transport (jumeau des M2/M3).
*
* Tables creees :
* - carrier : table principale (formulaire + lien QUALIMAT + archive + soft-delete
* + Timestampable/Blamable) ;
* - carrier_address / carrier_contact / carrier_price : sous-collections 1:n.
*
* Tables NON recrees (reutilisees) :
* - qualimat_carrier (ERP-39, Version20260612150000) : cible de la FK editable
* carrier.qualimat_carrier_id (§ 2.5) ;
* - uploaded_document (ERP-154, Version20260615130000) : cible de la FK
* carrier.discharge_document_id (Decharge, § 2.7) ;
* - client / client_address / supplier / supplier_address (M1/M2) et site (Sites) :
* cibles des FK de carrier_price (onglet Prix, RG-4.10/4.11).
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire :
* FK cross-module (user, client, client_address, supplier, supplier_address, site,
* qualimat_carrier, uploaded_document). Le tri par timestamp au sein du namespace
* racine garantit l'ordre apres la creation de ces tables sur base vide.
*
* Decision IDs (spec § 2.2, tranchee a ce ticket) : carrier et ses sous-tables
* utilisent `INT GENERATED BY DEFAULT AS IDENTITY` (homogeneite globale Starseed
* M1/M2/M3, evite la friction bigint->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,
));
}
}
@@ -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;
@@ -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;
@@ -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;
@@ -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.
@@ -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).
@@ -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',
],
],
[
@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Application\Idtf;
use RuntimeException;
use function array_slice;
/**
* Parsing pur d'une matrice (lignes/colonnes 0-indexees, telle que retournee
* par PhpSpreadsheet::toArray) de l'export Excel IDTF vers des lignes
* normalisees pretes a l'upsert. Sans dependance a PhpSpreadsheet : la matrice
* est un simple tableau, ce qui rend le parsing testable en isolation.
*
* Robuste au reordonnancement des colonnes (mapping par libelle normalise) et
* aux lignes de preambule (detection dynamique de la ligne d'en-tete). Voir
* ERP-149 § 2.
*/
final class IdtfSheetParser
{
/**
* @param array<int, array<int, mixed>> $matrix
*
* @return array{exportDate: null|string, rows: list<array<string, mixed>>}
*/
public static function parse(array $matrix): array
{
$exportDate = self::extractExportDate($matrix);
$headerIndex = self::findHeaderIndex($matrix);
if (null === $headerIndex) {
throw new RuntimeException("Ligne d'en-tete introuvable (colonne 'Numero IDTF').");
}
$map = self::buildColumnMap($matrix[$headerIndex]);
if (!isset($map['idtf_number'])) {
throw new RuntimeException("Colonne 'Numero IDTF' introuvable dans l'en-tete.");
}
$rows = [];
foreach (array_slice($matrix, $headerIndex + 1) as $row) {
$idtf = trim((string) ($row[$map['idtf_number']] ?? ''));
// Ligne vide / non exploitable : pas d'identifiant numerique.
if ('' === $idtf || !ctype_digit($idtf)) {
continue;
}
$rows[] = [
'idtf_number' => (int) $idtf,
'product_group' => self::val($row, $map['product_group'] ?? null),
'name' => self::val($row, $map['name'] ?? null) ?? '',
'cleaning_regime' => self::val($row, $map['cleaning_regime'] ?? null) ?? '',
'important_requirements' => self::val($row, $map['important_requirements'] ?? null),
'mandatory_date' => self::parseDate(self::val($row, $map['mandatory_date'] ?? null)),
'related_products' => self::val($row, $map['related_products'] ?? null),
'formula' => self::val($row, $map['formula'] ?? null),
'eural_code' => self::val($row, $map['eural_code'] ?? null),
'cas_numbers' => self::splitCas(self::val($row, $map['cas'] ?? null)),
'footnotes' => self::val($row, $map['footnotes'] ?? null),
];
}
return ['exportDate' => $exportDate, 'rows' => $rows];
}
/**
* Cherche une date "d-m-Y" dans les premieres lignes (preambule
* "Export date: 12-6-2026") et la convertit en "Y-m-d". Null si absente.
*
* @param array<int, array<int, mixed>> $matrix
*/
public static function extractExportDate(array $matrix): ?string
{
foreach (array_slice($matrix, 0, 5) as $row) {
$line = implode(' ', array_map(static fn (mixed $c): string => (string) $c, $row));
if (preg_match('/(\d{1,2})-(\d{1,2})-(\d{4})/', $line, $m)) {
$day = (int) $m[1];
$month = (int) $m[2];
$year = (int) $m[3];
if (checkdate($month, $day, $year)) {
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
}
}
return null;
}
/**
* Index de la ligne d'en-tete : premiere ligne contenant une cellule dont
* le libelle normalise contient "numero idtf".
*
* @param array<int, array<int, mixed>> $matrix
*/
private static function findHeaderIndex(array $matrix): ?int
{
foreach ($matrix as $i => $row) {
foreach ($row as $cell) {
if (str_contains(self::normalize((string) $cell), 'numero idtf')) {
return $i;
}
}
}
return null;
}
/**
* Construit le mapping logique -> index de colonne a partir de la ligne
* d'en-tete (resiste au reordonnancement via fields[]).
*
* @param array<int, mixed> $header
*
* @return array<string, int>
*/
private static function buildColumnMap(array $header): array
{
$map = [];
foreach ($header as $col => $label) {
$n = self::normalize((string) $label);
$key = match (true) {
str_contains($n, 'numero idtf') => 'idtf_number',
str_contains($n, 'product group'),
str_contains($n, 'groupe') => 'product_group',
str_contains($n, 'nom de la marchandise') => 'name',
str_contains($n, 'regime de nettoyage') => 'cleaning_regime',
str_contains($n, 'exigences importantes') => 'important_requirements',
str_contains($n, 'date d application') => 'mandatory_date',
str_contains($n, 'produits apparentes') => 'related_products',
str_contains($n, 'formule') => 'formula',
str_contains($n, 'code eural') => 'eural_code',
str_contains($n, 'numero cas') => 'cas',
str_contains($n, 'annotations') => 'footnotes',
default => null,
};
if (null !== $key && !isset($map[$key])) {
$map[$key] = (int) $col;
}
}
return $map;
}
/**
* Convertit une date "dd-mm-yyyy" en "yyyy-mm-dd". Null si format invalide
* ou date calendaire impossible.
*/
private static function parseDate(?string $raw): ?string
{
if (null === $raw || !preg_match('/^(\d{1,2})-(\d{1,2})-(\d{4})$/', $raw, $m)) {
return null;
}
$day = (int) $m[1];
$month = (int) $m[2];
$year = (int) $m[3];
if (!checkdate($month, $day, $year)) {
return null;
}
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
/**
* Eclate une cellule "Numero CAS" sur ';' en liste de chaines non vides.
*
* @return list<string>
*/
private static function splitCas(?string $raw): array
{
if (null === $raw) {
return [];
}
$parts = array_map('trim', explode(';', $raw));
return array_values(array_filter($parts, static fn (string $v): bool => '' !== $v));
}
/**
* Valeur d'une cellule par index : trim, null si absente/vide.
*
* @param array<int, mixed> $row
*/
private static function val(array $row, ?int $col): ?string
{
if (null === $col) {
return null;
}
$v = trim((string) ($row[$col] ?? ''));
return '' === $v ? null : $v;
}
/**
* Normalise un libelle d'en-tete : minuscules, sans accents ni apostrophes,
* espaces compresses (pour un matching robuste).
*/
private static function normalize(string $s): string
{
$s = str_replace(['', "'"], ' ', $s);
$s = (string) iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
$s = mb_strtolower($s);
return trim((string) preg_replace('/\s+/', ' ', $s));
}
}
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Application\Service;
/**
* Normalisation serveur des champs texte d'un Carrier / CarrierContact, appliquee
* par le CarrierProcessor (et les futurs processors de sous-ressources, WT6/7/8)
* AVANT persistance. Cf. spec-back M4 § 2.10 + RG-4.13. Jumeau de
* SupplierFieldNormalizer (M2), enrichi du cas LIOT (immatriculations).
*
* - name : UPPERCASE integral (RG-4.13)
* - firstName / lastName (personnes, sur CarrierContact) : Title Case (RG-4.13)
* - phone* : chiffres uniquement, ex "06.12.34.56.78" -> "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);
}
}
@@ -0,0 +1,525 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierProcessor;
use App\Module\Transport\Infrastructure\ApiPlatform\State\Provider\CarrierProvider;
use App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Entity\UploadedDocument;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* Transporteur (M4 Transport) entite racine du repertoire transporteurs,
* jumelle de Supplier (M2) / Provider (M3). Porte le formulaire principal, le
* lien editable vers le referentiel QUALIMAT (§ 2.5), l'archivage
* (is_archived / archived_at) et le soft-delete technique prepare mais non
* expose au M4 (deleted_at).
*
* 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,
* qualimatCarrier (statut/validite RG-4.04), updatedAt.
* - DETAIL (+ carrier:item:read + embeds client/supplier/site...) : sous-collections
* addresses / contacts / prices embarquees, avec les entites cross-module
* (Client/Supplier/Site/adresses) serialisees via leurs read-groups.
*
* Pas de #[ORM\UniqueConstraint] : l'unicite du nom (RG-4.12) est portee par
* l'index partiel fonctionnel uq_carrier_name_active (LOWER(name) WHERE
* is_archived = FALSE AND deleted_at IS NULL), inexprimable en attribut ORM.
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('transport.carriers.view')",
// Liste : embarque qualimatCarrier (ManyToOne, fetch-join sur cette
// seule relation cote repository — § 2.11) pour le statut/date de
// validite QUALIMAT (RG-4.04). Aucune sous-collection en liste.
normalizationContext: ['groups' => ['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,
),
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)]
#[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;
/** 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]
#[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,
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', '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)]
#[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 — obligatoire si affrete (RG-4.03, validateMainFormConsistency). */
#[ORM\Column(name: 'indexation_rate', type: 'decimal', precision: 5, scale: 2, nullable: true)]
#[Groups(['carrier:read', 'carrier:write:main'])]
private ?string $indexationRate = null;
/** BENNE|FOND_MOUVANT — obligatoire si affrete (RG-4.03). */
#[ORM\Column(name: 'container_type', length: 12, nullable: true)]
#[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 — obligatoire si affrete (RG-4.03). */
#[ORM\Column(name: 'volume_m3', type: 'decimal', precision: 10, scale: 2, nullable: true)]
#[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', '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', 'carrier:write:main'])]
private ?string $liotPlates = null;
// === Sous-collections — EMBARQUEES dans le DETAIL (read-group sur le getter) ===
/** @var Collection<int, CarrierAddress> */
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $addresses;
/** @var Collection<int, CarrierContact> */
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contacts;
/** @var Collection<int, CarrierPrice> */
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierPrice::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
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)]
#[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();
}
/**
* 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;
}
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<int, CarrierAddress> */
#[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<int, CarrierContact> */
#[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<int, CarrierPrice> */
#[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;
}
}
@@ -0,0 +1,227 @@
<?php
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).
*
* 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'])]
#[ORM\Index(name: 'idx_carrier_address_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_carrier_address_updated_by', columns: ['updated_by'])]
#[Auditable]
class CarrierAddress implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['carrier:item:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Carrier::class, inversedBy: 'addresses')]
#[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Carrier $carrier = 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(['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)]
#[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)]
#[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)]
#[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)]
#[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])]
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;
}
}
@@ -0,0 +1,268 @@
<?php
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.
*
* 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'])]
#[ORM\Index(name: 'idx_carrier_contact_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_carrier_contact_updated_by', columns: ['updated_by'])]
#[Auditable]
class CarrierContact implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['carrier:item:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Carrier::class, inversedBy: 'contacts')]
#[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)]
#[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)]
#[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)]
#[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;
#[ORM\Column(name: 'phone_secondary', length: 20, nullable: true)]
#[Groups(['carrier:item:read'])]
private ?string $phoneSecondary = null;
#[ORM\Column(length: 180, nullable: true)]
#[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<string>
*/
#[Groups(['carrier:write:contacts'])]
private ?array $phones = 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 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;
}
/**
* @return null|list<string>
*/
public function getPhones(): ?array
{
return $this->phones;
}
/**
* @param null|list<string> $phones
*/
public function setPhones(?array $phones): static
{
$this->phones = $phones;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,369 @@
<?php
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;
use App\Shared\Domain\Contract\ClientInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SupplierAddressInterface;
use App\Shared\Domain\Contract\SupplierInterface;
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
* soit une branche CLIENT (client + adresse de livraison + site de depart), soit
* une branche FOURNISSEUR (supplier + adresse d'appro + site de livraison),
* selon `direction`. La coherence des branches est garantie en BDD par les CHECK
* chk_carrier_price_client_branch / chk_carrier_price_supplier_branch.
*
* Relations cross-module (Client/Supplier/adresses M1-M2, Site Sites) referencees
* via des contrats Shared (ClientInterface, SupplierInterface, ...) + resolve_target_entities
* JAMAIS d'import direct d'une entite d'un autre module (regle ABSOLUE n°1).
* L'embed JSON au detail passe par les read-groups des entites concretes
* (client:read / client_address:read / supplier:read / supplier_address:read /
* site:read), inclus dans le contexte du Get racine de Carrier (§ 4.0).
*
* 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'])]
#[ORM\Index(name: 'idx_carrier_price_client', columns: ['client_id'])]
#[ORM\Index(name: 'idx_carrier_price_client_address', columns: ['client_delivery_address_id'])]
#[ORM\Index(name: 'idx_carrier_price_departure_site', columns: ['departure_site_id'])]
#[ORM\Index(name: 'idx_carrier_price_supplier', columns: ['supplier_id'])]
#[ORM\Index(name: 'idx_carrier_price_supplier_address', columns: ['supplier_supply_address_id'])]
#[ORM\Index(name: 'idx_carrier_price_delivery_site', columns: ['delivery_site_id'])]
#[ORM\Index(name: 'idx_carrier_price_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_carrier_price_updated_by', columns: ['updated_by'])]
#[Auditable]
class CarrierPrice implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['carrier:item:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Carrier::class, inversedBy: 'prices')]
#[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Carrier $carrier = null;
/** CLIENT|FOURNISSEUR (RG-4.09) — pilote la branche active. */
#[ORM\Column(length: 12)]
#[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', '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', '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', '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', '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', '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', 'carrier:write:prices'])]
private ?SiteInterface $deliverySite = null;
// === Commun (toujours obligatoires, RG-4.10/4.11) ===
/** BENNE|FOND_MOUVANT. */
#[ORM\Column(name: 'container_type', length: 12)]
#[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)]
#[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)]
#[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)]
#[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])]
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;
}
}
@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Domain\Entity;
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;
use Symfony\Component\Serializer\Attribute\SerializedName;
/**
* Mapping ORM LECTURE SEULE sur la table existante `qualimat_carrier`
* (referentiel des transporteurs agrees QUALIMAT, ERP-39). La table est
* alimentee/soft-deletee EXCLUSIVEMENT par la commande console `app:qualimat:sync` ;
* cette entite n'expose donc AUCUNE ecriture (ni Post/Patch/Delete).
*
* Role M4 (ERP-155/157) :
* - 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?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
* (pas l'ORM). Lecture seule + referentiel synchronise => exclue de
* EntitiesAreTimestampableBlamableTest et non #[Auditable].
*/
#[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(
security: "is_granted('transport.carriers.view')",
normalizationContext: ['groups' => ['qualimat:read', 'default:read']],
),
],
)]
#[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;
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Domain\Repository;
use App\Module\Transport\Domain\Entity\Carrier;
use Doctrine\ORM\QueryBuilder;
/**
* Contrat du repository transporteurs (M4). Implementation Doctrine :
* App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository.
*/
interface CarrierRepositoryInterface
{
public function findById(int $id): ?Carrier;
public function save(Carrier $carrier): void;
/**
* QueryBuilder de SELECTION (filtres + tri) pour la liste. Exclut les
* soft-deletes (deleted_at IS NOT NULL) et, par defaut, les archives.
* 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<string> $certificationTypes filtre repetable (OR) sur certificationType
*/
public function createListQueryBuilder(
bool $includeArchived = false,
?string $search = null,
array $certificationTypes = [],
bool $archivedOnly = false,
): QueryBuilder;
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Domain\Repository;
use Doctrine\ORM\QueryBuilder;
/**
* Contrat du repository du referentiel QUALIMAT (M4, lecture seule). Implementation
* Doctrine : App\Module\Transport\Infrastructure\Doctrine\DoctrineQualimatCarrierRepository.
*
* La table `qualimat_carrier` est alimentee/soft-deletee EXCLUSIVEMENT par la
* commande console `app:qualimat:sync` : ce contrat n'expose que de la lecture.
*/
interface QualimatCarrierRepositoryInterface
{
/**
* QueryBuilder de la saisie assistee (§ 4.7 / RG-4.01). Restreint aux lignes
* actives (is_active = true), recherche fuzzy sur name (+ siret), tri name ASC.
*
* @param null|string $search texte de recherche libre (fuzzy name + siret)
*/
public function createSearchQueryBuilder(?string $search = null): QueryBuilder;
}
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Entity\CarrierAddress;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture de la sous-ressource Adresse d'un transporteur (M4,
* spec-back § 4.5). Jumeau du SupplierAddressProcessor (M2) / ProviderAddressProcessor
* (M3), recentre sur le perimetre ERP-159, AVEC une garde propre au M4 : RG-4.05
* (adresse obligatoire si le transporteur est affrete).
*
* Sequence :
* - POST / PATCH : rattachement au transporteur parent (linkParent) puis garde
* RG-4.05 (guardCharteredAddress). RG-4.06 (code postal, Assert\Regex) est portee
* par l'entite et jouee par API Platform AVANT ce processor.
* - DELETE : aucune regle metier specifique (suppression physique directe).
*
* RG-4.05 vit ICI (et non en Assert\Callback sur l'entite) car elle depend du
* transporteur PARENT, indisponible a la validation Symfony sur un POST
* sous-ressource en read:false (le parent n'est rattache qu'au stade processor).
* La violation est construite a la main avec le meme rendu Hydra que les
* contraintes Symfony, donc consommable inline par champ (convention ERP-101).
*
* RG-4.07 (masquage du bouton « Valider » si QUALIMAT) est purement front : le
* back accepte le PATCH normalement, aucune garde ici.
*
* La security d'operation (transport.carriers.manage) est appliquee par API
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut.
*
* @implements ProcessorInterface<CarrierAddress, null|CarrierAddress>
*/
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);
}
}
}
@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Transport\Application\Service\CarrierFieldNormalizer;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Entity\CarrierContact;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use function count;
use function is_string;
/**
* Processor d'ecriture de la sous-ressource Contact d'un transporteur (M4,
* spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le
* perimetre ERP-160, AVEC deux specificites M4 : RG-4.08 ( 1 champ rempli, max
* 2 telephones) portee a la fois par le CHECK BDD chk_carrier_contact_filled et
* par ce Processor.
*
* Sequence :
* - POST / PATCH : rattachement au transporteur parent (linkParent),
* normalisation serveur RG-4.13 (prenom/nom Title Case, email lowercase),
* mapping du tableau d'ecriture `phones` -> 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<CarrierContact, null|CarrierContact>
*/
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;
}
}
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Entity\CarrierPrice;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture de la sous-ressource Prix d'un transporteur (M4,
* spec-back § 4.5, ERP-161). Jumeau des CarrierAddressProcessor / CarrierContactProcessor.
*
* Sequence :
* - POST / PATCH : rattachement au transporteur parent (linkParent) puis
* validation de la coherence de branche CLIENT/FOURNISSEUR (RG-4.09→4.11).
* - DELETE : suppression physique directe (aucune regle metier specifique).
*
* RG-4.10 (branche CLIENT) : `client`, `clientDeliveryAddress`, `departureSite`
* obligatoires ; l'adresse de livraison doit appartenir au client choisi.
* RG-4.11 (branche FOURNISSEUR) : `supplier`, `supplierSupplyAddress`,
* `deliverySite` obligatoires ; l'adresse d'appro doit appartenir au fournisseur.
* Ces RG vivent ICI (et non en contrainte d'attribut) car elles dependent de
* relations resolues a la denormalisation (et le parent carrier est indisponible
* en validation Symfony sur un POST sous-ressource read:false). On nettoie aussi
* la branche opposee (les CHECK BDD imposent ses colonnes nulles) transforme une
* violation SQL (500) en 422 propre rattachee au champ (mapping inline ERP-101).
*
* Les champs communs obligatoires (direction, containerType, pricingUnit, price,
* priceState) sont valides en amont par les contraintes d'attribut (Assert\NotBlank
* + Assert\Choice), de meme que la security d'operation (transport.carriers.manage).
*
* @implements ProcessorInterface<CarrierPrice, null|CarrierPrice>
*/
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);
}
}
@@ -0,0 +1,275 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Transport\Application\Service\CarrierFieldNormalizer;
use App\Module\Transport\Domain\Entity\Carrier;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use JsonException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* Processor d'ecriture du repertoire transporteurs (M4, formulaire principal). Cf.
* spec-back M4 § 4.3 / § 4.4 + RG-4.12 / RG-4.13 / RG-4.14. Jumeau du
* SupplierProcessor (M2), recentre sur le perimetre WT4 (formulaire principal :
* normalisation, gating archive, 409 doublon de nom).
*
* Sequence (POST / PATCH) :
* 1. Gating archive (mode strict RG-4.14). La security d'operation laisse entrer
* `transport.carriers.manage` (Admin + Bureau) ; ce processor re-gate
* finement : un payload basculant `isArchived` exige `transport.carriers.archive`
* (Admin seul) -> 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<Carrier, Carrier>
*/
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<string> 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<string> $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<string>
*/
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<string, mixed>
*/
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<string>
*/
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<string>
*/
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')) : [];
}
}
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Repository\CarrierRepositoryInterface;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider du repertoire transporteurs (M4, spec-back § 4.1 / § 4.2). Jumeau du
* SupplierProvider (M2), simplifie : pas de cloisonnement par site (§ 2.3) et
* aucune sous-collection a hydrater en liste (le contrat liste n'embarque que
* qualimatCarrier, deja fetch-joine par le repository § 2.11).
*
* 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.
*
* Item (GET /api/carriers/{id}) : 404 si introuvable OU soft-delete. Les archives
* restent consultables en detail.
*
* @implements ProviderInterface<Carrier>
*/
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<string, mixed> $context
*
* @return list<Carrier>|Paginator<Carrier>
*/
private function provideCollection(Operation $operation, array $context): array|Paginator
{
$filters = $context['filters'] ?? [];
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
$archivedOnly = $this->readBool($filters['archivedOnly'] ?? false);
$search = $filters['search'] ?? null;
$certificationTypes = $this->readStringList($filters['certificationType'] ?? []);
$qb = $this->repository->createListQueryBuilder(
$includeArchived,
is_string($search) ? $search : null,
$certificationTypes,
$archivedOnly,
);
// Echappatoire ?pagination=false : collection complete (selects front).
if (!$this->pagination->isEnabled($operation, $context)) {
// @var list<Carrier> $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<string, mixed> $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<string>
*/
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;
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Transport\Domain\Entity\QualimatCarrier;
use App\Module\Transport\Domain\Repository\QualimatCarrierRepositoryInterface;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider de la saisie assistee QUALIMAT (spec-back § 4.7 / RG-4.01).
*
* GET /api/qualimat_carriers?search=<texte> :
* - 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<QualimatCarrier>
*/
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<QualimatCarrier>|Paginator<QualimatCarrier>
*/
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<QualimatCarrier> $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));
}
}
@@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\Console;
use App\Module\Transport\Application\Idtf\IdtfSheetParser;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use PhpOffice\PhpSpreadsheet\IOFactory;
use RuntimeException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
use function array_slice;
use function count;
use function in_array;
use const JSON_THROW_ON_ERROR;
use const JSON_UNESCAPED_UNICODE;
/**
* ERP-149 : synchronise le referentiel des codes IDTF (regimes de nettoyage
* transport).
*
* Recupere l'export Excel depuis le generateur icrt-idtf.com (ou un fichier
* local), le parse et synchronise `idtf_product` de facon transactionnelle :
* upsert sur (schema, idtf_number), soft-delete des absents, journal dans
* `idtf_sync_log`. Idempotente (refresh complet).
*/
#[AsCommand(
name: 'app:idtf:sync',
description: 'Synchronise le referentiel des codes IDTF depuis l\'export Excel icrt-idtf.com (upsert + soft-delete + journal).',
)]
final class SyncIdtfCommand extends Command
{
private const string GENERATOR_URL = 'https://www.icrt-idtf.com/fr/excel-generator/';
/**
* Champs a cocher explicitement : `fields[]=all` ne deplie PAS les colonnes
* cote serveur (6 colonnes seulement). Cette liste donne l'export complet
* (11 colonnes). Cf. ERP-149 § 1.
*
* @var list<string>
*/
private const array EXPORT_FIELDS = [
'product_number_idtf',
'product_name',
'minimum_cleaning_regime',
'important_requirements',
'date_mandatory',
'related_products',
'formula',
'product_number_eural',
'product_number_cas',
'footnotes',
];
public function __construct(
private readonly Connection $connection,
private readonly HttpClientInterface $httpClient,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('schema', null, InputOption::VALUE_REQUIRED, "Module IDTF : 'road' (routier) ou 'water' (fluvial).", 'road')
->addOption('file', null, InputOption::VALUE_REQUIRED, "Chemin d'un .xlsx local (court-circuite le telechargement, utile pour tests/rejeu).")
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Analyse sans ecriture en base.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$schema = (string) $input->getOption('schema');
$dryRun = (bool) $input->getOption('dry-run');
$file = $input->getOption('file');
if (!in_array($schema, ['road', 'water'], true)) {
$io->error("--schema doit valoir 'road' ou 'water'.");
return Command::INVALID;
}
// 1. Recuperation du binaire xlsx (local ou via POST).
try {
$xlsx = null !== $file ? $this->readLocal((string) $file) : $this->downloadExport($schema);
} catch (Throwable $e) {
$io->error('Telechargement/lecture impossible : '.$e->getMessage());
return Command::FAILURE;
}
// 2. Parsing (xlsx -> matrice -> lignes normalisees).
try {
$parsed = IdtfSheetParser::parse($this->toMatrix($xlsx));
} catch (Throwable $e) {
$io->error('Parsing impossible : '.$e->getMessage());
return Command::FAILURE;
}
$rows = $parsed['rows'];
$exportDate = $parsed['exportDate'] ?? new DateTimeImmutable()->format('Y-m-d');
$io->section(sprintf('IDTF %s — export du %s', mb_strtoupper($schema), $exportDate));
$io->writeln(sprintf('%d lignes exploitables lues.', count($rows)));
if ($dryRun) {
$this->renderPreview($io, $rows);
$io->note(sprintf('Dry-run : aucune ecriture. (%d lignes au total)', count($rows)));
return Command::SUCCESS;
}
// 3. Sync transactionnelle : upsert -> soft-delete -> journal.
$run = new DateTimeImmutable()->format('Y-m-d H:i:s.u');
$this->connection->beginTransaction();
try {
$upserted = $this->upsertAll($schema, $exportDate, $rows, $run);
$deactivated = $this->deactivateMissing($schema, $run);
$this->log($schema, $exportDate, count($rows), $upserted, $deactivated);
$this->connection->commit();
} catch (Throwable $e) {
$this->connection->rollBack();
$io->error('Sync annulee (rollback) : '.$e->getMessage());
return Command::FAILURE;
}
$io->success(sprintf('%d upsert, %d desactive(s).', $upserted, $deactivated));
return Command::SUCCESS;
}
/**
* Rejoue le POST du generateur pour recuperer le binaire xlsx complet.
* Le formulaire poste sur lui-meme ; pas besoin de GET/cookies prealables.
*/
private function downloadExport(string $schema): string
{
// Corps construit a la main : http-client encoderait fields[] en
// indices numerotes, on veut bien des "fields[]=..." repetes.
$pairs = [
'schema='.$schema,
'type%5B%5D='.$schema,
'roadRegime%5B%5D=all',
'waterRegime%5B%5D=all',
'groups%5B%5D=all',
'products%5B%5D=all',
];
foreach (self::EXPORT_FIELDS as $field) {
$pairs[] = 'fields%5B%5D='.$field;
}
$pairs[] = 'generateExcel=';
$response = $this->httpClient->request('POST', self::GENERATOR_URL, [
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
'body' => implode('&', $pairs),
'timeout' => 90,
]);
$content = $response->getContent();
$contentType = $response->getHeaders(false)['content-type'][0] ?? '';
// Garde-fou : un HTML signifie un POST rejete (filtres/payload).
if (!str_contains($contentType, 'spreadsheet') && !str_starts_with($content, "PK\x03\x04")) {
throw new RuntimeException(sprintf('Reponse non-xlsx (content-type: %s). Verifie le payload.', $contentType));
}
return $content;
}
private function readLocal(string $path): string
{
$raw = @file_get_contents($path);
if (false === $raw) {
throw new RuntimeException(sprintf('Fichier illisible : %s', $path));
}
return $raw;
}
/**
* Charge le binaire xlsx via PhpSpreadsheet et retourne la feuille active
* sous forme de matrice 0-indexee (lignes/colonnes).
*
* @return array<int, array<int, mixed>>
*/
private function toMatrix(string $xlsx): array
{
$tmp = tempnam(sys_get_temp_dir(), 'idtf_').'.xlsx';
file_put_contents($tmp, $xlsx);
try {
// toArray(null, true, true, false) : colonnes 0-indexees.
return IOFactory::load($tmp)->getActiveSheet()->toArray(null, true, true, false);
} finally {
@unlink($tmp);
}
}
/**
* Upsert de toutes les lignes (cle naturelle = schema + idtf_number).
*
* @param list<array<string, mixed>> $rows
*/
private function upsertAll(string $schema, string $exportDate, array $rows, string $run): int
{
$sql = <<<'SQL'
INSERT INTO idtf_product
(idtf_number, schema, product_group, name, cleaning_regime, important_requirements,
mandatory_date, related_products, formula, eural_code, cas_numbers, footnotes,
source_export_date, is_active, last_synced_at)
VALUES
(:idtf, :schema, :grp, :name, :regime, :req, :mdate, :related, :formula, :eural,
CAST(:cas AS JSONB), :foot, :export, TRUE, :run)
ON CONFLICT (schema, idtf_number) DO UPDATE SET
product_group = EXCLUDED.product_group,
name = EXCLUDED.name,
cleaning_regime = EXCLUDED.cleaning_regime,
important_requirements = EXCLUDED.important_requirements,
mandatory_date = EXCLUDED.mandatory_date,
related_products = EXCLUDED.related_products,
formula = EXCLUDED.formula,
eural_code = EXCLUDED.eural_code,
cas_numbers = EXCLUDED.cas_numbers,
footnotes = EXCLUDED.footnotes,
source_export_date = EXCLUDED.source_export_date,
is_active = TRUE,
last_synced_at = EXCLUDED.last_synced_at
SQL;
$count = 0;
foreach ($rows as $r) {
$this->connection->executeStatement($sql, [
'idtf' => $r['idtf_number'],
'schema' => $schema,
'grp' => $r['product_group'],
'name' => $r['name'],
'regime' => $r['cleaning_regime'],
'req' => $r['important_requirements'],
'mdate' => $r['mandatory_date'],
'related' => $r['related_products'],
'formula' => $r['formula'],
'eural' => $r['eural_code'],
'cas' => json_encode($r['cas_numbers'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'foot' => $r['footnotes'],
'export' => $exportDate,
'run' => $run,
]);
++$count;
}
return $count;
}
/**
* Soft-delete : toute ligne du schema active non revue par ce run passe a
* is_active=false.
*/
private function deactivateMissing(string $schema, string $run): int
{
return (int) $this->connection->executeStatement(
'UPDATE idtf_product SET is_active = FALSE WHERE schema = :schema AND is_active = TRUE AND last_synced_at < :run',
['schema' => $schema, 'run' => $run],
);
}
private function log(string $schema, string $exportDate, int $total, int $upserted, int $deactivated): void
{
$this->connection->executeStatement(
<<<'SQL'
INSERT INTO idtf_sync_log (schema, export_date, rows_total, rows_upserted, rows_deactivated)
VALUES (:schema, :export, :total, :upserted, :deactivated)
SQL,
[
'schema' => $schema,
'export' => $exportDate,
'total' => $total,
'upserted' => $upserted,
'deactivated' => $deactivated,
],
);
}
/**
* @param list<array<string, mixed>> $rows
*/
private function renderPreview(SymfonyStyle $io, array $rows): void
{
$io->table(
['IDTF', 'Nom', 'Regime', 'CAS'],
array_map(static fn (array $r): array => [
(string) $r['idtf_number'],
mb_strimwidth((string) $r['name'], 0, 50, '…'),
(string) $r['cleaning_regime'],
implode(', ', $r['cas_numbers']),
], array_slice($rows, 0, 15)),
);
}
}
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\Controller;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Repository\CarrierRepositoryInterface;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use DateTimeImmutable;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Export XLSX du repertoire transporteurs (M4, spec-back § 4.6). Jumeau des
* controllers d'export SupplierExportController (M2) / ProviderExportController
* (M3) references en prose volontairement (pas de {@see} : un import
* inter-module violerait la regle ABSOLUE n°1). Simplifie : pas de cloisonnement
* par site (§ 2.3) ni de colonne gatee par une permission comptable.
*
* Controller Symfony custom (et non operation API Platform) car il produit un
* binaire de fichier, pas une representation Hydra. `priority: 1` est OBLIGATOIRE
* sur la route : sans cela API Platform capterait `/api/carriers/export.xlsx`
* comme l'item `GET /api/carriers/{id}.{_format}` (id="export", _format="xlsx")
* cf. CLAUDE.md « controller custom sous /api ».
*
* Separation des responsabilites :
* - le COMMENT (generation du fichier) est delegue au service Shared
* {@see SpreadsheetExporterInterface} generique, reutilisable, sans metier ;
* - le QUOI vit ICI : selection des transporteurs (MEMES filtres que
* `GET /api/carriers`, via {@see CarrierRepositoryInterface::createListQueryBuilder()}
* l'export reflete exactement ce que l'utilisateur voit a l'ecran) et mapping
* metier des colonnes.
*/
#[AsController]
final class CarrierExportController
{
public function __construct(
#[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository')]
private readonly CarrierRepositoryInterface $repository,
private readonly SpreadsheetExporterInterface $exporter,
) {}
#[Route('/api/carriers/export.xlsx', name: 'transport_carriers_export_xlsx', methods: ['GET'], priority: 1)]
#[IsGranted('transport.carriers.view')]
public function __invoke(Request $request): Response
{
// 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<Carrier> $carriers */
$carriers = $this->repository
->createListQueryBuilder($includeArchived, $search, $certificationTypes, $archivedOnly)
->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<string>
*/
private function buildHeaders(): array
{
return [
'Nom',
'Certification',
'Statut QUALIMAT',
'Date de validité',
'Affrété',
'Volume m³',
'Date de création',
];
}
/**
* @param list<Carrier> $carriers
*
* @return iterable<list<null|scalar>>
*/
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<string>
*/
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;
}
}
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\Controller;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Entity\CarrierPrice;
use App\Module\Transport\Domain\Repository\CarrierRepositoryInterface;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use DateTimeImmutable;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Export XLSX du tableau Prix d'un transporteur (M4, spec-back § 4.6 / spec-front
* § « Onglet Prix »). Reproduit le tableau de consultation regroupe par type de
* contenant (Fond Mouvant / Benne colonnes du docx p.10).
*
* Controller Symfony custom (binaire de fichier, pas une representation Hydra).
* `priority: 1` est OBLIGATOIRE : sans cela API Platform capterait
* `/api/carriers/{id}/prices/export.xlsx` via ses routes generiques.
*
* Separation des responsabilites : le COMMENT (generation) est delegue au service
* Shared {@see SpreadsheetExporterInterface} ; le QUOI (chargement du transporteur,
* regroupement par contenant, mapping metier des colonnes) vit ICI.
*
* Adresses cross-module : les contrats Shared (ClientInterface / SupplierInterface
* / SiteInterface) exposent volontairement le minimum (regle ABSOLUE n°1). Faute
* d'acceder au detail postal d'une adresse Client/Fournisseur sans coupler au
* module Commercial, les colonnes d'adresse identifient le point par le libelle
* disponible : nom du site pour un Site, raison sociale du client/fournisseur pour
* une adresse de livraison/approvisionnement.
*/
#[AsController]
final class CarrierPriceExportController
{
/** Libelles d'affichage des enums (spec-front « Onglet Prix »). */
private const array CONTAINER_LABELS = ['BENNE' => '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<string>
*/
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<list<null|scalar>>
*/
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;
}
}
@@ -0,0 +1,314 @@
<?php
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/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).
*
* 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.
*/
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<int, class-string>
*/
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
{
// Donnees de demo : dev uniquement. En test, on laisse la table vierge.
if ('test' === $this->environment) {
return;
}
// === 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 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]);
}
}
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\Doctrine;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Repository\CarrierRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Carrier>
*/
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 = [],
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
// 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.
// 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');
}
$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<string> $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)
;
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\Doctrine;
use App\Module\Transport\Domain\Entity\QualimatCarrier;
use App\Module\Transport\Domain\Repository\QualimatCarrierRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<QualimatCarrier>
*/
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)
;
}
}
+11 -6
View File
@@ -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<int, array{code: string, label: string}>
*/
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'],
];
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat minimal d'une adresse de Client (M1 Commercial) exposable a un autre
* module sans couplage direct (regle ABSOLUE n°1). Mappe vers
* App\Module\Commercial\Domain\Entity\ClientAddress via `resolve_target_entities`.
*
* Implemente par App\Module\Commercial\Domain\Entity\ClientAddress. Utilise
* comme type-hint des relations ORM cross-module (ex: CarrierPrice.clientDeliveryAddress,
* M4). La serialisation passe par le read-group de l'entite concrete
* (client_address:read), pas par cette interface.
*/
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;
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat minimal exposant ce qu'un autre module doit connaitre d'un Client
* (M1 Commercial) sans creer de couplage direct vers le module Commercial
* (regle ABSOLUE n°1). Mappe vers App\Module\Commercial\Domain\Entity\Client
* via `resolve_target_entities` (doctrine.yaml).
*
* Implemente par App\Module\Commercial\Domain\Entity\Client. Utilise comme
* type-hint dans les relations ORM cross-module (ex: CarrierPrice.client, M4).
* La serialisation passe par les read-groups de l'entite concrete (client:read),
* pas par cette interface.
*/
interface ClientInterface
{
public function getId(): ?int;
public function getCompanyName(): ?string;
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat minimal d'une adresse de Supplier (M2 Commercial) exposable a un autre
* module sans couplage direct (regle ABSOLUE n°1). Mappe vers
* App\Module\Commercial\Domain\Entity\SupplierAddress via `resolve_target_entities`.
*
* Implemente par App\Module\Commercial\Domain\Entity\SupplierAddress. Utilise
* comme type-hint des relations ORM cross-module (ex: CarrierPrice.supplierSupplyAddress,
* M4). La serialisation passe par le read-group de l'entite concrete
* (supplier_address:read), pas par cette interface.
*/
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;
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat minimal exposant ce qu'un autre module doit connaitre d'un Supplier
* (M2 Commercial) sans creer de couplage direct vers le module Commercial
* (regle ABSOLUE n°1). Mappe vers App\Module\Commercial\Domain\Entity\Supplier
* via `resolve_target_entities` (doctrine.yaml).
*
* Implemente par App\Module\Commercial\Domain\Entity\Supplier. Utilise comme
* type-hint dans les relations ORM cross-module (ex: CarrierPrice.supplier, M4).
* La serialisation passe par les read-groups de l'entite concrete (supplier:read),
* pas par cette interface.
*/
interface SupplierInterface
{
public function getId(): ?int;
public function getCompanyName(): ?string;
}
@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Post;
use App\Shared\Infrastructure\ApiPlatform\State\UploadedDocumentProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Reference technique d'un fichier televerse (infra generique Shared, ERP-154).
*
* Entite IMMUABLE : un document n'est jamais modifie apres creation (pas d'onglet
* edition cote front). Elle ne porte donc QUE `created_at` / `created_by` pas
* la paire `updated_*` et n'implemente volontairement pas Timestampable /
* Blamable (qui imposeraient les 4 colonnes). `created_at` est rempli par le
* FileUploader via l'horloge injectee ; `created_by` est positionne par le
* processor depuis l'utilisateur authentifie (null hors HTTP).
*
* Pas de `#[Auditable]` : c'est un enregistrement d'infrastructure (et non un
* agregat metier edite), sa tracabilite est portee par created_at / created_by.
*
* Operations API :
* - Post (/uploaded_documents, multipart) : `deserialize: false` le binaire
* n'est pas deserialise dans l'entite, le UploadedDocumentProcessor lit le
* fichier de la requete, delegue au FileUploader (validation MIME server-side,
* bornage taille, checksum, ecriture disque) puis persiste. MIME hors
* whitelist -> 422.
* - Get (/uploaded_documents/{id}) : necessaire pour qu'API Platform genere
* l'IRI renvoyee par le Post. Protege par IS_AUTHENTICATED_FULLY uniquement
* (pas de RBAC ni de cloisonnement tenant ici) : cette ressource est une
* infra GENERIQUE qui ne porte aucune notion de proprietaire metier. Le
* cloisonnement d'acces (qui peut voir quel document) est volontairement
* delegue au module CONSOMMATEUR (ex: la Decharge M4), qui exposera le
* document via sa propre ressource cloisonnee plutot que via cet endpoint
* technique. Ne renvoie que des metadonnees (jamais le binaire).
*
* Pas de GetCollection exposee (non requise) la regle de pagination ne
* s'applique donc pas ici.
*/
#[ORM\Entity]
#[ORM\Table(name: 'uploaded_document')]
#[ApiResource(
operations: [
new Get(
security: "is_granted('IS_AUTHENTICATED_FULLY')",
),
new Post(
// Entree multipart : le binaire arrive en multipart/form-data sous
// le champ « file ». Sans cet inputFormats, API Platform rejette la
// requete en 415.
inputFormats: ['multipart' => ['multipart/form-data']],
// Le fichier n'est pas deserialisable dans l'entite : le processor
// lit le binaire de la requete. La validation est portee par le
// FileUploader (MIME server-side, taille), pas par les contraintes.
deserialize: false,
validate: false,
security: "is_granted('IS_AUTHENTICATED_FULLY')",
processor: UploadedDocumentProcessor::class,
),
],
normalizationContext: ['groups' => ['uploaded_document:read']],
)]
class UploadedDocument
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['uploaded_document:read'])]
private ?int $id = null;
#[ORM\Column(name: 'original_filename', length: 255)]
#[Groups(['uploaded_document:read'])]
private string $originalFilename;
#[ORM\Column(name: 'stored_path', length: 512)]
#[Groups(['uploaded_document:read'])]
private string $storedPath;
#[ORM\Column(name: 'mime_type', length: 100)]
#[Groups(['uploaded_document:read'])]
private string $mimeType;
#[ORM\Column(name: 'size_bytes', type: 'integer')]
#[Groups(['uploaded_document:read'])]
private int $sizeBytes;
#[ORM\Column(name: 'checksum', length: 64)]
#[Groups(['uploaded_document:read'])]
private string $checksum;
#[ORM\Column(name: 'created_at', type: 'datetime_immutable')]
#[Groups(['uploaded_document:read'])]
private DateTimeImmutable $createdAt;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'created_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?UserInterface $createdBy = null;
public function __construct(
string $originalFilename,
string $storedPath,
string $mimeType,
int $sizeBytes,
string $checksum,
DateTimeImmutable $createdAt,
) {
$this->originalFilename = $originalFilename;
$this->storedPath = $storedPath;
$this->mimeType = $mimeType;
$this->sizeBytes = $sizeBytes;
$this->checksum = $checksum;
$this->createdAt = $createdAt;
}
public function getId(): ?int
{
return $this->id;
}
public function getOriginalFilename(): string
{
return $this->originalFilename;
}
public function getStoredPath(): string
{
return $this->storedPath;
}
public function getMimeType(): string
{
return $this->mimeType;
}
public function getSizeBytes(): int
{
return $this->sizeBytes;
}
public function getChecksum(): string
{
return $this->checksum;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getCreatedBy(): ?UserInterface
{
return $this->createdBy;
}
public function setCreatedBy(?UserInterface $user): void
{
$this->createdBy = $user;
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Exception;
/**
* Levee quand le fichier televerse depasse la taille maximale autorisee
* (FileUploader::MAX_SIZE_BYTES). Traduite en HTTP 422 par le processor.
*/
final class FileTooLargeException extends FileUploadException
{
public function __construct(int $size, int $maxSize)
{
parent::__construct(sprintf(
'Le fichier (%d octets) dépasse la taille maximale autorisée (%d octets).',
$size,
$maxSize,
));
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Exception;
use RuntimeException;
/**
* Exception de base des erreurs de televersement (FileUploader).
*
* Decouplee de HTTP : le service Shared\Infrastructure\Upload\FileUploader leve
* une de ces exceptions metier, et c'est la couche API (UploadedDocumentProcessor)
* qui la traduit en reponse HTTP 422.
*/
class FileUploadException extends RuntimeException {}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Exception;
/**
* Levee quand le type MIME detecte server-side n'appartient pas a la whitelist
* du FileUploader (PDF + images). Traduite en HTTP 422 par le processor.
*/
final class UnsupportedMimeTypeException extends FileUploadException
{
/**
* @param list<string> $allowed Types MIME autorises
*/
public function __construct(string $mimeType, array $allowed)
{
parent::__construct(sprintf(
'Le type de fichier « %s » n\'est pas autorisé. Types acceptés : %s.',
$mimeType,
implode(', ', $allowed),
));
}
}
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Shared\Domain\Exception\FileUploadException;
use App\Shared\Infrastructure\Upload\FileUploader;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Security\Core\User\UserInterface;
use Throwable;
/**
* Processor d'ecriture de l'upload generique (POST /api/uploaded_documents).
*
* L'operation Post est en `deserialize: false` : le binaire n'est pas mappe sur
* l'entite. Ce processor lit le fichier multipart de la requete (champ « file »),
* delegue au FileUploader (validation MIME server-side, bornage taille, checksum,
* ecriture disque), positionne l'auteur (created_by) puis persiste via le
* processor Doctrine standard. Le retour est l'entite, qu'API Platform serialise
* en JSON-LD (avec son @id / IRI).
*
* Mapping des erreurs :
* - fichier absent -> 422 ;
* - MIME hors whitelist / fichier trop volumineux (FileUploadException) -> 422.
*
* Si la persistance Doctrine echoue APRES l'ecriture disque, le fichier physique
* deja deplace est supprime (compensation) pour ne pas laisser de binaire orphelin.
*
* @implements ProcessorInterface<mixed, mixed>
*/
final class UploadedDocumentProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly FileUploader $fileUploader,
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
$request = $this->requestStack->getCurrentRequest();
$file = $request?->files->get('file');
if (!$file instanceof UploadedFile) {
throw new UnprocessableEntityHttpException('Aucun fichier fourni (champ « file » attendu).');
}
try {
$document = $this->fileUploader->upload($file);
} catch (FileUploadException $e) {
// MIME hors whitelist ou fichier trop volumineux -> 422 avec le
// message metier explicite porte par l'exception.
throw new UnprocessableEntityHttpException($e->getMessage(), $e);
}
$user = $this->security->getUser();
if ($user instanceof UserInterface) {
$document->setCreatedBy($user);
}
try {
return $this->persistProcessor->process($document, $operation, $uriVariables, $context);
} catch (Throwable $e) {
// La persistance a echoue APRES l'ecriture disque (erreur DB, FK...) :
// on supprime le fichier orphelin pour ne pas le laisser sans ligne
// uploaded_document correspondante, puis on relaie l'erreur.
$this->fileUploader->remove($document);
throw $e;
}
}
}
@@ -36,6 +36,18 @@ final class ColumnCommentsCatalog
public static function comments(): array
{
return [
'uploaded_document' => [
'_table' => 'Fichiers televerses (infra generique Shared, ERP-154) — documents immuables (PDF / images), 1er consommateur la Decharge M4.',
'id' => 'Identifiant interne auto-incremente.',
'original_filename' => 'Nom de fichier d origine fourni par le client (≤ 255) — metadonnee d affichage uniquement, jamais utilise pour le stockage disque.',
'stored_path' => 'Chemin relatif du fichier sous var/uploads (ex: 2026/06/<hash>.pdf) — nom genere aleatoirement, jamais le nom client.',
'mime_type' => 'Type MIME detecte SERVER-SIDE via getMimeType (jamais getClientMimeType, spoofable) — borne a la whitelist FileUploader (PDF + images).',
'size_bytes' => 'Taille du fichier en octets — bornee par FileUploader::MAX_SIZE_BYTES.',
'checksum' => 'Empreinte SHA-256 du contenu (64 caracteres hex) — controle d integrite + deduplication eventuelle (hors scope).',
'created_at' => 'Horodatage UTC du televersement — rempli par FileUploader via l horloge injectee (pas via TimestampableBlamableSubscriber).',
'created_by' => 'ID de l utilisateur ayant televerse le fichier — null hors HTTP (CLI, fixture). FK -> "user".id, ON DELETE SET NULL.',
],
'audit_log' => [
'_table' => "Journal d'audit append-only — trace toutes les modifications BDD sur entites annotees #[Auditable]. Lecture seule via API.",
'id' => "UUID v7 — identifiant de la ligne d'audit (genere en PHP, ordre temporel garanti).",
@@ -446,6 +458,86 @@ final class ColumnCommentsCatalog
'iban' => '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(),
];
}
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Upload;
use App\Shared\Domain\Entity\UploadedDocument;
use App\Shared\Domain\Exception\FileTooLargeException;
use App\Shared\Domain\Exception\FileUploadException;
use App\Shared\Domain\Exception\UnsupportedMimeTypeException;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* Service generique de televersement de fichiers (infra Shared, ERP-154).
*
* Responsabilites :
* - Validation du type MIME SERVER-SIDE via `getMimeType()` (detection finfo
* sur le contenu reel) JAMAIS `getClientMimeType()`, spoofable par le
* client (regle backend.md « Upload de fichiers »).
* - Whitelist MIME explicite (PDF + images courantes).
* - Bornage de la taille (MAX_SIZE_BYTES).
* - Calcul du checksum sha256 (controle d integrite) AVANT le deplacement.
* - Ecriture disque sous `var/uploads/{yyyy}/{mm}/` avec un nom genere
* aleatoirement (jamais le nom client, qui reste une simple metadonnee).
*
* Le service est volontairement decouple de HTTP au-dela du type UploadedFile :
* il leve des exceptions metier (FileUploadException), traduites en 422 par le
* UploadedDocumentProcessor.
*/
final class FileUploader
{
/**
* Types MIME autorises (detectes server-side) : PDF + images courantes.
*
* @var list<string>
*/
public const ALLOWED_MIME_TYPES = [
'application/pdf',
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
];
/**
* Taille maximale autorisee : 10 Mo.
*/
public const MAX_SIZE_BYTES = 10 * 1024 * 1024;
public function __construct(
// Racine de stockage des fichiers televerses (hors web root, sous var/).
#[Autowire('%kernel.project_dir%/var/uploads')]
private readonly string $uploadBaseDir,
private readonly ClockInterface $clock,
) {}
/**
* Valide, calcule l empreinte, deplace le fichier sur disque et retourne
* un UploadedDocument NON persiste (le caller le persiste).
*
* @throws UnsupportedMimeTypeException si le MIME server-side est hors whitelist
* @throws FileTooLargeException si le fichier depasse MAX_SIZE_BYTES
*/
public function upload(UploadedFile $file): UploadedDocument
{
// Detection MIME server-side (finfo sur le contenu) — jamais le MIME
// declare par le client.
$mimeType = $file->getMimeType() ?? 'application/octet-stream';
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
throw new UnsupportedMimeTypeException($mimeType, self::ALLOWED_MIME_TYPES);
}
// getSize() peut renvoyer false si le fichier est illisible.
$size = $file->getSize();
if (false === $size || $size > self::MAX_SIZE_BYTES) {
throw new FileTooLargeException(false === $size ? 0 : $size, self::MAX_SIZE_BYTES);
}
// Checksum AVANT le move : le chemin du fichier change apres deplacement.
// hash_file renvoie false si le fichier temporaire est illisible (I/O) :
// on echoue proprement plutot que de propager un TypeError opaque au
// constructeur (parametre $checksum type string).
$checksum = hash_file('sha256', $file->getPathname());
if (false === $checksum) {
throw new FileUploadException('Impossible de lire le fichier televerse pour en calculer l\'empreinte.');
}
$now = $this->clock->now();
$relativeDir = $now->format('Y').'/'.$now->format('m');
$targetDir = $this->uploadBaseDir.'/'.$relativeDir;
// Nom de stockage genere aleatoirement : evite les collisions et toute
// injection via le nom client. Extension deduite du MIME.
$extension = $file->guessExtension() ?: 'bin';
$storedName = bin2hex(random_bytes(16)).'.'.$extension;
// Le nom d origine est conserve uniquement comme metadonnee d affichage,
// borne a la longueur de colonne (255).
$originalFilename = mb_substr($file->getClientOriginalName(), 0, 255);
$file->move($targetDir, $storedName);
return new UploadedDocument(
originalFilename: $originalFilename,
storedPath: $relativeDir.'/'.$storedName,
mimeType: $mimeType,
sizeBytes: $size,
checksum: $checksum,
createdAt: $now,
);
}
/**
* Supprime le fichier physique d'un document (compensation lorsque la
* persistance echoue APRES l'ecriture disque). Best-effort : silencieux si
* le fichier a deja disparu. Evite d'accumuler des binaires orphelins non
* references en base.
*/
public function remove(UploadedDocument $document): void
{
$path = $this->uploadBaseDir.'/'.$document->getStoredPath();
if (is_file($path)) {
@unlink($path);
}
}
}
@@ -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
@@ -56,8 +56,19 @@ 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).
'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.',
];
@@ -174,6 +185,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
foreach ($constraints as $c) {
if ($c instanceof Assert\Length) {
$length = $c->max;
break;
}
}
@@ -245,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<string>|null
* @return null|list<string>
*/
private function messagePropertiesFor(Constraint $constraint): ?array
{
@@ -0,0 +1,282 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
use App\Module\Commercial\Domain\Entity\ClientAddress;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Commercial\Domain\Entity\SupplierAddress;
use App\Module\Sites\Domain\Entity\Site;
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\Tests\Module\Core\Api\AbstractApiTestCase;
use DateTimeImmutable;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* Base des tests fonctionnels du repertoire transporteurs (M4). Apporte les
* factories de seed direct (sans passer par l'API : le flux d'ecriture arrive
* au WT4) pour les tests de lecture / serialisation / contrat (DoD § 4.0.bis).
*
* Donnees (RETEX M1) : chaque test seede ses transporteurs ; le tearDown les
* purge (cascade BDD sur les sous-collections) ainsi que les lignes
* qualimat_carrier de test (prefixe SIRET dedie).
*
* @internal
*/
abstract class AbstractCarrierApiTestCase extends AbstractApiTestCase
{
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';
/** Prefixe des Client/Supplier de test (cross-module Prix) — purge ciblee. */
private const string TEST_REF_PREFIX = 'TESTCARRIERREF';
protected function tearDown(): void
{
$em = $this->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');
}
/**
* Garde-fou ERP-101 : verifie qu'une reponse 422 porte une violation sur le
* `propertyPath` attendu (et pas seulement le bon code HTTP). Sans cette
* assertion, une 422 venue d'une AUTRE cause (autre champ manquant, IRI 404)
* ferait passer le test au vert sans prouver le mapping inline par champ.
*
* Mutualise dans la base (au lieu d'un duplicata par fichier) pour que toute
* la stack d'ecriture (formulaire principal + sous-ressources) l'utilise.
*/
protected static function assertViolationOnPath(object $response, string $path): void
{
/** @var ResponseInterface $response */
$paths = array_column($response->toArray(false)['violations'] ?? [], 'propertyPath');
self::assertContains(
$path,
$paths,
sprintf('Aucune violation sur "%s" (paths: %s).', $path, implode(', ', $paths)),
);
}
/**
* 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<string, mixed>
*/
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.
*/
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;
}
}
@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Entity\CarrierAddress;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* Sous-ressource Adresse d'un transporteur (spec-back M4 § 4.5, ERP-159).
* POST /api/carriers/{id}/addresses, PATCH/DELETE /api/carrier_addresses/{id}.
*
* Contrat verifie :
* - RG-4.06 : code postal hors ^[0-9]{4,5}$ -> 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();
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => ['postalCode' => '123'], // 3 chiffres -> Regex KO
]);
self::assertResponseStatusCodeSame(422);
// La 422 doit cibler le champ fautif (mapping inline ERP-101), pas juste le code HTTP.
self::assertViolationOnPath($response, 'postalCode');
}
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
// ville + rue manquantes -> 422 conditionnelle (CarrierAddressProcessor).
$carrier = $this->seedCarrierWithChartered('Affrete Incomplet', true);
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => ['postalCode' => '86000'],
]);
self::assertResponseStatusCodeSame(422);
// RG-4.05 mappe une violation PAR champ manquant (ville + rue ici) -> chaque
// erreur s'affiche inline sous son champ (ERP-101).
self::assertViolationOnPath($response, 'city');
self::assertViolationOnPath($response, 'street');
}
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 testPostAddressOnUnknownCarrierReturns404(): void
{
// Sous-ressource en read:false : le parent introuvable n'est plus intercepte
// en amont -> le processor doit lever un 404 explicite (sinon 500 au persist).
$client = $this->createAdminClient();
$client->request('POST', '/api/carriers/999999/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => ['postalCode' => '86000', 'city' => 'Poitiers', 'street' => '1 rue X'],
]);
self::assertResponseStatusCodeSame(404);
}
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;
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
/**
* Archivage / restauration transporteur trou 409 de restauration en conflit
* d'unicite (M4, RG-4.14). Le nominal (archive pose archivedAt) et le 422
* « archive + autre champ » sont couverts par CarrierWriteApiTest. Jumeau de
* SupplierArchiveTest (M2).
*
* @internal
*/
final class CarrierArchiveTest extends AbstractCarrierApiTestCase
{
/**
* RG-4.14 : restaurer un transporteur archive dont le nom a ete repris par un
* transporteur actif entre-temps doit echouer en 409 (index partiel
* uq_carrier_name_active : un seul actif portant ce nom).
*/
public function testRestoreConflictReturns409(): void
{
$client = $this->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);
}
}
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
use Doctrine\DBAL\Connection;
/**
* Tests Audit du repertoire transporteurs (M4, spec § 6). Couvre :
* - POST / PATCH / archivage -> 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<string, mixed> $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],
);
}
}
@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use App\Module\Transport\Domain\Entity\CarrierContact;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* Sous-ressource Contact d'un transporteur (spec-back M4 § 4.5, ERP-160).
* POST /api/carriers/{id}/contacts, PATCH/DELETE /api/carrier_contacts/{id}.
*
* Contrat verifie :
* - RG-4.08 : contact totalement vide -> 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();
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => [],
]);
self::assertResponseStatusCodeSame(422);
// RG-4.08 : la violation est rattachee a `firstName` (mapping inline ERP-101).
self::assertViolationOnPath($response, 'firstName');
}
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();
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'firstName' => 'Jean',
'phones' => ['0611111111', '0622222222', '0633333333'],
],
]);
self::assertResponseStatusCodeSame(422);
// Le max-2 cible le champ virtuel `phones` (mapping inline ERP-101).
self::assertViolationOnPath($response, 'phones');
}
public function testInvalidEmailReturns422(): void
{
// L'email du contact porte un Assert\Email (nouvelle contrainte M4) : une
// adresse mal formee -> 422 ciblee sur `email`.
$carrier = $this->seedCarrier('Contact Email Invalide');
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => ['lastName' => 'Durand', 'email' => 'pas-un-email'],
]);
self::assertResponseStatusCodeSame(422);
self::assertViolationOnPath($response, 'email');
}
public function testPostContactOnUnknownCarrierReturns404(): void
{
// Parent introuvable (read:false) -> 404 explicite du processor.
$client = $this->createAdminClient();
$client->request('POST', '/api/carriers/999999/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => ['lastName' => 'Martin'],
]);
self::assertResponseStatusCodeSame(404);
}
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;
}
}
@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* Tests fonctionnels de l'export XLSX du repertoire transporteurs (M4, § 4.6).
* Jumeau du {@see \App\Tests\Module\Commercial\Api\SupplierExportControllerTest}.
*
* Couvre : reponse 200 (Content-Type + Content-Disposition + en-tetes), exclusion
* des archives par defaut, respect du filtre ?search, peuplement des colonnes
* QUALIMAT (statut + date de validite, RG-4.04), 403 sans transport.carriers.view,
* 401 anonyme.
*
* @internal
*/
final class CarrierExportControllerTest extends AbstractCarrierApiTestCase
{
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
private const string EXPORT_URL = '/api/carriers/export.xlsx';
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
{
$client = $this->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<int, array<int, mixed>>
*/
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<string>
*/
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<int, array<int, mixed>> $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,
));
}
}
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
/**
* Tests fonctionnels de la liste transporteurs (M4, spec § 4.1 + RG-4.14 + regle
* ABSOLUE n°13) : tri name ASC, echappatoire ?pagination=false (selects), et
* ANTI N+1 (le nombre de requetes SQL de la liste ne croit pas avec le nombre de
* lignes fetch-join qualimatCarrier batche, § 2.11). L'exclusion des archives
* et la forme de l'enveloppe Hydra sont couvertes par
* {@see CarrierSerializationContractTest::testCollectionEnvelopeShapeAndArchivedExcluded}.
*
* @internal
*/
final class CarrierListTest extends AbstractCarrierApiTestCase
{
public function testListIsSortedByNameAsc(): void
{
$http = $this->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 <select> (regle n°13).
$data = $http->request('GET', '/api/carriers?search='.$token.'&pagination=false', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('member', $data);
self::assertCount(3, $data['member']);
}
public function testAnonymousRequestReturns401(): void
{
// La collection est gatee par is_granted('transport.carriers.view') : un appel
// NON authentifie doit recevoir 401 (spec § 4.1 liste 401 ET 403 ; jusqu'ici
// seuls les exports couvraient le 401).
$http = self::createClient();
$http->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(401);
}
public function testCertificationTypeFilterRestrictsResults(): void
{
// Filtre ?certificationType= (repetable, livre cote repo/provider mais
// jusqu'ici non exerce en collection) : seul le transporteur OVOCOM remonte.
$http = $this->createAdminClient();
$token = $this->token();
$this->seedCarrier($token.' Gmp'); // GMP_PLUS (defaut seedCarrier)
$ovocom = $this->seedCarrier($token.' Ovo');
$ovocom->setCertificationType('OVOCOM');
$this->getEm()->flush();
$data = $http->request(
'GET',
'/api/carriers?search='.$token.'&certificationType=OVOCOM',
['headers' => ['Accept' => self::LD]],
)->toArray();
self::assertCount(1, $data['member'], 'Seul le transporteur OVOCOM doit remonter.');
self::assertStringContainsString('OVO', (string) $data['member'][0]['name']);
}
/**
* Anti N+1 (§ 2.11) : le nombre de requetes SQL de la liste ne doit PAS croitre
* avec le nombre de transporteurs. On mesure pour N=2 puis N=4 (chacun avec son
* lien QUALIMAT embarque) et on exige un compte IDENTIQUE preuve que le
* fetch-join `qualimatCarrier` est batche et non par ligne.
*/
public function testListQueryCountDoesNotGrowWithRowCount(): void
{
$this->skipIfSitesModuleDisabled();
$token = $this->token();
// Premiere mesure : 2 transporteurs complets (lien QUALIMAT embarque en liste).
$this->seedCompleteCarrier($token.' A');
$this->seedCompleteCarrier($token.' B');
$countFor2 = $this->countListQueries($token);
// Seconde mesure : 2 de plus (4 au total, tous sur la meme page).
$this->seedCompleteCarrier($token.' C');
$this->seedCompleteCarrier($token.' D');
$countFor4 = $this->countListQueries($token);
self::assertSame(
$countFor2,
$countFor4,
sprintf('Anti N+1 : le nombre de requetes liste doit etre constant (%d pour 2, %d pour 4).', $countFor2, $countFor4),
);
}
/**
* Compte les requetes SQL emises par UN GET liste filtre, via le data holder de
* debug Doctrine (actif en test grace a `profiling: true` dans la config test,
* independamment d'APP_DEBUG sinon le compte casse en CI). Le holder est remis
* a zero juste avant la requete pour isoler ses requetes (hors login).
*/
private function countListQueries(string $token): int
{
$http = $this->createAdminClient();
$holder = self::getContainer()->get('doctrine.debug_data_holder');
$holder->reset();
$http->request('GET', '/api/carriers?search='.$token, ['headers' => ['Accept' => self::LD]]);
$data = $holder->getData();
return count($data['default'] ?? []);
}
private function token(): string
{
return 'List'.substr(bin2hex(random_bytes(4)), 0, 8);
}
}
@@ -0,0 +1,328 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
use App\Module\Commercial\Domain\Entity\ClientAddress;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Transport\Domain\Entity\CarrierPrice;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* Sous-ressource Prix d'un transporteur (spec-back M4 § 4.5, ERP-161).
* POST /api/carriers/{id}/prices, PATCH/DELETE /api/carrier_prices/{id}.
*
* Contrat verifie (RG-4.09→4.11) :
* - branche CLIENT incomplete -> 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();
$response = $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);
// Faux-vert evite : la 422 doit prouver l'integrite referentielle adresse<->tiers
// (violation sur clientDeliveryAddress), pas une autre cause (RG-4.10, ERP-101).
self::assertViolationOnPath($response, 'clientDeliveryAddress');
}
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();
$response = $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);
self::assertViolationOnPath($response, 'supplierSupplyAddress');
}
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 testNegativePriceReturns422(): void
{
// Le prix porte un Assert\PositiveOrZero : une valeur negative -> 422 sur `price`
// (la branche CLIENT est par ailleurs complete pour isoler la cause).
$carrier = $this->seedCarrier('Prix Negatif');
$addr = $this->seedClientWithAddress('Client Prix Negatif');
$this->getEm()->flush();
$siteId = $this->aSiteId();
$client = $this->createAdminClient();
$response = $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' => '-5.00',
'priceState' => 'VALIDE',
],
]);
self::assertResponseStatusCodeSame(422);
self::assertViolationOnPath($response, 'price');
}
public function testPostPriceOnUnknownCarrierReturns404(): void
{
// Parent introuvable (read:false) -> 404 explicite du processor (linkParent
// s'execute avant validateBranch). Le payload porte les scalaires NotBlank
// (containerType/pricingUnit/price/priceState) pour passer la validation
// d'entite et atteindre le processor, ou le 404 prime.
$client = $this->createAdminClient();
$client->request('POST', '/api/carriers/999999/prices', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'direction' => 'CLIENT',
'containerType' => 'BENNE',
'pricingUnit' => 'TONNE',
'price' => '42.50',
'priceState' => 'VALIDE',
],
]);
self::assertResponseStatusCodeSame(404);
}
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;
}
}
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
use App\Module\Transport\Domain\Entity\Carrier;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* Tests fonctionnels de l'export XLSX du tableau Prix d'un transporteur (M4,
* § 4.6 / spec-front « Onglet Prix »).
*
* Couvre : reponse 200 (Content-Type + Content-Disposition + en-tetes), rendu des
* lignes regroupees par type de contenant (Benne / Fond Mouvant) avec ventilation
* Forfait/Tonne, libelles d'etat FR, points de depart/livraison cross-module,
* 404 sur transporteur inconnu, 403 sans transport.carriers.view, 401 anonyme.
*
* @internal
*/
final class CarrierPriceExportControllerTest extends AbstractCarrierApiTestCase
{
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
{
$client = $this->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<int, array<int, mixed>>
*/
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<int, array<int, mixed>> $grid
*
* @return null|array<int, mixed>
*/
private function rowForContainer(array $grid, string $container): ?array
{
foreach (array_slice($grid, 1) as $row) {
if ((string) ($row[0] ?? '') === $container) {
return $row;
}
}
return null;
}
}
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* Matrice RBAC du repertoire transporteurs par role metier (spec-back M4 § 5.2 +
* ERP-153/158). Valide 200/403 par verbe pour bureau / compta / commerciale /
* usine ; l'archivage reste admin seul (gating CarrierProcessor, RG-4.14). Jumeau
* de SupplierRBACMatrixTest (M2).
*
* Matrice § 5.2 rappel :
* - bureau : view + manage (PAS archive)
* - commerciale : view seul (ni manage ni archive)
* - compta : aucun acces (403 sur view ET manage)
* - usine : aucun acces (403 partout)
* - archive : admin seul
*
* @internal
*/
final class CarrierRBACMatrixTest extends AbstractCarrierApiTestCase
{
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
protected function setUp(): void
{
parent::setUp();
// Seed idempotent via la commande applicative (roles + matrice § 5.2 +
// comptes demo) — meme chemin qu'en recette.
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 : 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);
}
}
@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
/**
* Tests du CONTRAT DE SERIALISATION du repertoire transporteurs (M4, spec-back
* § 4.0 / § 4.0.bis). Jumeau de {@see \App\Tests\Module\Commercial\Api\SupplierSerializationContractTest}.
* Reverifie sur le JSON REEL les pieges silencieux du M1 transposes au M4 :
* - #1/#2 : relations embarquees en OBJET (pas IRI nu) — qualimatCarrier, et au
* detail prices[].client / .supplier / .departureSite / .deliverySite.
* - #3 : booleens isArchived / isChartered presents dans le JSON (Groups +
* SerializedName sur le getter).
* - enveloppe AP4 (member/totalItems/view sans prefixe hydra:) + exclusion des
* archives par defaut, ?includeArchived=true les reintegre.
*
* REGLE D'OR : on asserte sur le CORPS JSON reel, jamais sur les annotations.
*
* @internal
*/
final class CarrierSerializationContractTest extends AbstractCarrierApiTestCase
{
// === Enveloppe AP4 + exclusion des archives (§ 4.1) ===
public function testCollectionEnvelopeShapeAndArchivedExcluded(): void
{
$http = $this->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<string, mixed> $collection
*
* @return array<string, mixed>|null
*/
private function memberById(array $collection, int $id): ?array
{
foreach ($collection['member'] ?? [] as $member) {
if (($member['id'] ?? null) === $id) {
return $member;
}
}
return null;
}
}
@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
/**
* Ecriture du formulaire principal transporteur (M4, WT4 ERP-158). Couvre les
* RG du CarrierProcessor + contraintes conditionnelles : RG-4.01 (QUALIMAT + cas
* LIOT), RG-4.02 (AUTRE -> 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);
}
}
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* Endpoint de recherche du referentiel QUALIMAT (spec-back M4 § 4.7 / RG-4.01,
* ERP-156). Saisie assistee du nom : GET /api/qualimat_carriers?search= .
*
* Contrat verifie :
* - recherche fuzzy sur name (+ siret), SEULEMENT les lignes actives ;
* - tri name ASC ;
* - enveloppe Hydra paginee (member / totalItems / view regle n°13) ;
* - 403 sans la permission transport.carriers.view (compta/usine, matrice § 5.2).
*
* @internal
*/
final class QualimatCarrierSearchTest extends AbstractCarrierApiTestCase
{
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
/** Prefixe SIRET dedie (purge par AbstractCarrierApiTestCase::tearDown). */
private const string SIRET_PREFIX = 'TESTQ';
protected function setUp(): void
{
parent::setUp();
// Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin
// qu'en recette), requis pour le test de permission (usine sans acces).
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 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'),
]);
}
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Application;
use App\Module\Transport\Application\Service\CarrierFieldNormalizer;
use PHPUnit\Framework\TestCase;
/**
* Normalisation serveur des champs texte du repertoire transporteurs (RG-4.13 +
* cas LIOT RG-4.01). Jumeau de SupplierFieldNormalizerTest (M2), enrichi de
* normalizeLiotPlates.
*
* @internal
*/
final class CarrierFieldNormalizerTest extends TestCase
{
private CarrierFieldNormalizer $normalizer;
protected function setUp(): void
{
$this->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));
}
}
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Application\Idtf;
use App\Module\Transport\Application\Idtf\IdtfSheetParser;
use PHPUnit\Framework\TestCase;
use RuntimeException;
/**
* @internal
*/
final class IdtfSheetParserTest extends TestCase
{
public function testExtractsExportDate(): void
{
$parsed = IdtfSheetParser::parse($this->sampleMatrix());
self::assertSame('2026-06-12', $parsed['exportDate']);
}
public function testParsesAndNormalizesFirstRow(): void
{
$parsed = IdtfSheetParser::parse($this->sampleMatrix());
$row = $parsed['rows'][0];
self::assertSame(30748, $row['idtf_number']);
self::assertSame('Argiles avec régime de nettoyage C', $row['name']);
self::assertSame('C', $row['cleaning_regime']);
self::assertSame('2026-04-02', $row['mandatory_date']);
self::assertSame('Al2O3', $row['formula']);
self::assertSame('01 01 01', $row['eural_code']);
self::assertSame(['7631-86-9', '1344-28-1'], $row['cas_numbers']);
self::assertSame('Note 1', $row['footnotes']);
}
public function testSkipsEmptyAndNonNumericRows(): void
{
$parsed = IdtfSheetParser::parse($this->sampleMatrix());
// 2 lignes exploitables (30748 et 30744) ; vide + "abc" ignorees.
self::assertCount(2, $parsed['rows']);
self::assertSame(30744, $parsed['rows'][1]['idtf_number']);
}
public function testEmptyOptionalCellsBecomeNullAndCasEmpty(): void
{
$parsed = IdtfSheetParser::parse($this->sampleMatrix());
$row = $parsed['rows'][1]; // 30744
self::assertNull($row['mandatory_date']);
self::assertNull($row['formula']);
self::assertNull($row['product_group']);
self::assertSame([], $row['cas_numbers']);
}
public function testColumnOrderIsResolvedByLabel(): void
{
// En-tete dans un ordre different : le mapping doit suivre les libelles.
$matrix = [
['Export date: 1-1-2026'],
['Numéro CAS', 'Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'],
['7440-44-0', '99', 'Carbone', 'B'],
];
$parsed = IdtfSheetParser::parse($matrix);
$row = $parsed['rows'][0];
self::assertSame(99, $row['idtf_number']);
self::assertSame('Carbone', $row['name']);
self::assertSame('B', $row['cleaning_regime']);
self::assertSame(['7440-44-0'], $row['cas_numbers']);
}
public function testThrowsWhenHeaderMissing(): void
{
$this->expectException(RuntimeException::class);
IdtfSheetParser::parse([['foo', 'bar'], ['1', '2']]);
}
public function testExportDateNullWhenAbsent(): void
{
$matrix = [
['Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'],
['1', 'X', 'A'],
];
self::assertNull(IdtfSheetParser::parse($matrix)['exportDate']);
}
/**
* Matrice representative de l'export reel : preambule (lignes 0-1), ligne
* vide (2), en-tete (3) puis donnees.
*
* @return array<int, array<int, mixed>>
*/
private function sampleMatrix(): array
{
return [
['Export date: 12-6-2026'],
['Changes in the database after this date...'],
[],
['Numéro IDTF', 'Product Group', 'Nom de la marchandise', 'Régime de nettoyage', 'Exigences importantes', 'Date dapplication obligatoire', 'Produits apparentés', 'Formule', 'Code EURAL', 'Numéro CAS', 'Annotations'],
['30748', 'Substances inorganiques', 'Argiles avec régime de nettoyage C', 'C', 'Exigence X', '02-04-2026', 'Poudre argile', 'Al2O3', '01 01 01', '7631-86-9 ; 1344-28-1', 'Note 1'],
['', '', '', '', '', '', '', '', '', '', ''],
['abc', 'ligne non numerique a ignorer', '', '', '', '', '', '', '', '', ''],
['30744', '', 'Additifs alimentaires', 'A', '', '', '', '', '', '', ''],
];
}
}
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Infrastructure\Console;
use Doctrine\DBAL\Connection;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
/**
* Test fonctionnel de `app:idtf:sync` via --file : genere un vrai .xlsx, le
* passe a la commande et verifie le parsing, l'upsert, le journal et le
* soft-delete (chemin complet IOFactory -> parser -> DBAL).
*
* @internal
*/
final class SyncIdtfCommandTest extends KernelTestCase
{
private Connection $connection;
protected function setUp(): void
{
self::bootKernel();
/** @var Connection $connection */
$connection = self::getContainer()->get('doctrine.dbal.default_connection');
$this->connection = $connection;
$this->purge();
}
protected function tearDown(): void
{
$this->purge();
parent::tearDown();
}
public function testSyncParsesXlsxUpsertsAndLogs(): void
{
$path = $this->makeXlsx([
['Export date: 12-6-2026'],
['Avertissement preambule'],
[],
['Numéro IDTF', 'Product Group', 'Nom de la marchandise', 'Régime de nettoyage', 'Exigences importantes', 'Date dapplication obligatoire', 'Produits apparentés', 'Formule', 'Code EURAL', 'Numéro CAS', 'Annotations'],
['30748', 'Inorganiques', 'Argiles régime C', 'C', 'Exig X', '02-04-2026', 'Poudre', 'Al2O3', '01 01 01', '7631-86-9 ; 1344-28-1', 'Note'],
['', '', '', '', '', '', '', '', '', '', ''],
['30744', '', 'Additifs', 'A', '', '', '', '', '', '', ''],
]);
$tester = $this->runSync($path);
$tester->assertCommandIsSuccessful();
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM idtf_product'));
$row = $this->connection->fetchAssociative("SELECT * FROM idtf_product WHERE idtf_number = 30748 AND schema = 'road'");
self::assertNotFalse($row);
self::assertSame('Argiles régime C', $row['name']);
self::assertSame('C', $row['cleaning_regime']);
self::assertSame('2026-04-02', $row['mandatory_date']);
self::assertSame('2026-06-12', $row['source_export_date']);
self::assertSame(['7631-86-9', '1344-28-1'], json_decode((string) $row['cas_numbers'], true));
$log = $this->connection->fetchAssociative('SELECT * FROM idtf_sync_log ORDER BY id DESC LIMIT 1');
self::assertNotFalse($log);
self::assertSame('road', $log['schema']);
self::assertSame('2026-06-12', $log['export_date']);
self::assertSame(2, (int) $log['rows_total']);
self::assertSame(2, (int) $log['rows_upserted']);
self::assertSame(0, (int) $log['rows_deactivated']);
}
public function testSecondSyncSoftDeletesMissing(): void
{
$header = ['Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'];
$this->runSync($this->makeXlsx([
['Export date: 1-6-2026'],
$header,
['100', 'Produit 100', 'A'],
['200', 'Produit 200', 'B'],
]))->assertCommandIsSuccessful();
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM idtf_product WHERE is_active = TRUE'));
// 2e export sans 200 -> soft-delete de 200, mise a jour de 100.
$tester = $this->runSync($this->makeXlsx([
['Export date: 2-6-2026'],
$header,
['100', 'Produit 100 maj', 'C'],
]));
$tester->assertCommandIsSuccessful();
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM idtf_product'));
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM idtf_product WHERE is_active = TRUE'));
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM idtf_product WHERE idtf_number = 200 AND is_active = FALSE'));
$row100 = $this->connection->fetchAssociative('SELECT * FROM idtf_product WHERE idtf_number = 100');
self::assertNotFalse($row100);
self::assertSame('Produit 100 maj', $row100['name']);
self::assertSame('C', $row100['cleaning_regime']);
$log = $this->connection->fetchAssociative('SELECT * FROM idtf_sync_log ORDER BY id DESC LIMIT 1');
self::assertNotFalse($log);
self::assertSame(1, (int) $log['rows_upserted']);
self::assertSame(1, (int) $log['rows_deactivated']);
}
public function testInvalidSchemaIsRejected(): void
{
$path = $this->makeXlsx([
['Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'],
['1', 'X', 'A'],
]);
$application = new Application(self::$kernel);
$tester = new CommandTester($application->find('app:idtf:sync'));
$exitCode = $tester->execute(['--file' => $path, '--schema' => 'air']);
@unlink($path);
self::assertSame(2, $exitCode); // Command::INVALID
self::assertSame(0, $this->countRows('SELECT COUNT(*) FROM idtf_product'));
}
/**
* @param array<int, array<int, mixed>> $matrix
*/
private function makeXlsx(array $matrix): string
{
$spreadsheet = new Spreadsheet();
$spreadsheet->getActiveSheet()->fromArray($matrix, null, 'A1', true);
$path = tempnam(sys_get_temp_dir(), 'idtf_').'.xlsx';
new Xlsx($spreadsheet)->save($path);
$spreadsheet->disconnectWorksheets();
return $path;
}
private function runSync(string $path): CommandTester
{
$application = new Application(self::$kernel);
$tester = new CommandTester($application->find('app:idtf:sync'));
$tester->execute(['--file' => $path, '--schema' => 'road']);
@unlink($path);
return $tester;
}
private function countRows(string $sql): int
{
return (int) $this->connection->fetchOne($sql);
}
private function purge(): void
{
$this->connection->executeStatement('DELETE FROM idtf_product');
$this->connection->executeStatement('DELETE FROM idtf_sync_log');
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport;
use App\Module\Transport\TransportModule;
use PHPUnit\Framework\TestCase;
/**
* Tests structurels du module Transport (M4) : identite et contrat
* `permissions()` (socle RBAC, ERP-153).
*
* @internal
*/
final class TransportModuleTest extends TestCase
{
public function testModuleIdentity(): void
{
self::assertSame('transport', TransportModule::ID);
self::assertSame('Transport', TransportModule::LABEL);
self::assertFalse(TransportModule::REQUIRED);
}
public function testPermissionsSetContainsExactlyThreeCodes(): void
{
// Garde-fou : le jeu de permissions du module est fige par ce test. Si
// quelqu'un ajoute / retire une permission sans ajuster la spec (§ 5.1)
// ni la matrice RBAC (§ 5.2), le test casse explicitement.
$codes = array_column(TransportModule::permissions(), 'code');
sort($codes);
self::assertSame(
[
'transport.carriers.archive',
'transport.carriers.manage',
'transport.carriers.view',
],
$codes,
);
}
public function testEveryPermissionCodeIsPrefixedByModuleId(): void
{
// Convention de nommage `module.resource[.sub].action` : le prefixe doit
// correspondre exactement a l'ID du module (verifie aussi par
// app:sync-permissions).
foreach (TransportModule::permissions() as $permission) {
self::assertStringStartsWith(
TransportModule::ID.'.',
$permission['code'],
'Chaque code de permission doit etre prefixe par l\'ID du module.',
);
self::assertNotSame('', $permission['label'], 'Chaque permission doit porter un label.');
}
}
}
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Tests\Shared\Api;
use App\Shared\Domain\Entity\UploadedDocument;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* Tests fonctionnels de l'endpoint d'upload generique (ERP-154).
*
* Couvre :
* - POST multipart d'un PDF valide -> 201, IRI renvoyee, ligne persistee,
* checksum sha256 calcule cote serveur ;
* - POST d'un MIME hors whitelist (text/plain) -> 422 ;
* - POST sans fichier -> 422 ;
* - POST anonyme -> 401 (acces /api protege globalement).
*
* @internal
*/
final class UploadedDocumentApiTest extends AbstractApiTestCase
{
private const string ENDPOINT = '/api/uploaded_documents';
/** @var list<string> */
private array $tempFiles = [];
protected function tearDown(): void
{
foreach ($this->tempFiles as $path) {
if (is_file($path)) {
@unlink($path);
}
}
parent::tearDown();
}
public function testUploadValidPdfReturnsIriAndPersistsRowWithChecksum(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$content = $this->minimalPdf();
$file = $this->makeUploadedFile($content, 'facture.pdf');
$response = $client->request('POST', self::ENDPOINT, [
'headers' => ['Accept' => 'application/ld+json'],
'extra' => ['files' => ['file' => $file]],
]);
self::assertResponseStatusCodeSame(201);
$data = $response->toArray();
self::assertArrayHasKey('@id', $data);
self::assertStringStartsWith(self::ENDPOINT.'/', $data['@id']);
self::assertSame('facture.pdf', $data['originalFilename']);
self::assertSame('application/pdf', $data['mimeType']);
self::assertSame(\strlen($content), $data['sizeBytes']);
self::assertSame(hash('sha256', $content), $data['checksum']);
self::assertSame(64, \strlen($data['checksum']));
// La ligne est bien persistee et relisible via le repository.
$id = $data['id'];
$document = $this->getEm()->getRepository(UploadedDocument::class)->find($id);
self::assertInstanceOf(UploadedDocument::class, $document);
self::assertSame(hash('sha256', $content), $document->getChecksum());
}
public function testUploadDisallowedMimeTypeReturns422(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$file = $this->makeUploadedFile('just some plain text content', 'note.txt');
$client->request('POST', self::ENDPOINT, [
'headers' => ['Accept' => 'application/ld+json'],
'extra' => ['files' => ['file' => $file]],
]);
self::assertResponseStatusCodeSame(422);
}
public function testUploadWithoutFileReturns422(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$client->request('POST', self::ENDPOINT, [
'headers' => ['Accept' => 'application/ld+json'],
'extra' => ['files' => []],
]);
self::assertResponseStatusCodeSame(422);
}
public function testUploadAnonymousIsRejected(): void
{
$client = self::createClient();
$file = $this->makeUploadedFile($this->minimalPdf(), 'facture.pdf');
$client->request('POST', self::ENDPOINT, [
'headers' => ['Accept' => 'application/ld+json'],
'extra' => ['files' => ['file' => $file]],
]);
self::assertResponseStatusCodeSame(401);
}
/**
* Cree un UploadedFile en mode test (move() autorise hors contexte HTTP).
*/
private function makeUploadedFile(string $content, string $clientName): UploadedFile
{
$path = sys_get_temp_dir().'/erp154-api-'.bin2hex(random_bytes(4));
file_put_contents($path, $content);
$this->tempFiles[] = $path;
return new UploadedFile($path, $clientName, null, null, true);
}
/**
* Contenu PDF minimal valide (entete `%PDF-1.4` -> finfo `application/pdf`).
*/
private function minimalPdf(): string
{
return "%PDF-1.4\n"
."1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
."2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
."3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n"
."trailer<</Root 1 0 R/Size 4>>\n"
."%%EOF\n";
}
}
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Tests\Shared\Infrastructure\Upload;
use App\Shared\Domain\Exception\FileTooLargeException;
use App\Shared\Domain\Exception\UnsupportedMimeTypeException;
use App\Shared\Infrastructure\Upload\FileUploader;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* Tests unitaires du service generique de televersement (ERP-154).
*
* Couvre : rejet d'un MIME hors whitelist, rejet d'un fichier trop volumineux,
* et le chemin nominal (checksum sha256 calcule, taille/MIME captures, fichier
* ecrit sous var/uploads/{yyyy}/{mm}/ avec horodatage de l'horloge injectee).
*
* @internal
*/
final class FileUploaderTest extends TestCase
{
private string $uploadBaseDir;
/** @var list<string> */
private array $tempFiles = [];
protected function setUp(): void
{
$this->uploadBaseDir = sys_get_temp_dir().'/erp154-uploads-'.bin2hex(random_bytes(4));
}
protected function tearDown(): void
{
// Nettoyage des fichiers sources et de l'arborescence de destination.
foreach ($this->tempFiles as $path) {
if (is_file($path)) {
@unlink($path);
}
}
$this->removeDirectory($this->uploadBaseDir);
}
public function testRejectsMimeTypeOutsideWhitelist(): void
{
$uploader = $this->createUploader();
$file = $this->makeUploadedFile('hello world plain text', 'note.txt');
$this->expectException(UnsupportedMimeTypeException::class);
$uploader->upload($file);
}
public function testRejectsFileLargerThanMaxSize(): void
{
$uploader = $this->createUploader();
// Contenu PDF valide mais artificiellement gonfle au-dela de la borne.
$content = "%PDF-1.4\n".str_repeat('A', FileUploader::MAX_SIZE_BYTES + 1);
$file = $this->makeUploadedFile($content, 'huge.pdf');
$this->expectException(FileTooLargeException::class);
$uploader->upload($file);
}
public function testStoresPdfAndComputesSha256Checksum(): void
{
$content = $this->minimalPdf();
$clock = new MockClock(new DateTimeImmutable('2026-06-15 10:00:00'));
$uploader = $this->createUploader($clock);
$file = $this->makeUploadedFile($content, 'facture.pdf');
$document = $uploader->upload($file);
self::assertSame('facture.pdf', $document->getOriginalFilename());
self::assertSame('application/pdf', $document->getMimeType());
self::assertSame(\strlen($content), $document->getSizeBytes());
self::assertSame(hash('sha256', $content), $document->getChecksum());
self::assertSame(64, \strlen($document->getChecksum()));
// Chemin relatif date selon l'horloge injectee (2026/06).
self::assertStringStartsWith('2026/06/', $document->getStoredPath());
self::assertFileExists($this->uploadBaseDir.'/'.$document->getStoredPath());
// Le fichier ecrit a bien le contenu d'origine (checksum coherent).
self::assertSame(
$document->getChecksum(),
hash_file('sha256', $this->uploadBaseDir.'/'.$document->getStoredPath()),
);
}
public function testRemoveDeletesStoredFile(): void
{
$uploader = $this->createUploader();
$file = $this->makeUploadedFile($this->minimalPdf(), 'facture.pdf');
$document = $uploader->upload($file);
$storedPath = $this->uploadBaseDir.'/'.$document->getStoredPath();
self::assertFileExists($storedPath);
// Compensation : remove() efface le fichier physique...
$uploader->remove($document);
self::assertFileDoesNotExist($storedPath);
// ...et reste silencieux si on le rappelle alors que le fichier a disparu.
$uploader->remove($document);
self::assertFileDoesNotExist($storedPath);
}
private function createUploader(?MockClock $clock = null): FileUploader
{
return new FileUploader($this->uploadBaseDir, $clock ?? new MockClock());
}
/**
* Cree un UploadedFile en mode test (move() autorise hors contexte HTTP).
*/
private function makeUploadedFile(string $content, string $clientName): UploadedFile
{
$path = sys_get_temp_dir().'/erp154-src-'.bin2hex(random_bytes(4));
file_put_contents($path, $content);
$this->tempFiles[] = $path;
// Le 5e argument `test: true` court-circuite move_uploaded_file().
return new UploadedFile($path, $clientName, null, null, true);
}
/**
* Contenu PDF minimal valide l'entete `%PDF-1.4` suffit a faire detecter
* `application/pdf` par finfo (getMimeType server-side).
*/
private function minimalPdf(): string
{
return "%PDF-1.4\n"
."1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
."2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
."3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n"
."trailer<</Root 1 0 R/Size 4>>\n"
."%%EOF\n";
}
private function removeDirectory(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = scandir($dir) ?: [];
foreach ($items as $item) {
if ('.' === $item || '..' === $item) {
continue;
}
$path = $dir.'/'.$item;
is_dir($path) ? $this->removeDirectory($path) : @unlink($path);
}
@rmdir($dir);
}
}