Compare commits

...

1 Commits

Author SHA1 Message Date
Matthieu ba7a93c9de 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 11:40:47 +02:00
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`)
@@ -4,48 +4,311 @@ declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\DataFixtures;
use App\Module\Commercial\Infrastructure\DataFixtures\ClientFixtures;
use App\Module\Commercial\Infrastructure\DataFixtures\SupplierFixtures;
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
use App\Module\Transport\Application\Service\CarrierFieldNormalizer;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Entity\CarrierAddress;
use App\Module\Transport\Domain\Entity\CarrierContact;
use App\Module\Transport\Domain\Entity\CarrierPrice;
use App\Module\Transport\Domain\Entity\QualimatCarrier;
use App\Shared\Domain\Contract\ClientAddressInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SiteProviderInterface;
use App\Shared\Domain\Contract\SupplierAddressInterface;
use App\Shared\Domain\Entity\UploadedDocument;
use DateTimeImmutable;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Fixtures dev/test MINIMALES du repertoire transporteurs (M4, ERP-155/157) :
* 2 transporteurs de demonstration suffisant a faire tourner les ecrans de
* lecture (liste + detail). Les fixtures completes (cas QUALIMAT, affrete,
* LIOT, prix CLIENT/FOURNISSEUR...) sont livrees par le worktree dedie (WT10)
* ne pas les developper ici (scope WT3 : contrat de lecture).
* Fixtures dev/demo du repertoire transporteurs (M4) couvrant l'ensemble des cas
* metier RG-4.xx, jumelles des fixtures fournisseurs (M2). C'est ICI que vivent
* les fixtures COMPLETES (les maillons WT precedents s'etaient limites a un stub
* de lecture). Cas pivots seedes (§ 8.4) :
* - 1 transporteur QUALIMAT (lien `qualimat_carrier` + adresse copiee +
* validityDate PASSEE pour exercer le fond rouge RG-4.04) ;
* - 1 transporteur AUTRE + Decharge (UploadedDocument, RG-4.02) ;
* - 1 transporteur affrete (indexation + benne + volume obligatoires, RG-4.03) ;
* - 1 transporteur LIOT (immatriculations, certification non requise, RG-4.01) ;
* - 1 transporteur COMPLET : contacts + adresses + prix CLIENT et FOURNISSEUR ;
* - 1 transporteur archive (exclusion liste + restauration, RG-4.14).
*
* Aucune dependance cross-module (pas de prix, pas de lien QUALIMAT) : la
* fixture reste autonome et joue en fin de chaine sans contrainte d'ordre.
* Resolution inter-modules conforme a la regle n°1 (pas d'import de logique) :
* - sites resolus via le contrat Shared SiteProviderInterface ;
* - client/adresse et fournisseur/adresse des prix resolus via les contrats
* Shared ClientAddressInterface / SupplierAddressInterface (relations ORM
* partagees, RG-4.10/4.11). Si la demo Commercial/Sites n'est pas chargee, les
* prix sont simplement omis (le reste de la fiche reste seede).
*
* Normalisation : valeurs fournies BRUTES puis normalisees par
* CarrierFieldNormalizer avant persist, comme le ferait le CarrierProcessor via
* l'API (name UPPERCASE, first/last Capitalize, telephones chiffres seuls, email
* lowercase, liotPlates « ; »-normalise).
*
* Idempotence : lookup par `name` normalise (coherent avec l'index unique partiel
* uq_carrier_name_active). Un transporteur deja present n'est pas reconstruit (ses
* sous-collections ne sont pas redupliquees). Rejouable sans doublon.
*
* Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by
* restent null (« Systeme » cote front), c'est attendu.
*
* Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`, la
* fixture ne charge rien : les tests seedent et nettoient leurs propres
* transporteurs et comptent sur une table `carrier` vierge — y injecter des
* transporteurs de demo casserait les comptages de liste et les cleanups. Meme
* garde-fou que ClientFixtures / SupplierFixtures.
*/
final class CarrierFixtures extends Fixture
class CarrierFixtures extends Fixture implements DependentFixtureInterface
{
/** SIRET de la ligne qualimat_carrier de demo (cle naturelle, insert idempotent). */
private const string QUALIMAT_DEMO_SIRET = '90000000000017';
public function __construct(
private readonly CarrierFieldNormalizer $normalizer,
private readonly SiteProviderInterface $siteProvider,
#[Autowire('%kernel.environment%')]
private readonly string $environment,
) {}
/**
* @return array<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
{
// Transporteur certifie « classique ».
$alpha = new Carrier();
$alpha->setName('TRANSPORTS ALPHA');
$alpha->setCertificationType('GMP_PLUS');
$manager->persist($alpha);
// Donnees de demo : dev uniquement. En test, on laisse la table vierge.
if ('test' === $this->environment) {
return;
}
$contact = new CarrierContact();
$contact->setCarrier($alpha);
$contact->setLastName('Durand');
$contact->setPhonePrimary('0612345678');
$alpha->addContact($contact);
$manager->persist($contact);
// === Transporteur QUALIMAT (RG-4.01) — adresse copiee + validite PASSEE (RG-4.04) ===
[$grelillier, $isNew] = $this->ensureCarrier($manager, 'Transports Grelillier');
if ($isNew) {
$grelillier->setQualimatCarrier($this->ensureQualimatDemoLine($manager));
$grelillier->setCertificationType('QUALIMAT');
// Adresse pre-remplie depuis la copie QUALIMAT (RG-4.05).
$this->addAddress($grelillier, '86000', 'Poitiers', '12 rue des Acacias');
$this->addContact($grelillier, 'Marie', 'Martin', 'Exploitation', '06 12 34 56 78', null, 'marie.martin@grelillier.fr');
}
// Transporteur affrete (RG-4.03).
$beta = new Carrier();
$beta->setName('TRANSPORTS BETA');
$beta->setCertificationType('AUTRE');
$beta->setIsChartered(true);
$beta->setIndexationRate('5.00');
$beta->setContainerType('BENNE');
$beta->setVolumeM3('90.00');
$manager->persist($beta);
// === Transporteur AUTRE + Decharge (RG-4.02) ===
[$pandele, $isNew] = $this->ensureCarrier($manager, 'Transports Pandele');
if ($isNew) {
$pandele->setCertificationType('AUTRE');
$pandele->setDischargeDocument($this->buildDischargeDocument($manager));
$this->addContact($pandele, 'Luc', 'Pandele', 'Gerant', '05 49 11 22 33', null, 'luc.pandele@pandele.fr');
}
// === Transporteur affrete (RG-4.03) — indexation + benne + volume ===
[$affrete, $isNew] = $this->ensureCarrier($manager, 'Affreteurs Reunis');
if ($isNew) {
$affrete->setCertificationType('GMP_PLUS');
$affrete->setIsChartered(true);
$affrete->setIndexationRate('5.00');
$affrete->setContainerType('BENNE');
$affrete->setVolumeM3('90.00');
$this->addAddress($affrete, '17000', 'La Rochelle', '4 quai des Affreteurs');
}
// === Cas LIOT (RG-4.01) — immatriculations, certification non requise ===
[$liot, $isNew] = $this->ensureCarrier($manager, 'LIOT');
if ($isNew) {
$liot->setLiotPlates($this->normalizer->normalizeLiotPlates('ab-123-cd ; ef-456-gh ; gh-789-ij'));
}
// === Transporteur COMPLET — contacts + adresses + prix CLIENT et FOURNISSEUR ===
[$complet, $isNew] = $this->ensureCarrier($manager, 'Transports Logistique Globale');
if ($isNew) {
$complet->setCertificationType('OVOCOM');
$this->addAddress($complet, '86100', 'Châtellerault', '20 zone des Transporteurs');
$this->addContact($complet, 'Sophie', 'Bernard', 'Directrice', '05 49 44 55 66', '06 99 88 77 66', 'sophie.bernard@logistique-globale.fr', 0);
$this->addContact($complet, 'Marc', 'Lopez', 'Affretement', '05 49 44 55 67', null, 'marc.lopez@logistique-globale.fr', 1);
$this->addPrices($manager, $complet);
}
// === Transporteur archive (RG-4.14) ===
[$archive, $isNew] = $this->ensureCarrier($manager, 'Transports Anciens', isArchived: true);
if ($isNew) {
$archive->setCertificationType('COMPTE_PROPRE');
$this->addContact($archive, 'Paul', 'Ancien', 'Ex-gerant', '05 49 00 00 00', null, 'paul.ancien@anciens.fr');
}
$manager->flush();
}
/**
* Cree un transporteur (nom normalise UPPERCASE) s'il n'existe pas encore,
* sinon retourne l'existant. Retourne [Carrier, isNew] : isNew=false bloque la
* reconstruction des sous-collections (idempotence sans doublon).
*
* @return array{0: Carrier, 1: bool}
*/
private function ensureCarrier(ObjectManager $manager, string $name, bool $isArchived = false): array
{
$normalizedName = (string) $this->normalizer->normalizeName($name);
$existing = $manager->getRepository(Carrier::class)->findOneBy(['name' => $normalizedName]);
if ($existing instanceof Carrier) {
return [$existing, false];
}
$carrier = new Carrier();
$carrier->setName($normalizedName);
if ($isArchived) {
$carrier->setIsArchived(true);
$carrier->setArchivedAt(new DateTimeImmutable());
}
$manager->persist($carrier);
return [$carrier, true];
}
/**
* Ajoute une adresse au transporteur (cascade persist via Carrier.addresses).
*/
private function addAddress(Carrier $carrier, string $postalCode, string $city, string $street): void
{
$address = new CarrierAddress();
$address->setPostalCode($postalCode);
$address->setCity($city);
$address->setStreet($street);
$carrier->addAddress($address);
}
/**
* Ajoute un contact normalise au transporteur (cascade persist via
* Carrier.contacts). Au moins un champ est toujours fourni (RG-4.08).
*/
private function addContact(
Carrier $carrier,
?string $firstName,
?string $lastName,
?string $jobTitle,
?string $phonePrimary,
?string $phoneSecondary,
?string $email,
int $position = 0,
): void {
$contact = new CarrierContact();
$contact->setFirstName($this->normalizer->normalizePersonName($firstName));
$contact->setLastName($this->normalizer->normalizePersonName($lastName));
$contact->setJobTitle($jobTitle);
$contact->setPhonePrimary($this->normalizer->normalizePhone($phonePrimary));
$contact->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary));
$contact->setEmail($this->normalizer->normalizeEmail($email));
$contact->setPosition($position);
$carrier->addContact($contact);
}
/**
* Ajoute un prix CLIENT et un prix FOURNISSEUR au transporteur (RG-4.10/4.11),
* en resolvant les relations cross-module (client/adresse de livraison + site
* de depart ; fournisseur/adresse d'appro + site de livraison) via les contrats
* Shared. Si la demo Commercial/Sites n'est pas disponible, les prix sont omis.
*/
private function addPrices(ObjectManager $manager, Carrier $carrier): void
{
$site = $this->siteProvider->findByName('Chatellerault');
// Branche CLIENT (RG-4.10) : 1ere adresse de livraison de la demo M1.
$clientAddress = $manager->getRepository(ClientAddressInterface::class)->findOneBy(['isDelivery' => true]);
if ($site instanceof SiteInterface && $clientAddress instanceof ClientAddressInterface && null !== $clientAddress->getClient()) {
$clientPrice = new CarrierPrice();
$clientPrice->setDirection('CLIENT');
$clientPrice->setClient($clientAddress->getClient());
$clientPrice->setClientDeliveryAddress($clientAddress);
$clientPrice->setDepartureSite($site);
$clientPrice->setContainerType('BENNE');
$clientPrice->setPricingUnit('TONNE');
$clientPrice->setPrice('42.50');
$clientPrice->setPriceState('VALIDE');
$carrier->addPrice($clientPrice);
}
// Branche FOURNISSEUR (RG-4.11) : 1ere adresse de DEPART de la demo M2.
$supplierAddress = $manager->getRepository(SupplierAddressInterface::class)->findOneBy(['addressType' => 'DEPART']);
if ($site instanceof SiteInterface && $supplierAddress instanceof SupplierAddressInterface && null !== $supplierAddress->getSupplier()) {
$supplierPrice = new CarrierPrice();
$supplierPrice->setDirection('FOURNISSEUR');
$supplierPrice->setSupplier($supplierAddress->getSupplier());
$supplierPrice->setSupplierSupplyAddress($supplierAddress);
$supplierPrice->setDeliverySite($site);
$supplierPrice->setContainerType('FOND_MOUVANT');
$supplierPrice->setPricingUnit('FORFAIT');
$supplierPrice->setPrice('320.00');
$supplierPrice->setPriceState('EN_COURS');
$carrier->addPrice($supplierPrice);
}
}
/**
* Construit (non persiste explicitement — cascade via la FK Carrier) un
* UploadedDocument de demo pour la Decharge (RG-4.02). Pas de fichier reel sur
* disque : metadonnees factices suffisantes pour la demo.
*/
private function buildDischargeDocument(ObjectManager $manager): UploadedDocument
{
$document = new UploadedDocument(
'decharge-demo.pdf',
'demo/decharge-demo.pdf',
'application/pdf',
12_345,
str_repeat('0', 64),
new DateTimeImmutable(),
);
$manager->persist($document);
return $document;
}
/**
* Insere (idempotent, par SIRET) une ligne `qualimat_carrier` de demo a
* validite PASSEE (RG-4.04) puis retourne l'entite (lecture seule) rechargee.
* La table est normalement alimentee par `app:qualimat:sync` ; en demo on pose
* une ligne directe en DBAL (l'entite mappee n'expose aucune ecriture API).
*/
private function ensureQualimatDemoLine(ObjectManager $manager): QualimatCarrier
{
$repository = $manager->getRepository(QualimatCarrier::class);
$existing = $repository->findOneBy(['siret' => self::QUALIMAT_DEMO_SIRET]);
if ($existing instanceof QualimatCarrier) {
return $existing;
}
if ($manager instanceof EntityManagerInterface) {
$manager->getConnection()->insert('qualimat_carrier', [
'siret' => self::QUALIMAT_DEMO_SIRET,
'name' => 'TRANSPORTS GRELILLIER',
'address' => '12 rue des Acacias',
'postal_code' => '86000',
'city' => 'Poitiers',
'status' => 'Valide',
// Validite PASSEE : exerce le fond rouge RG-4.04 cote front.
'validity_date' => '2024-12-31',
'is_active' => 'true',
'last_synced_at' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
]);
}
/** @var QualimatCarrier $line */
return $repository->findOneBy(['siret' => self::QUALIMAT_DEMO_SIRET]);
}
}
@@ -63,6 +63,26 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
self::assertResponseStatusCodeSame(422);
}
public function testInconsistentPostalCodeAndCityIsAccepted(): void
{
// RG-4.06 : la validation serveur borne le FORMAT du code postal
// (^[0-9]{4,5}$) mais ne controle PAS la coherence CP <-> ville (deleguee
// a l'autocomplete BAN cote front). Un CP valide avec une ville qui ne lui
// correspond pas est donc accepte (201).
$carrier = $this->seedCarrierWithChartered('Cp Ville Incoherents', false);
$client = $this->createAdminClient();
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '86000', // Poitiers
'city' => 'Marseille', // incoherent, mais non controle
'street' => '1 rue de la Coherence',
],
]);
self::assertResponseStatusCodeSame(201);
}
public function testCharteredCarrierIncompleteAddressReturns422(): void
{
// Transporteur affrete : RG-4.05 exige Pays/CP/Ville/Adresse. CP valide mais
@@ -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,105 @@
<?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']);
}
/**
* 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);
}
}