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
This commit is contained in:
Matthieu
2026-06-16 11:40:47 +02:00
parent c0fa00c9c5
commit 18c88156e5
5 changed files with 688 additions and 56 deletions
+165 -28
View File
@@ -586,7 +586,7 @@ class Carrier implements TimestampableInterface, BlamableInterface
> 2. Sérialisation booléen `isArchived` (bug #3 M1) : clé présente dans le JSON réel.
> 3. `qualimatCarrier` embarqué (statut + validité) pour RG-4.04.
> ✅ **CAPTURÉ (WT3, ERP-155/157)** — JSON réel produit par `CarrierSerializationContractTest` (transporteur complet seedé : lien QUALIMAT, 1 adresse, 1 contact, 2 prix CLIENT + FOURNISSEUR). Les 3 pièges sont vérifiés verts. **Le front peut démarrer sur ce contrat.**
> ✅ **CAPTURÉ (ERP-163)** — JSON **RÉEL** produit par `CarrierSerializationContractTest::testDodReferenceJsonShape` (transporteur complet seedé : lien QUALIMAT, 1 adresse, 1 contact, 2 prix CLIENT + FOURNISSEUR), dumpé via la variable d'env `CARRIER_DOD_DUMP=1`. Les 3 pièges sont vérifiés verts. **Le front peut démarrer sur ce contrat.** Les valeurs cosmétiques (noms, SIRET) sont nettoyées du bruit de seed ; **toutes les clés ci-dessous sont présentes telles quelles dans la réponse réelle**.
>
> Contraintes d'architecture validées au passage :
> - Relations cross-module des prix (`client`/`supplier`/adresses) câblées **sans import inter-module** (règle n°1) via des contrats `Shared/Domain/Contract/*Interface` + `resolve_target_entities`. L'embed JSON passe par les read-groups des entités concrètes (`client:read`, `client_address:read`, `supplier:read`, `supplier_address:read`, `site:read`). Un groupe `supplier_address:read` a été **ajouté aux champs scalaires de `SupplierAddress`** (M2) pour que `supplierSupplyAddress` s'embarque comme `clientDeliveryAddress` (M1 avait déjà `client_address:read`).
@@ -596,19 +596,31 @@ class Carrier implements TimestampableInterface, BlamableInterface
```jsonc
{
"@context": "/api/contexts/Carrier", "@id": "/api/carriers", "@type": "Collection",
"@context": "/api/contexts/Carrier",
"@id": "/api/carriers",
"@type": "Collection",
"totalItems": 1,
"member": [
{
"@id": "/api/carriers/12", "@type": "Carrier", "id": 12,
"@id": "/api/carriers/26",
"@type": "Carrier",
"id": 26,
"name": "TRANSPORTS GRELILLIER",
"qualimatCarrier": { // embarqué (objet), pas IRI — RG-4.04
"@id": "/api/qualimat_carriers/8", "@type": "QualimatCarrier", "id": "8",
"siret": "…", "name": "…", "address": "…", "postalCode": "86000", "city": "Poitiers",
"status": "Valide", "validityDate": "2027-12-31T00:00:00+01:00"
"@id": "/api/qualimat_carriers/22",
"@type": "QualimatCarrier",
"id": "22",
"siret": "80012345600017",
"name": "TRANSPORTS GRELILLIER",
"address": "12 rue des Acacias",
"postalCode": "86000",
"city": "Poitiers",
"status": "Valide",
"validityDate": "2027-12-31T00:00:00+01:00"
},
"certificationType": "QUALIMAT",
"createdAt": "…", "updatedAt": "…",
"createdAt": "2026-06-15T19:12:39+02:00",
"updatedAt": "2026-06-15T19:12:39+02:00",
"isChartered": false, // bool présent (getter + SerializedName)
"isArchived": false // bool présent (piège #3)
}
@@ -617,44 +629,169 @@ class Carrier implements TimestampableInterface, BlamableInterface
}
```
**`GET /api/carriers/{id}` (DÉTAIL)** — `qualimatCarrier` + `addresses[]` + `contacts[]` + `prices[]` avec relations cross-module embarquées en objet :
**`GET /api/carriers/{id}` (DÉTAIL)** — `qualimatCarrier` + `addresses[]` + `contacts[]` + `prices[]` avec relations cross-module embarquées en objet (les `@id` des sous-collections sortent en `/.well-known/genid/…` : ce sont des IRI anonymes API Platform, normal pour des entités non exposées en ressource racine) :
```jsonc
{
"@id": "/api/carriers/12", "@type": "Carrier", "id": 12,
"@context": "/api/contexts/Carrier",
"@id": "/api/carriers/26",
"@type": "Carrier",
"id": 26,
"name": "TRANSPORTS GRELILLIER",
"qualimatCarrier": { "@type": "QualimatCarrier", "status": "Valide", "validityDate": "…", "...": "…" },
"qualimatCarrier": { // embarqué (statut + validité) — RG-4.04
"@id": "/api/qualimat_carriers/22",
"@type": "QualimatCarrier",
"id": "22",
"siret": "80012345600017",
"name": "TRANSPORTS GRELILLIER",
"address": "12 rue des Acacias",
"postalCode": "86000",
"city": "Poitiers",
"status": "Valide",
"validityDate": "2027-12-31T00:00:00+01:00"
},
"certificationType": "QUALIMAT",
"addresses": [
{ "@type": "CarrierAddress", "id": 4, "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "…", "createdAt": "…", "updatedAt": "…" }
{
"@type": "CarrierAddress",
"@id": "/api/.well-known/genid/9f597da33f73776f1c25",
"id": 12,
"country": "France",
"postalCode": "86000",
"city": "Poitiers",
"street": "12 rue des Acacias",
"createdAt": "2026-06-15T19:12:39+02:00",
"updatedAt": "2026-06-15T19:12:39+02:00"
}
],
"contacts": [
{ "@type": "CarrierContact", "id": 5, "firstName": "Marie", "lastName": "Martin", "phonePrimary": "0612345678", "email": "…", "createdAt": "…", "updatedAt": "…" }
{
"@type": "CarrierContact",
"@id": "/api/.well-known/genid/6c6335ead4557062774f",
"id": 13,
"firstName": "Marie",
"lastName": "Martin",
"phonePrimary": "0612345678",
"email": "marie.martin@grelillier.fr",
"createdAt": "2026-06-15T19:12:39+02:00",
"updatedAt": "2026-06-15T19:12:39+02:00"
}
],
"prices": [
{
"@type": "CarrierPrice", "id": 7, "direction": "CLIENT",
"client": { "@type": "Client", "@id": "/api/clients/4", "id": 4, "companyName": "…", "isArchived": false, "...": "…" },
"clientDeliveryAddress": { "@type": "ClientAddress", "@id": "/api/client_addresses/4", "postalCode": "86000", "city": "Poitiers", "street": "…", "...": "…" },
"departureSite": { "@type": "Site", "@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "...": "…" },
"containerType": "BENNE", "pricingUnit": "TONNE", "price": "42.50", "priceState": "VALIDE",
"createdAt": "…", "updatedAt": "…"
"@type": "CarrierPrice",
"@id": "/api/.well-known/genid/ac0305352bb3751a5b76",
"id": 23,
"direction": "CLIENT",
"client": { // OBJET embarqué (client:read), pas IRI nu — piège #1
"@type": "Client",
"@id": "/api/clients/117",
"id": 117,
"companyName": "NÉGOCE MÉTAUX ATLANTIQUE",
"triageService": false,
"categories": [],
"createdAt": "2026-06-15T19:12:39+02:00",
"updatedAt": "2026-06-15T19:12:39+02:00",
"sites": [],
"isArchived": false
},
"clientDeliveryAddress": { // OBJET embarqué (client_address:read)
"@type": "ClientAddress",
"@id": "/api/client_addresses/32",
"id": 32,
"country": "France",
"postalCode": "86000",
"city": "Poitiers",
"street": "1 rue de la Livraison",
"position": 0,
"sites": [],
"contacts": [],
"categories": [],
"createdAt": "2026-06-15T19:12:39+02:00",
"updatedAt": "2026-06-15T19:12:39+02:00",
"isProspect": false,
"isDelivery": true,
"isBilling": false,
"isBroker": false,
"isDistributor": false
},
"departureSite": { // OBJET embarqué (site:read)
"@type": "Site",
"@id": "/api/sites/1",
"id": 1,
"name": "Chatellerault",
"street": "14 All. d'Argenson",
"postalCode": "86100",
"city": "Châtellerault",
"color": "#056CF2",
"createdAt": "2026-06-15T18:57:56+02:00",
"updatedAt": "2026-06-15T18:57:56+02:00",
"fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
},
"containerType": "BENNE",
"pricingUnit": "TONNE",
"price": "42.50",
"priceState": "VALIDE",
"createdAt": "2026-06-15T19:12:39+02:00",
"updatedAt": "2026-06-15T19:12:39+02:00"
},
{
"@type": "CarrierPrice", "id": 8, "direction": "FOURNISSEUR",
"supplier": { "@type": "Supplier", "@id": "/api/suppliers/4", "id": 4, "companyName": "…", "isArchived": false, "...": "…" },
"supplierSupplyAddress": { "@type": "SupplierAddress", "@id": "/api/supplier_addresses/38", "id": 38, "addressType": "DEPART", "country": "France", "postalCode": "17000", "city": "La Rochelle", "street": "…" },
"deliverySite": { "@type": "Site", "@id": "/api/sites/1", "name": "Chatellerault", "...": "…" },
"containerType": "FOND_MOUVANT", "pricingUnit": "FORFAIT", "price": "320.00", "priceState": "EN_COURS",
"createdAt": "…", "updatedAt": "…"
"@type": "CarrierPrice",
"@id": "/api/.well-known/genid/cfee3c4dda8fb899ff3e",
"id": 24,
"direction": "FOURNISSEUR",
"supplier": { // OBJET embarqué (supplier:read), pas IRI nu — piège #1
"@type": "Supplier",
"@id": "/api/suppliers/102",
"id": 102,
"companyName": "FERRAILLEUR GRAND OUEST",
"categories": [],
"createdAt": "2026-06-15T19:12:39+02:00",
"updatedAt": "2026-06-15T19:12:39+02:00",
"sites": [],
"isArchived": false
},
"supplierSupplyAddress": { // OBJET embarqué (supplier_address:read)
"@type": "SupplierAddress",
"@id": "/api/supplier_addresses/38",
"id": 38,
"addressType": "DEPART",
"country": "France",
"postalCode": "17000",
"city": "La Rochelle",
"street": "2 quai de l Appro",
"createdAt": "2026-06-15T19:12:39+02:00",
"updatedAt": "2026-06-15T19:12:39+02:00"
},
"deliverySite": { // OBJET embarqué (site:read)
"@type": "Site",
"@id": "/api/sites/1",
"id": 1,
"name": "Chatellerault",
"street": "14 All. d'Argenson",
"postalCode": "86100",
"city": "Châtellerault",
"color": "#056CF2",
"createdAt": "2026-06-15T18:57:56+02:00",
"updatedAt": "2026-06-15T18:57:56+02:00",
"fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
},
"containerType": "FOND_MOUVANT",
"pricingUnit": "FORFAIT",
"price": "320.00",
"priceState": "EN_COURS",
"createdAt": "2026-06-15T19:12:39+02:00",
"updatedAt": "2026-06-15T19:12:39+02:00"
}
],
"createdAt": "…", "updatedAt": "…",
"isChartered": false, "isArchived": false
"createdAt": "2026-06-15T19:12:39+02:00",
"updatedAt": "2026-06-15T19:12:39+02:00",
"isChartered": false, // bool présent (getter + SerializedName)
"isArchived": false // bool présent (piège #3)
}
```
> Note WT3 : opérations exposées = `GetCollection` + `Get` (lecture). `POST`/`PATCH` (+ `CarrierProcessor`, normalisation, RG-4.01→4.14, 409 doublon, gating archive) et les sous-ressources d'écriture (adresses/contacts/prix) arrivent aux worktrees suivants (WT4+).
> Note (ERP-163) : opérations exposées = `GetCollection` + `Get` (lecture) **et** `POST`/`PATCH` (`CarrierProcessor` : normalisation RG-4.13, RG-4.01→4.14, 409 doublon, gating archive mode strict) **et** les sous-ressources d'écriture adresses/contacts/prix (`Carrier*Processor`). La couverture RG-4.01→4.14 + RBAC + audit + anti-N+1 est portée par la matrice de tests `tests/Module/Transport/Api/` (ERP-163).
### 4.1 `GET /api/carriers` — Liste
@@ -909,7 +1046,7 @@ Synchronisation : `php bin/console app:sync-permissions`.
- [x] 3 maillons de sérialisation documentés pour chaque champ liste + détail (§ 4.0)
- [x] Décision embed vs GetCollection explicite (embed détail + sous-ressources write — § 3.3 / § 3.4 / § 4.5)
- [ ] **Réponses JSON RÉELLES** capturées (§ 4.0.bis) — à produire au ticket tests (`CarrierSerializationContractTest`)
- [x] **Réponses JSON RÉELLES** capturées (§ 4.0.bis) — produites par `CarrierSerializationContractTest` (ERP-163, dump `CARRIER_DOD_DUMP=1`)
- [x] Matrice RBAC rôle × permission + mode strict archive (§ 5.2 / RG-4.14)
- [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit + i18n, routes à plat : rappelés
- [x] Réutilisations identifiées (référentiel QUALIMAT, Client/Supplier/Site partagés, `usePaginatedList`, blocs, archive, normalisation, `useAddressAutocomplete`)