Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba7a93c9de |
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user