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.
This commit is contained in:
Matthieu
2026-06-15 19:15:12 +02:00
parent e607cccf08
commit d9313dbec8
39 changed files with 4696 additions and 16 deletions
@@ -0,0 +1,241 @@
<?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;
/**
* 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';
/** 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');
}
/**
* 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;
}
}