feat(transport) : adresse unique par transporteur (OneToOne back + un seul bloc front) (ERP-172)

This commit is contained in:
2026-06-17 17:32:29 +02:00
parent 498cef8cc0
commit e76bd1dd63
14 changed files with 219 additions and 225 deletions
+13 -21
View File
@@ -198,10 +198,13 @@ class Carrier implements TimestampableInterface, BlamableInterface
#[Groups(['carrier:read', 'carrier:write:main'])]
private ?string $liotPlates = null;
// === Adresse UNIQUE (OneToOne) — EMBARQUEE dans le DETAIL (read-group sur le getter) ===
// Metier : un transporteur a au plus UNE adresse (decision metier ERP-172). La
// FK porte un index UNIQUE (cote CarrierAddress.carrier en OneToOne owning side).
#[ORM\OneToOne(mappedBy: 'carrier', targetEntity: CarrierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private ?CarrierAddress $address = null;
// === Sous-collections — EMBARQUEES dans le DETAIL (read-group sur le getter) ===
/** @var Collection<int, CarrierAddress> */
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $addresses;
/** @var Collection<int, CarrierContact> */
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
@@ -228,9 +231,8 @@ class Carrier implements TimestampableInterface, BlamableInterface
public function __construct()
{
$this->addresses = new ArrayCollection();
$this->contacts = new ArrayCollection();
$this->prices = new ArrayCollection();
$this->contacts = new ArrayCollection();
$this->prices = new ArrayCollection();
}
/**
@@ -409,32 +411,22 @@ class Carrier implements TimestampableInterface, BlamableInterface
return $this;
}
/** @return Collection<int, CarrierAddress> */
#[Groups(['carrier:item:read'])]
public function getAddresses(): Collection
public function getAddress(): ?CarrierAddress
{
return $this->addresses;
return $this->address;
}
public function addAddress(CarrierAddress $address): static
public function setAddress(?CarrierAddress $address): static
{
if (!$this->addresses->contains($address)) {
$this->addresses->add($address);
$this->address = $address;
if (null !== $address && $address->getCarrier() !== $this) {
$address->setCarrier($this);
}
return $this;
}
public function removeAddress(CarrierAddress $address): static
{
if ($this->addresses->removeElement($address) && $address->getCarrier() === $this) {
$address->setCarrier(null);
}
return $this;
}
/** @return Collection<int, CarrierContact> */
#[Groups(['carrier:item:read'])]
public function getContacts(): Collection
@@ -58,14 +58,13 @@ use Symfony\Component\Validator\Constraints as Assert;
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
),
new Post(
uriTemplate: '/carriers/{carrierId}/addresses',
uriTemplate: '/carriers/{carrierId}/address',
uriVariables: [
'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'),
],
// read:false : pas de stade lecture du parent. Le Link toProperty
// resoudrait l'enfant (SELECT CarrierAddress ... WHERE carrier = :id)
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
// manuellement par CarrierAddressProcessor::linkParent (404 si absent).
// read:false : pas de stade lecture du parent. Le parent est rattache
// manuellement par CarrierAddressProcessor::linkParent (404 si absent),
// qui refuse aussi une 2e adresse (RG metier : adresse UNIQUE — 409).
read: false,
security: "is_granted('transport.carriers.manage')",
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
@@ -86,7 +85,9 @@ use Symfony\Component\Validator\Constraints as Assert;
)]
#[ORM\Entity]
#[ORM\Table(name: 'carrier_address')]
#[ORM\Index(name: 'idx_carrier_address_carrier', columns: ['carrier_id'])]
// Adresse UNIQUE par transporteur (OneToOne owning side) : contrainte d'unicite
// sur carrier_id (decision metier ERP-172).
#[ORM\UniqueConstraint(name: 'uniq_carrier_address_carrier', columns: ['carrier_id'])]
#[ORM\Index(name: 'idx_carrier_address_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_carrier_address_updated_by', columns: ['updated_by'])]
#[Auditable]
@@ -100,7 +101,7 @@ class CarrierAddress implements TimestampableInterface, BlamableInterface
#[Groups(['carrier:item:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Carrier::class, inversedBy: 'addresses')]
#[ORM\OneToOne(targetEntity: Carrier::class, inversedBy: 'address')]
#[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Carrier $carrier = null;