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:
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Module\Transport\Domain\Entity;
|
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\Attribute\Auditable;
|
||||||
use App\Shared\Domain\Contract\BlamableInterface;
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
use App\Shared\Domain\Contract\ClientAddressInterface;
|
use App\Shared\Domain\Contract\ClientAddressInterface;
|
||||||
@@ -15,6 +22,7 @@ use App\Shared\Domain\Contract\TimestampableInterface;
|
|||||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
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
|
* 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 /
|
* (client:read / client_address:read / supplier:read / supplier_address:read /
|
||||||
* site:read), inclus dans le contexte du Get racine de Carrier (§ 4.0).
|
* 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
|
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
|
||||||
* sous-ressources d'ecriture + validation des branches (Processor) : WT8.
|
* 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\Entity]
|
||||||
#[ORM\Table(name: 'carrier_price')]
|
#[ORM\Table(name: 'carrier_price')]
|
||||||
#[ORM\Index(name: 'idx_carrier_price_carrier', columns: ['carrier_id'])]
|
#[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. */
|
/** CLIENT|FOURNISSEUR (RG-4.09) — pilote la branche active. */
|
||||||
#[ORM\Column(length: 12)]
|
#[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;
|
private ?string $direction = null;
|
||||||
|
|
||||||
// === Branche CLIENT (RG-4.10) ===
|
// === 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\ManyToOne(targetEntity: ClientInterface::class)]
|
||||||
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
#[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;
|
private ?ClientInterface $client = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: ClientAddressInterface::class)]
|
#[ORM\ManyToOne(targetEntity: ClientAddressInterface::class)]
|
||||||
#[ORM\JoinColumn(name: 'client_delivery_address_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
#[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;
|
private ?ClientAddressInterface $clientDeliveryAddress = null;
|
||||||
|
|
||||||
/** Adresse de depart = un des 3 sites (86/17/82). */
|
/** Adresse de depart = un des 3 sites (86/17/82). */
|
||||||
#[ORM\ManyToOne(targetEntity: SiteInterface::class)]
|
#[ORM\ManyToOne(targetEntity: SiteInterface::class)]
|
||||||
#[ORM\JoinColumn(name: 'departure_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
#[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;
|
private ?SiteInterface $departureSite = null;
|
||||||
|
|
||||||
// === Branche FOURNISSEUR (RG-4.11) ===
|
// === Branche FOURNISSEUR (RG-4.11) ===
|
||||||
#[ORM\ManyToOne(targetEntity: SupplierInterface::class)]
|
#[ORM\ManyToOne(targetEntity: SupplierInterface::class)]
|
||||||
#[ORM\JoinColumn(name: 'supplier_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
#[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;
|
private ?SupplierInterface $supplier = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: SupplierAddressInterface::class)]
|
#[ORM\ManyToOne(targetEntity: SupplierAddressInterface::class)]
|
||||||
#[ORM\JoinColumn(name: 'supplier_supply_address_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
#[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;
|
private ?SupplierAddressInterface $supplierSupplyAddress = null;
|
||||||
|
|
||||||
/** Adresse de livraison = un des 3 sites (86/17/82). */
|
/** Adresse de livraison = un des 3 sites (86/17/82). */
|
||||||
#[ORM\ManyToOne(targetEntity: SiteInterface::class)]
|
#[ORM\ManyToOne(targetEntity: SiteInterface::class)]
|
||||||
#[ORM\JoinColumn(name: 'delivery_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
#[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;
|
private ?SiteInterface $deliverySite = null;
|
||||||
|
|
||||||
// === Commun ===
|
// === Commun (toujours obligatoires, RG-4.10/4.11) ===
|
||||||
/** BENNE|FOND_MOUVANT. */
|
/** BENNE|FOND_MOUVANT. */
|
||||||
#[ORM\Column(name: 'container_type', length: 12)]
|
#[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;
|
private ?string $containerType = null;
|
||||||
|
|
||||||
/** FORFAIT|TONNE. */
|
/** FORFAIT|TONNE. */
|
||||||
#[ORM\Column(name: 'pricing_unit', length: 8)]
|
#[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;
|
private ?string $pricingUnit = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'decimal', precision: 12, scale: 2)]
|
#[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;
|
private ?string $price = null;
|
||||||
|
|
||||||
/** EN_COURS|VALIDE|NON_VALIDE. */
|
/** EN_COURS|VALIDE|NON_VALIDE. */
|
||||||
#[ORM\Column(name: 'price_state', length: 12)]
|
#[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;
|
private ?string $priceState = null;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
#[ORM\Column(options: ['default' => 0])]
|
||||||
|
|||||||
+170
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,4 +17,12 @@ namespace App\Shared\Domain\Contract;
|
|||||||
interface ClientAddressInterface
|
interface ClientAddressInterface
|
||||||
{
|
{
|
||||||
public function getId(): ?int;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,12 @@ namespace App\Shared\Domain\Contract;
|
|||||||
interface SupplierAddressInterface
|
interface SupplierAddressInterface
|
||||||
{
|
{
|
||||||
public function getId(): ?int;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
|||||||
'Carrier::certificationType' => 'Choice des 5 certifications borne deja les valeurs.',
|
'Carrier::certificationType' => 'Choice des 5 certifications borne deja les valeurs.',
|
||||||
// Le Choice {BENNE,FOND_MOUVANT} borne les valeurs (<= 12).
|
// Le Choice {BENNE,FOND_MOUVANT} borne les valeurs (<= 12).
|
||||||
'Carrier::containerType' => 'Choice {BENNE,FOND_MOUVANT} borne deja les valeurs.',
|
'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.
|
// Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres.
|
||||||
'Site::color' => 'Regex code hex #RRGGBB borne deja la longueur.',
|
'Site::color' => 'Regex code hex #RRGGBB borne deja la longueur.',
|
||||||
];
|
];
|
||||||
@@ -109,7 +114,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @var Constraint $constraint */
|
/** @var Constraint $constraint */
|
||||||
$constraint = $attribute->newInstance();
|
$constraint = $attribute->newInstance();
|
||||||
$messageProps = $this->messagePropertiesFor($constraint);
|
$messageProps = $this->messagePropertiesFor($constraint);
|
||||||
|
|
||||||
self::assertNotNull(
|
self::assertNotNull(
|
||||||
@@ -180,6 +185,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
|||||||
foreach ($constraints as $c) {
|
foreach ($constraints as $c) {
|
||||||
if ($c instanceof Assert\Length) {
|
if ($c instanceof Assert\Length) {
|
||||||
$length = $c->max;
|
$length = $c->max;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,7 +257,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
|||||||
* Liste des proprietes de message a verifier pour une contrainte donnee, ou
|
* 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).
|
* null si la contrainte n'est pas geree (le test echoue alors explicitement).
|
||||||
*
|
*
|
||||||
* @return list<string>|null
|
* @return null|list<string>
|
||||||
*/
|
*/
|
||||||
private function messagePropertiesFor(Constraint $constraint): ?array
|
private function messagePropertiesFor(Constraint $constraint): ?array
|
||||||
{
|
{
|
||||||
@@ -325,7 +331,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<Constraint> $constraints
|
* @param list<Constraint> $constraints
|
||||||
* @param list<class-string<Constraint>> $classes
|
* @param list<class-string<Constraint>> $classes
|
||||||
*/
|
*/
|
||||||
private function hasAnyConstraint(array $constraints, array $classes): bool
|
private function hasAnyConstraint(array $constraints, array $classes): bool
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Transport\Api;
|
||||||
|
|
||||||
|
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||||
|
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
|
||||||
|
use App\Module\Sites\Domain\Entity\Site;
|
||||||
|
use App\Module\Transport\Domain\Entity\CarrierPrice;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
|
use Symfony\Component\Console\Input\ArrayInput;
|
||||||
|
use Symfony\Component\Console\Output\NullOutput;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sous-ressource Prix d'un transporteur (spec-back M4 § 4.5, ERP-161).
|
||||||
|
* POST /api/carriers/{id}/prices, PATCH/DELETE /api/carrier_prices/{id}.
|
||||||
|
*
|
||||||
|
* Contrat verifie (RG-4.09→4.11) :
|
||||||
|
* - branche CLIENT incomplete -> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user