feat(transport) : sous-ressource prix transporteur (ERP-161)

POST /api/carriers/{id}/prices + PATCH/DELETE /api/carrier_prices/{id}
(security transport.carriers.manage) via CarrierPriceProcessor.

RG-4.09->4.11 : coherence de branche CLIENT/FOURNISSEUR (champs requis +
appartenance de l'adresse de livraison au client / de l'adresse d'appro au
fournisseur, sinon 422), nettoyage de la branche opposee (CHECK BDD). Champs
communs obligatoires via Assert\NotBlank + Assert\Choice.

Les contrats Shared ClientAddressInterface / SupplierAddressInterface exposent
desormais getClient() / getSupplier() (canal cross-module, regle n°1) pour la
verification d'appartenance. Colonnes enum du prix whitelistees dans le miroir
Assert\Length (deja bornees par Choice).
This commit is contained in:
Matthieu
2026-06-16 10:42:41 +02:00
parent daa8224b8b
commit 7d2812cea6
6 changed files with 570 additions and 17 deletions
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Entity\CarrierPrice;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture de la sous-ressource Prix d'un transporteur (M4,
* spec-back § 4.5, ERP-161). Jumeau des CarrierAddressProcessor / CarrierContactProcessor.
*
* Sequence :
* - POST / PATCH : rattachement au transporteur parent (linkParent) puis
* validation de la coherence de branche CLIENT/FOURNISSEUR (RG-4.09→4.11).
* - DELETE : suppression physique directe (aucune regle metier specifique).
*
* RG-4.10 (branche CLIENT) : `client`, `clientDeliveryAddress`, `departureSite`
* obligatoires ; l'adresse de livraison doit appartenir au client choisi.
* RG-4.11 (branche FOURNISSEUR) : `supplier`, `supplierSupplyAddress`,
* `deliverySite` obligatoires ; l'adresse d'appro doit appartenir au fournisseur.
* Ces RG vivent ICI (et non en contrainte d'attribut) car elles dependent de
* relations resolues a la denormalisation (et le parent carrier est indisponible
* en validation Symfony sur un POST sous-ressource read:false). On nettoie aussi
* la branche opposee (les CHECK BDD imposent ses colonnes nulles) — transforme une
* violation SQL (500) en 422 propre rattachee au champ (mapping inline ERP-101).
*
* Les champs communs obligatoires (direction, containerType, pricingUnit, price,
* priceState) sont valides en amont par les contraintes d'attribut (Assert\NotBlank
* + Assert\Choice), de meme que la security d'operation (transport.carriers.manage).
*
* @implements ProcessorInterface<CarrierPrice, null|CarrierPrice>
*/
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);
}
}