diff --git a/src/Module/Transport/Domain/Entity/CarrierPrice.php b/src/Module/Transport/Domain/Entity/CarrierPrice.php index 90e4be0..ed41f01 100644 --- a/src/Module/Transport/Domain/Entity/CarrierPrice.php +++ b/src/Module/Transport/Domain/Entity/CarrierPrice.php @@ -4,6 +4,13 @@ declare(strict_types=1); namespace App\Module\Transport\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierPriceProcessor; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\ClientAddressInterface; @@ -15,6 +22,7 @@ use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Trait\TimestampableBlamableTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Validator\Constraints as Assert; /** * Prix d'un transporteur (1:n) — onglet Prix (M4, RG-4.09→4.11). Une ligne porte @@ -30,9 +38,73 @@ use Symfony\Component\Serializer\Attribute\Groups; * (client:read / client_address:read / supplier:read / supplier_address:read / * site:read), inclus dans le contexte du Get racine de Carrier (§ 4.0). * - * WT3 (ERP-155/157) = LECTURE seule : proprietes en `carrier:item:read`. Les - * sous-ressources d'ecriture + validation des branches (Processor) : WT8. + * Lecture : proprietes en `carrier:item:read` (embarquees au detail du + * transporteur). Ecriture : groupe `carrier:write:prices`. + * + * Sous-ressource API (ERP-161, spec § 4.5) — jumelle de CarrierAddress / + * CarrierContact : + * - POST /api/carriers/{carrierId}/prices : creation rattachee au transporteur + * parent (Link toProperty 'carrier'), security transport.carriers.manage. + * - PATCH / DELETE /api/carrier_prices/{id} : security transport.carriers.manage. + * - GET /api/carrier_prices/{id} : lecture unitaire (security view). + * Tout passe par le CarrierPriceProcessor (rattachement parent + RG-4.09→4.11 : + * coherence de branche CLIENT/FOURNISSEUR + appartenance de l'adresse). + * + * Les champs communs (direction, containerType, pricingUnit, price, priceState) + * sont obligatoires (Assert\NotBlank + Assert\Choice). L'obligation conditionnelle + * des champs de branche (client/supplier + adresses + sites) et l'appartenance de + * l'adresse au client/fournisseur sont portees par le Processor (violations Hydra + * a la main) : ces RG dependent de relations resolues a la denormalisation et non + * exprimables par une simple contrainte d'attribut. */ +#[ApiResource( + operations: [ + new Get( + security: "is_granted('transport.carriers.view')", + normalizationContext: ['groups' => [ + 'carrier:item:read', + 'client:read', 'client_address:read', + 'supplier:read', 'supplier_address:read', + 'site:read', 'default:read', + ]], + ), + new Post( + uriTemplate: '/carriers/{carrierId}/prices', + uriVariables: [ + 'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'), + ], + // read:false : pas de stade lecture du parent. Le Link toProperty + // resoudrait l'enfant (SELECT CarrierPrice ... WHERE carrier = :id) et + // casse en NonUniqueResult des >= 2 enfants. Le parent est rattache + // manuellement par CarrierPriceProcessor::linkParent (404 si absent). + read: false, + security: "is_granted('transport.carriers.manage')", + normalizationContext: ['groups' => [ + 'carrier:item:read', + 'client:read', 'client_address:read', + 'supplier:read', 'supplier_address:read', + 'site:read', 'default:read', + ]], + denormalizationContext: ['groups' => ['carrier:write:prices']], + processor: CarrierPriceProcessor::class, + ), + new Patch( + security: "is_granted('transport.carriers.manage')", + normalizationContext: ['groups' => [ + 'carrier:item:read', + 'client:read', 'client_address:read', + 'supplier:read', 'supplier_address:read', + 'site:read', 'default:read', + ]], + denormalizationContext: ['groups' => ['carrier:write:prices']], + processor: CarrierPriceProcessor::class, + ), + new Delete( + security: "is_granted('transport.carriers.manage')", + processor: CarrierPriceProcessor::class, + ), + ], +)] #[ORM\Entity] #[ORM\Table(name: 'carrier_price')] #[ORM\Index(name: 'idx_carrier_price_carrier', columns: ['carrier_id'])] @@ -61,61 +133,74 @@ class CarrierPrice implements TimestampableInterface, BlamableInterface /** CLIENT|FOURNISSEUR (RG-4.09) — pilote la branche active. */ #[ORM\Column(length: 12)] - #[Groups(['carrier:item:read'])] + #[Assert\NotBlank(message: 'Le sens du prix est obligatoire.')] + #[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR'], message: 'Le sens du prix est invalide.')] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?string $direction = null; // === Branche CLIENT (RG-4.10) === + // Obligation conditionnelle (direction=CLIENT) + appartenance de l'adresse au + // client : portees par le CarrierPriceProcessor (relations resolues a la + // denormalisation, hors portee d'une contrainte d'attribut). #[ORM\ManyToOne(targetEntity: ClientInterface::class)] #[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] - #[Groups(['carrier:item:read'])] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?ClientInterface $client = null; #[ORM\ManyToOne(targetEntity: ClientAddressInterface::class)] #[ORM\JoinColumn(name: 'client_delivery_address_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] - #[Groups(['carrier:item:read'])] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?ClientAddressInterface $clientDeliveryAddress = null; /** Adresse de depart = un des 3 sites (86/17/82). */ #[ORM\ManyToOne(targetEntity: SiteInterface::class)] #[ORM\JoinColumn(name: 'departure_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] - #[Groups(['carrier:item:read'])] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?SiteInterface $departureSite = null; // === Branche FOURNISSEUR (RG-4.11) === #[ORM\ManyToOne(targetEntity: SupplierInterface::class)] #[ORM\JoinColumn(name: 'supplier_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] - #[Groups(['carrier:item:read'])] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?SupplierInterface $supplier = null; #[ORM\ManyToOne(targetEntity: SupplierAddressInterface::class)] #[ORM\JoinColumn(name: 'supplier_supply_address_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] - #[Groups(['carrier:item:read'])] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?SupplierAddressInterface $supplierSupplyAddress = null; /** Adresse de livraison = un des 3 sites (86/17/82). */ #[ORM\ManyToOne(targetEntity: SiteInterface::class)] #[ORM\JoinColumn(name: 'delivery_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] - #[Groups(['carrier:item:read'])] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?SiteInterface $deliverySite = null; - // === Commun === + // === Commun (toujours obligatoires, RG-4.10/4.11) === /** BENNE|FOND_MOUVANT. */ #[ORM\Column(name: 'container_type', length: 12)] - #[Groups(['carrier:item:read'])] + #[Assert\NotBlank(message: 'Le type de contenant est obligatoire.')] + #[Assert\Choice(choices: ['BENNE', 'FOND_MOUVANT'], message: 'Le type de contenant est invalide.')] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?string $containerType = null; /** FORFAIT|TONNE. */ #[ORM\Column(name: 'pricing_unit', length: 8)] - #[Groups(['carrier:item:read'])] + #[Assert\NotBlank(message: 'L\'unite de tarification est obligatoire.')] + #[Assert\Choice(choices: ['FORFAIT', 'TONNE'], message: 'L\'unite de tarification est invalide.')] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?string $pricingUnit = null; #[ORM\Column(type: 'decimal', precision: 12, scale: 2)] - #[Groups(['carrier:item:read'])] + #[Assert\NotBlank(message: 'Le prix est obligatoire.')] + #[Assert\PositiveOrZero(message: 'Le prix ne peut pas etre negatif.')] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?string $price = null; /** EN_COURS|VALIDE|NON_VALIDE. */ #[ORM\Column(name: 'price_state', length: 12)] - #[Groups(['carrier:item:read'])] + #[Assert\NotBlank(message: 'L\'etat du prix est obligatoire.')] + #[Assert\Choice(choices: ['EN_COURS', 'VALIDE', 'NON_VALIDE'], message: 'L\'etat du prix est invalide.')] + #[Groups(['carrier:item:read', 'carrier:write:prices'])] private ?string $priceState = null; #[ORM\Column(options: ['default' => 0])] diff --git a/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierPriceProcessor.php b/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierPriceProcessor.php new file mode 100644 index 0000000..adefa85 --- /dev/null +++ b/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierPriceProcessor.php @@ -0,0 +1,170 @@ + + */ +final class CarrierPriceProcessor implements ProcessorInterface +{ + private const string DIRECTION_CLIENT = 'CLIENT'; + + private const string DIRECTION_SUPPLIER = 'FOURNISSEUR'; + + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private readonly ProcessorInterface $removeProcessor, + private readonly EntityManagerInterface $em, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof CarrierPrice) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof DeleteOperationInterface) { + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } + + $this->linkParent($data, $uriVariables); + $this->validateBranch($data); + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + /** + * Rattache le prix au transporteur parent de la sous-ressource POST + * (/carriers/{carrierId}/prices) : la relation n'est pas peuplee + * automatiquement par le Link sur une ecriture. Sur PATCH (entite existante), + * le transporteur est deja present -> no-op. + */ + private function linkParent(CarrierPrice $price, array $uriVariables): void + { + if (null !== $price->getCarrier()) { + return; + } + + $carrierId = $uriVariables['carrierId'] ?? null; + if (null === $carrierId) { + return; + } + + $carrier = $carrierId instanceof Carrier + ? $carrierId + : $this->em->getRepository(Carrier::class)->find($carrierId); + + // read:false sur le POST : sans stade lecture, un parent introuvable n'est + // plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la + // contrainte carrier_id NOT NULL). + if (!$carrier instanceof Carrier) { + throw new NotFoundHttpException('Transporteur introuvable.'); + } + + $price->setCarrier($carrier); + } + + /** + * RG-4.09→4.11 : valide la coherence de la branche active (CLIENT vs + * FOURNISSEUR) et nettoie la branche opposee (les CHECK BDD imposent ses + * colonnes nulles). Toutes les violations sont collectees puis renvoyees d'un + * coup (un seul aller-retour, mapping inline par champ — ERP-101). La direction + * elle-meme est deja garantie CLIENT|FOURNISSEUR par Assert\NotBlank + Choice. + */ + private function validateBranch(CarrierPrice $price): void + { + $violations = new ConstraintViolationList(); + + if (self::DIRECTION_CLIENT === $price->getDirection()) { + $this->requireField($violations, $price, 'client', $price->getClient(), 'Le client est obligatoire pour un prix client.'); + $this->requireField($violations, $price, 'clientDeliveryAddress', $price->getClientDeliveryAddress(), 'L\'adresse de livraison du client est obligatoire pour un prix client.'); + $this->requireField($violations, $price, 'departureSite', $price->getDepartureSite(), 'Le site de depart est obligatoire pour un prix client.'); + + // RG-4.10 : l'adresse de livraison doit appartenir au client choisi. + $client = $price->getClient(); + $address = $price->getClientDeliveryAddress(); + if (null !== $client && null !== $address && $address->getClient()?->getId() !== $client->getId()) { + $violations->add($this->violation($price, 'clientDeliveryAddress', 'L\'adresse de livraison doit appartenir au client selectionne.')); + } + + // Coherence CHECK chk_carrier_price_client_branch : branche fournisseur nulle. + $price->setSupplier(null); + $price->setSupplierSupplyAddress(null); + $price->setDeliverySite(null); + } elseif (self::DIRECTION_SUPPLIER === $price->getDirection()) { + $this->requireField($violations, $price, 'supplier', $price->getSupplier(), 'Le fournisseur est obligatoire pour un prix fournisseur.'); + $this->requireField($violations, $price, 'supplierSupplyAddress', $price->getSupplierSupplyAddress(), 'L\'adresse d\'approvisionnement est obligatoire pour un prix fournisseur.'); + $this->requireField($violations, $price, 'deliverySite', $price->getDeliverySite(), 'Le site de livraison est obligatoire pour un prix fournisseur.'); + + // RG-4.11 : l'adresse d'appro doit appartenir au fournisseur choisi. + $supplier = $price->getSupplier(); + $address = $price->getSupplierSupplyAddress(); + if (null !== $supplier && null !== $address && $address->getSupplier()?->getId() !== $supplier->getId()) { + $violations->add($this->violation($price, 'supplierSupplyAddress', 'L\'adresse d\'approvisionnement doit appartenir au fournisseur selectionne.')); + } + + // Coherence CHECK chk_carrier_price_supplier_branch : branche client nulle. + $price->setClient(null); + $price->setClientDeliveryAddress(null); + $price->setDepartureSite(null); + } + + if (0 < $violations->count()) { + throw new ValidationException($violations); + } + } + + /** + * Ajoute une violation « champ obligatoire » sur `$path` si la relation est + * absente (branche active, RG-4.10/4.11). + */ + private function requireField(ConstraintViolationList $violations, CarrierPrice $price, string $path, ?object $value, string $message): void + { + if (null === $value) { + $violations->add($this->violation($price, $path, $message)); + } + } + + private function violation(CarrierPrice $price, string $path, string $message): ConstraintViolation + { + return new ConstraintViolation($message, null, [], $price, $path, null); + } +} diff --git a/src/Shared/Domain/Contract/ClientAddressInterface.php b/src/Shared/Domain/Contract/ClientAddressInterface.php index 4f5f6a4..0c52e84 100644 --- a/src/Shared/Domain/Contract/ClientAddressInterface.php +++ b/src/Shared/Domain/Contract/ClientAddressInterface.php @@ -17,4 +17,12 @@ namespace App\Shared\Domain\Contract; interface ClientAddressInterface { public function getId(): ?int; + + /** + * Client parent de l'adresse. Expose le lien inverse sans coupler au module + * Commercial : permet a un autre module de verifier l'appartenance d'une + * adresse a un client (ex: CarrierPrice, RG-4.10 — l'adresse de livraison + * doit appartenir au client choisi). Retour covariant ?Client cote entite. + */ + public function getClient(): ?ClientInterface; } diff --git a/src/Shared/Domain/Contract/SupplierAddressInterface.php b/src/Shared/Domain/Contract/SupplierAddressInterface.php index 4e32c7f..2c5a141 100644 --- a/src/Shared/Domain/Contract/SupplierAddressInterface.php +++ b/src/Shared/Domain/Contract/SupplierAddressInterface.php @@ -17,4 +17,12 @@ namespace App\Shared\Domain\Contract; interface SupplierAddressInterface { public function getId(): ?int; + + /** + * Fournisseur parent de l'adresse. Expose le lien inverse sans coupler au + * module Commercial : permet a un autre module de verifier l'appartenance + * d'une adresse a un fournisseur (ex: CarrierPrice, RG-4.11 — l'adresse + * d'appro doit appartenir au fournisseur choisi). Retour covariant ?Supplier. + */ + public function getSupplier(): ?SupplierInterface; } diff --git a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php index 799ff09..74374e7 100644 --- a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php +++ b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php @@ -64,6 +64,11 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase 'Carrier::certificationType' => 'Choice des 5 certifications borne deja les valeurs.', // Le Choice {BENNE,FOND_MOUVANT} borne les valeurs (<= 12). 'Carrier::containerType' => 'Choice {BENNE,FOND_MOUVANT} borne deja les valeurs.', + // Colonnes enum du prix transporteur (M4) : le Choice borne deja les valeurs. + 'CarrierPrice::direction' => 'Choice {CLIENT,FOURNISSEUR} borne deja les valeurs.', + 'CarrierPrice::containerType' => 'Choice {BENNE,FOND_MOUVANT} borne deja les valeurs.', + 'CarrierPrice::pricingUnit' => 'Choice {FORFAIT,TONNE} borne deja les valeurs.', + 'CarrierPrice::priceState' => 'Choice {EN_COURS,VALIDE,NON_VALIDE} borne deja les valeurs.', // Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres. 'Site::color' => 'Regex code hex #RRGGBB borne deja la longueur.', ]; @@ -109,7 +114,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase } /** @var Constraint $constraint */ - $constraint = $attribute->newInstance(); + $constraint = $attribute->newInstance(); $messageProps = $this->messagePropertiesFor($constraint); self::assertNotNull( @@ -180,6 +185,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase foreach ($constraints as $c) { if ($c instanceof Assert\Length) { $length = $c->max; + break; } } @@ -251,7 +257,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase * Liste des proprietes de message a verifier pour une contrainte donnee, ou * null si la contrainte n'est pas geree (le test echoue alors explicitement). * - * @return list|null + * @return null|list */ private function messagePropertiesFor(Constraint $constraint): ?array { @@ -325,7 +331,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase } /** - * @param list $constraints + * @param list $constraints * @param list> $classes */ private function hasAnyConstraint(array $constraints, array $classes): bool diff --git a/tests/Module/Transport/Api/CarrierPriceApiTest.php b/tests/Module/Transport/Api/CarrierPriceApiTest.php new file mode 100644 index 0000000..399430d --- /dev/null +++ b/tests/Module/Transport/Api/CarrierPriceApiTest.php @@ -0,0 +1,276 @@ + 422 ; + * - branche FOURNISSEUR incomplete -> 422 ; + * - adresse de livraison etrangere au client -> 422 ; + * - adresse d'appro etrangere au fournisseur -> 422 ; + * - prix CLIENT / FOURNISSEUR complets -> 201 ; + * - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul). + * + * @internal + */ +final class CarrierPriceApiTest extends AbstractCarrierApiTestCase +{ + private const string PWD = RbacDemoFixtures::DEMO_PASSWORD; + + protected function setUp(): void + { + parent::setUp(); + + // Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin + // qu'en recette), requis pour les tests de permission (bureau/commerciale). + self::bootKernel(); + $application = new Application(self::$kernel); + $application->setAutoExit(false); + $exit = $application->run( + new ArrayInput([ + 'command' => 'app:seed-rbac', + '--with-demo-users' => true, + '--password' => self::PWD, + ]), + new NullOutput(), + ); + self::assertSame(0, $exit, 'app:seed-rbac a echoue (permissions transport.carriers.* synchronisees ?).'); + + self::ensureKernelShutdown(); + } + + public function testIncompleteClientBranchReturns422(): void + { + // RG-4.10 : direction CLIENT sans client / adresse / site de depart -> 422. + $carrier = $this->seedCarrier('Prix Client Incomplet'); + $client = $this->createAdminClient(); + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'direction' => 'CLIENT', + 'containerType' => 'BENNE', + 'pricingUnit' => 'TONNE', + 'price' => '42.50', + 'priceState' => 'VALIDE', + ], + ]); + self::assertResponseStatusCodeSame(422); + } + + public function testIncompleteSupplierBranchReturns422(): void + { + // RG-4.11 : direction FOURNISSEUR sans fournisseur / adresse / site -> 422. + $carrier = $this->seedCarrier('Prix Fournisseur Incomplet'); + $client = $this->createAdminClient(); + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'direction' => 'FOURNISSEUR', + 'containerType' => 'FOND_MOUVANT', + 'pricingUnit' => 'FORFAIT', + 'price' => '320.00', + 'priceState' => 'EN_COURS', + ], + ]); + self::assertResponseStatusCodeSame(422); + } + + public function testForeignClientAddressReturns422(): void + { + // RG-4.10 : l'adresse de livraison doit appartenir au client choisi. + $carrier = $this->seedCarrier('Prix Adresse Etrangere Client'); + $addrA = $this->seedClientWithAddress('Client A'); + $addrB = $this->seedClientWithAddress('Client B'); + $this->getEm()->flush(); + $siteId = $this->aSiteId(); + + $client = $this->createAdminClient(); + $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'direction' => 'CLIENT', + 'client' => '/api/clients/'.$addrA->getClient()?->getId(), + 'clientDeliveryAddress' => '/api/client_addresses/'.$addrB->getId(), // adresse du client B + 'departureSite' => '/api/sites/'.$siteId, + 'containerType' => 'BENNE', + 'pricingUnit' => 'TONNE', + 'price' => '42.50', + 'priceState' => 'VALIDE', + ], + ]); + self::assertResponseStatusCodeSame(422); + } + + public function testForeignSupplierAddressReturns422(): void + { + // RG-4.11 : l'adresse d'appro doit appartenir au fournisseur choisi. + $carrier = $this->seedCarrier('Prix Adresse Etrangere Fournisseur'); + $addrA = $this->seedSupplierWithAddress('Fournisseur A'); + $addrB = $this->seedSupplierWithAddress('Fournisseur B'); + $this->getEm()->flush(); + $siteId = $this->aSiteId(); + + $client = $this->createAdminClient(); + $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'direction' => 'FOURNISSEUR', + 'supplier' => '/api/suppliers/'.$addrA->getSupplier()?->getId(), + 'supplierSupplyAddress' => '/api/supplier_addresses/'.$addrB->getId(), // adresse du fournisseur B + 'deliverySite' => '/api/sites/'.$siteId, + 'containerType' => 'FOND_MOUVANT', + 'pricingUnit' => 'FORFAIT', + 'price' => '320.00', + 'priceState' => 'EN_COURS', + ], + ]); + self::assertResponseStatusCodeSame(422); + } + + public function testValidClientPriceIsCreated(): void + { + $carrier = $this->seedCarrier('Prix Client Valide'); + $addr = $this->seedClientWithAddress('Client OK'); + $this->getEm()->flush(); + $siteId = $this->aSiteId(); + + $client = $this->createAdminClient(); + $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'direction' => 'CLIENT', + 'client' => '/api/clients/'.$addr->getClient()?->getId(), + 'clientDeliveryAddress' => '/api/client_addresses/'.$addr->getId(), + 'departureSite' => '/api/sites/'.$siteId, + 'containerType' => 'BENNE', + 'pricingUnit' => 'TONNE', + 'price' => '42.50', + 'priceState' => 'VALIDE', + ], + ]); + self::assertResponseStatusCodeSame(201); + self::assertJsonContains(['direction' => 'CLIENT', 'priceState' => 'VALIDE']); + } + + public function testValidSupplierPriceIsCreated(): void + { + $carrier = $this->seedCarrier('Prix Fournisseur Valide'); + $addr = $this->seedSupplierWithAddress('Fournisseur OK'); + $this->getEm()->flush(); + $siteId = $this->aSiteId(); + + $client = $this->createAdminClient(); + $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'direction' => 'FOURNISSEUR', + 'supplier' => '/api/suppliers/'.$addr->getSupplier()?->getId(), + 'supplierSupplyAddress' => '/api/supplier_addresses/'.$addr->getId(), + 'deliverySite' => '/api/sites/'.$siteId, + 'containerType' => 'FOND_MOUVANT', + 'pricingUnit' => 'FORFAIT', + 'price' => '320.00', + 'priceState' => 'EN_COURS', + ], + ]); + self::assertResponseStatusCodeSame(201); + self::assertJsonContains(['direction' => 'FOURNISSEUR', 'priceState' => 'EN_COURS']); + } + + public function testPatchAndDeleteSucceedWithManage(): void + { + $price = $this->seedClientPrice('Patch Delete'); + $client = $this->authenticatedClient('bureau', self::PWD); // manage (matrice § 5.2) + + // PATCH (manage) -> 200 + $client->request('PATCH', '/api/carrier_prices/'.$price->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['priceState' => 'NON_VALIDE'], + ]); + self::assertResponseStatusCodeSame(200); + self::assertJsonContains(['priceState' => 'NON_VALIDE']); + + // DELETE (manage) -> 204 + $client->request('DELETE', '/api/carrier_prices/'.$price->getId()); + self::assertResponseStatusCodeSame(204); + } + + public function testWriteForbiddenWithoutManage(): void + { + $price = $this->seedClientPrice('Forbidden'); + $carrier = $price->getCarrier(); + self::assertNotNull($carrier); + $client = $this->authenticatedClient('commerciale', self::PWD); // view seul + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['direction' => 'CLIENT'], + ]); + self::assertResponseStatusCodeSame(403); + + $client->request('PATCH', '/api/carrier_prices/'.$price->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['priceState' => 'VALIDE'], + ]); + self::assertResponseStatusCodeSame(403); + + $client->request('DELETE', '/api/carrier_prices/'.$price->getId()); + self::assertResponseStatusCodeSame(403); + } + + /** Id d'un site fixture (adresse de depart / livraison des prix). */ + private function aSiteId(): int + { + $site = $this->getEm()->getRepository(Site::class)->findOneBy([]); + self::assertNotNull($site, 'Un site fixture est requis (SitesFixtures).'); + $id = $site->getId(); + self::assertNotNull($id); + + return $id; + } + + /** + * Seede un transporteur + un prix CLIENT complet rattache (pour les tests + * PATCH / DELETE). Passe par l'EM directement (le flux d'ecriture est teste + * via l'API ailleurs). + */ + private function seedClientPrice(string $name): CarrierPrice + { + $em = $this->getEm(); + $carrier = $this->seedCarrier($name); + + /** @var ClientAddress $addr */ + $addr = $this->seedClientWithAddress($name); + + $price = new CarrierPrice(); + $price->setCarrier($carrier); + $price->setDirection('CLIENT'); + $price->setClient($addr->getClient()); + $price->setClientDeliveryAddress($addr); + $price->setDepartureSite($em->getRepository(Site::class)->findOneBy([])); + $price->setContainerType('BENNE'); + $price->setPricingUnit('TONNE'); + $price->setPrice('42.50'); + $price->setPriceState('VALIDE'); + $carrier->addPrice($price); + $em->persist($price); + $em->flush(); + + return $price; + } +}