['supplier:item:read', 'site:read', 'category:read', 'default:read']], ), new Post( uriTemplate: '/suppliers/{supplierId}/addresses', uriVariables: [ 'supplierId' => new Link(fromClass: Supplier::class, toProperty: 'supplier'), ], // read:false : pas de stade lecture du parent. Le Link toProperty // resoudrait l'enfant (SELECT SupplierAddress ... WHERE supplier = :id) // et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache // manuellement par SupplierAddressProcessor::linkParent (404 si absent). read: false, security: "is_granted('commercial.suppliers.manage')", normalizationContext: ['groups' => ['supplier:item:read', 'site:read', 'category:read', 'default:read']], denormalizationContext: ['groups' => ['supplier:write:addresses']], processor: SupplierAddressProcessor::class, ), new Patch( security: "is_granted('commercial.suppliers.manage')", normalizationContext: ['groups' => ['supplier:item:read', 'site:read', 'category:read', 'default:read']], denormalizationContext: ['groups' => ['supplier:write:addresses']], processor: SupplierAddressProcessor::class, ), new Delete( security: "is_granted('commercial.suppliers.manage')", processor: SupplierAddressProcessor::class, ), ], )] #[ORM\Entity(repositoryClass: DoctrineSupplierAddressRepository::class)] #[ORM\Table(name: 'supplier_address')] #[ORM\Index(name: 'idx_supplier_address_supplier', columns: ['supplier_id'])] #[Auditable] class SupplierAddress implements TimestampableInterface, BlamableInterface { use TimestampableBlamableTrait; /** * Valeurs autorisees de address_type (RG-2.09). Miroir applicatif du CHECK BDD * chk_supplier_address_type : alimente l'Assert\Choice (422 propre rattachee * au champ avant la base) et reste la source des options cote front. */ public const array ADDRESS_TYPES = ['PROSPECT', 'DEPART', 'RENDU']; /** * RG-2.10 : seules les categories de ce type sont autorisees sur une adresse * fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCode() (pas * d'import du module Catalog — regle ABSOLUE n°1). */ private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR'; #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['supplier:item:read'])] private ?int $id = null; #[ORM\ManyToOne(targetEntity: Supplier::class, inversedBy: 'addresses')] #[ORM\JoinColumn(name: 'supplier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] private ?Supplier $supplier = null; // RG-2.09 : enum exclusif. La valeur est bornee par Assert\Choice (longueur de // fait <= 8), d'ou la whitelist du miroir Assert\Length == ORM length (ERP-107, // EntityConstraintsHaveFrenchMessageTest::EXCLUDED_LENGTH_MIRROR). #[ORM\Column(length: 20)] #[Assert\NotBlank(message: 'Le type d\'adresse est obligatoire.', normalizer: 'trim')] #[Assert\Choice(choices: self::ADDRESS_TYPES, message: 'Le type d\'adresse doit être Prospect, Départ ou Rendu.')] #[Groups(['supplier:item:read', 'supplier:write:addresses'])] private ?string $addressType = null; #[ORM\Column(length: 80, options: ['default' => 'France'])] #[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['supplier:item:read', 'supplier:write:addresses'])] private string $country = 'France'; // RG-2.05 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur). // Le Regex borne deja la longueur (<= 5) : pas de Length redondant (whitelist). #[ORM\Column(length: 20)] #[Assert\NotBlank(message: 'Le code postal est obligatoire.', normalizer: 'trim')] #[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')] #[Groups(['supplier:item:read', 'supplier:write:addresses'])] private ?string $postalCode = null; #[ORM\Column(length: 120)] #[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['supplier:item:read', 'supplier:write:addresses'])] private ?string $city = null; #[ORM\Column(length: 255)] #[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['supplier:item:read', 'supplier:write:addresses'])] private ?string $street = null; #[ORM\Column(length: 255, nullable: true)] #[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['supplier:item:read', 'supplier:write:addresses'])] private ?string $streetComplement = null; // Specifique fournisseur : nombre de bennes sur le site. #[ORM\Column(nullable: true)] #[Assert\PositiveOrZero(message: 'Le nombre de bennes doit être un nombre positif ou nul.')] #[Groups(['supplier:item:read', 'supplier:write:addresses'])] private ?int $bennes = null; // Specifique fournisseur : prestataire de triage sur cette adresse. Groupe // d'ECRITURE uniquement sur la propriete ; le groupe de LECTURE est porte par // le getter isTriageProvider() avec SerializedName('triageProvider') — sinon // Symfony strip le prefixe "is" et droppe la cle (piege n°3 du M1). #[ORM\Column(name: 'triage_provider', options: ['default' => false])] #[Groups(['supplier:write:addresses'])] private bool $triageProvider = false; // Ordre d'affichage de l'adresse (gere serveur, non expose au M2). #[ORM\Column(options: ['default' => 0])] private int $position = 0; // RG-2.06 : au moins un site rattache a chaque adresse. /** @var Collection */ #[ORM\ManyToMany(targetEntity: SiteInterface::class)] #[ORM\JoinTable(name: 'supplier_address_site')] #[ORM\JoinColumn(name: 'supplier_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] #[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')] #[Groups(['supplier:item:read', 'supplier:write:addresses'])] private Collection $sites; /** @var Collection */ #[ORM\ManyToMany(targetEntity: SupplierContact::class)] #[ORM\JoinTable(name: 'supplier_address_contact')] #[ORM\JoinColumn(name: 'supplier_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\InverseJoinColumn(name: 'supplier_contact_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[Groups(['supplier:item:read', 'supplier:write:addresses'])] private Collection $contacts; // RG-2.10 : categories d'adresse de type FOURNISSEUR (controle au Processor). /** @var Collection */ #[ORM\ManyToMany(targetEntity: CategoryInterface::class)] #[ORM\JoinTable(name: 'supplier_address_category')] #[ORM\JoinColumn(name: 'supplier_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] #[Groups(['supplier:item:read', 'supplier:write:addresses'])] private Collection $categories; public function __construct() { $this->sites = new ArrayCollection(); $this->contacts = new ArrayCollection(); $this->categories = new ArrayCollection(); } /** * RG-2.10 : toute categorie posee sur une adresse fournisseur doit etre de * type FOURNISSEUR -> sinon 422 avec violation sur le champ `categories` * (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur * CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog — * regle ABSOLUE n°1). Joue avant la base via la validation API Platform. */ #[Assert\Callback] public function validateCategoryType(ExecutionContextInterface $context): void { foreach ($this->categories as $category) { if ($category instanceof CategoryInterface && self::REQUIRED_CATEGORY_TYPE_CODE !== $category->getCategoryTypeCode()) { $context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).') ->atPath('categories') ->addViolation() ; return; } } } public function getId(): ?int { return $this->id; } public function getSupplier(): ?Supplier { return $this->supplier; } public function setSupplier(?Supplier $supplier): static { $this->supplier = $supplier; return $this; } public function getAddressType(): ?string { return $this->addressType; } public function setAddressType(?string $addressType): static { $this->addressType = $addressType; return $this; } public function getCountry(): string { return $this->country; } public function setCountry(string $country): static { $this->country = $country; return $this; } public function getPostalCode(): ?string { return $this->postalCode; } public function setPostalCode(?string $postalCode): static { $this->postalCode = $postalCode; return $this; } public function getCity(): ?string { return $this->city; } public function setCity(?string $city): static { $this->city = $city; return $this; } public function getStreet(): ?string { return $this->street; } public function setStreet(?string $street): static { $this->street = $street; return $this; } public function getStreetComplement(): ?string { return $this->streetComplement; } public function setStreetComplement(?string $streetComplement): static { $this->streetComplement = $streetComplement; return $this; } public function getBennes(): ?int { return $this->bennes; } public function setBennes(?int $bennes): static { $this->bennes = $bennes; return $this; } // Groupe de lecture + nom serialise explicite (cf. note sur la propriete) : // sans SerializedName, Symfony exposerait la cle "triage" (strip du prefixe // "is") et, le groupe etant sur la propriete `triageProvider`, droppait // silencieusement la cle du JSON. #[Groups(['supplier:item:read'])] #[SerializedName('triageProvider')] public function isTriageProvider(): bool { return $this->triageProvider; } public function setTriageProvider(bool $triageProvider): static { $this->triageProvider = $triageProvider; return $this; } public function getPosition(): int { return $this->position; } public function setPosition(int $position): static { $this->position = $position; return $this; } /** @return Collection */ public function getSites(): Collection { return $this->sites; } public function addSite(SiteInterface $site): static { if (!$this->sites->contains($site)) { $this->sites->add($site); } return $this; } public function removeSite(SiteInterface $site): static { $this->sites->removeElement($site); return $this; } /** @return Collection */ public function getContacts(): Collection { return $this->contacts; } public function addContact(SupplierContact $contact): static { if (!$this->contacts->contains($contact)) { $this->contacts->add($contact); } return $this; } public function removeContact(SupplierContact $contact): static { $this->contacts->removeElement($contact); return $this; } /** @return Collection */ public function getCategories(): Collection { return $this->categories; } public function addCategory(CategoryInterface $category): static { if (!$this->categories->contains($category)) { $this->categories->add($category); } return $this; } public function removeCategory(CategoryInterface $category): static { $this->categories->removeElement($category); return $this; } }