diff --git a/docs/specs/M4-transporteurs/spec-back.md b/docs/specs/M4-transporteurs/spec-back.md index 6a251a5..d6dd36a 100644 --- a/docs/specs/M4-transporteurs/spec-back.md +++ b/docs/specs/M4-transporteurs/spec-back.md @@ -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`) diff --git a/src/Module/Transport/Infrastructure/DataFixtures/CarrierFixtures.php b/src/Module/Transport/Infrastructure/DataFixtures/CarrierFixtures.php index 4ffaed0..bd4c27e 100644 --- a/src/Module/Transport/Infrastructure/DataFixtures/CarrierFixtures.php +++ b/src/Module/Transport/Infrastructure/DataFixtures/CarrierFixtures.php @@ -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 + */ + 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]); + } } diff --git a/tests/Module/Transport/Api/CarrierAddressApiTest.php b/tests/Module/Transport/Api/CarrierAddressApiTest.php index 53ea6f3..6d163b8 100644 --- a/tests/Module/Transport/Api/CarrierAddressApiTest.php +++ b/tests/Module/Transport/Api/CarrierAddressApiTest.php @@ -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 diff --git a/tests/Module/Transport/Api/CarrierAuditTest.php b/tests/Module/Transport/Api/CarrierAuditTest.php new file mode 100644 index 0000000..d8ad120 --- /dev/null +++ b/tests/Module/Transport/Api/CarrierAuditTest.php @@ -0,0 +1,107 @@ + 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 $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], + ); + } +} diff --git a/tests/Module/Transport/Api/CarrierListTest.php b/tests/Module/Transport/Api/CarrierListTest.php new file mode 100644 index 0000000..f9b1856 --- /dev/null +++ b/tests/Module/Transport/Api/CarrierListTest.php @@ -0,0 +1,105 @@ +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