['supplier:read', 'default:read', 'category:read', 'site:read']], provider: SupplierProvider::class, ), new Get( security: "is_granted('commercial.suppliers.view')", // Detail : fournisseur + sous-collections embarquees (contacts / // adresses + leurs sites/categories/contacts). // - supplier:read:accounting est ajoute par SupplierReadGroupContextBuilder // selon la permission (gate les scalaires comptables ET les RIB // embarques), donc volontairement ABSENT ici (parade bug #4 M1). // - category:read / site:read indispensables pour embarquer le // code/name des categories et le name/postalCode des sites (sinon // stub IRI nu — bugs #1/#2 M1). normalizationContext: ['groups' => [ 'supplier:read', 'supplier:item:read', 'category:read', 'site:read', 'default:read', ]], provider: SupplierProvider::class, ), new Post( security: "is_granted('commercial.suppliers.manage')", normalizationContext: ['groups' => ['supplier:read', 'default:read', 'category:read', 'site:read']], denormalizationContext: ['groups' => ['supplier:write:main']], processor: SupplierProcessor::class, ), new Patch( // Security elargie : `manage` OU `accounting.manage`. Le role Compta // n'a pas `manage` mais doit pouvoir editer l'onglet Comptabilite // d'un fournisseur existant (§ 2.9). Le SupplierProcessor re-gate // ensuite onglet par onglet (mode strict RG-2.16) : // - champs accounting -> accounting.manage (guardAccounting) ; // - champs main/information -> manage (guardManage : empeche Compta // d'editer les autres onglets) ; // - isArchived -> archive (guardArchive, RG-2.14). security: "is_granted('commercial.suppliers.manage') or is_granted('commercial.suppliers.accounting.manage')", normalizationContext: ['groups' => ['supplier:read', 'default:read', 'category:read', 'site:read']], denormalizationContext: ['groups' => [ 'supplier:write:main', 'supplier:write:information', 'supplier:write:accounting', 'supplier:write:archive', ]], provider: SupplierProvider::class, processor: SupplierProcessor::class, ), // Pas de Delete au M2 (HP-M3-1). Archivage via PATCH { isArchived: true }. ], )] #[ORM\Entity(repositoryClass: DoctrineSupplierRepository::class)] #[ORM\Table(name: 'supplier')] // Index nommes pour matcher la migration (Version20260605130000). L'index unique // partiel uq_supplier_company_name_active reste possede par la migration : // Doctrine ORM ne sait pas exprimer un index fonctionnel (LOWER) + partiel // (WHERE) via attribut. Pas de #[ORM\UniqueConstraint] (§ 2.6). #[ORM\Index(name: 'idx_supplier_is_archived', columns: ['is_archived'])] #[ORM\Index(name: 'idx_supplier_deleted_at', columns: ['deleted_at'])] #[ORM\Index(name: 'idx_supplier_created_by', columns: ['created_by'])] #[ORM\Index(name: 'idx_supplier_updated_by', columns: ['updated_by'])] #[Auditable] class Supplier implements TimestampableInterface, BlamableInterface { use TimestampableBlamableTrait; /** * RG-2.10 : seules les categories PORTANT ce type sont autorisees sur le * fournisseur (entite principale). Miroir de SupplierAddress (ERP-88). * S'appuie sur CategoryInterface::getCategoryTypeCodes() (pas d'import du * module Catalog — regle ABSOLUE n°1). */ private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR'; /** RG-2.07 : code du type de reglement imposant une banque. */ private const string PAYMENT_TYPE_VIREMENT = 'VIREMENT'; /** RG-2.08 : code du type de reglement imposant au moins un RIB. */ private const string PAYMENT_TYPE_LCR = 'LCR'; #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['supplier:read'])] private ?int $id = null; // === Formulaire principal === #[ORM\Column(length: 180)] #[Assert\NotBlank(message: 'Le nom du fournisseur est obligatoire.', normalizer: 'trim')] #[Assert\Length(min: 2, max: 180, minMessage: 'Le nom du fournisseur doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom du fournisseur ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['supplier:read', 'supplier:write:main'])] private ?string $companyName = null; // RG : au moins une categorie (Count min 1), de type FOURNISSEUR (RG-2.10, // verifiee au Processor/Validator a ERP-89). M2M vers Category via le contrat // CategoryInterface (resolve_target_entities -> Category). Embarquee en LISTE // ET DETAIL (coherence M1/ERP-62) ; maillon (c) : le contexte inclut // 'category:read' pour exposer id/code/name. /** @var Collection */ #[ORM\ManyToMany(targetEntity: CategoryInterface::class)] #[ORM\JoinTable(name: 'supplier_category')] #[ORM\JoinColumn(name: 'supplier_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] #[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')] #[Groups(['supplier:read', 'supplier:write:main'])] private Collection $categories; // === Onglet Information === #[ORM\Column(type: 'text', nullable: true)] #[Groups(['supplier:read', 'supplier:write:information'])] private ?string $description = null; #[ORM\Column(length: 255, nullable: true)] #[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['supplier:read', 'supplier:write:information'])] private ?string $competitors = null; #[ORM\Column(type: 'date_immutable', nullable: true)] #[Groups(['supplier:read', 'supplier:write:information'])] private ?DateTimeImmutable $foundedAt = null; #[ORM\Column(nullable: true)] #[Assert\PositiveOrZero(message: 'L\'effectif doit être un nombre positif ou nul.')] #[Groups(['supplier:read', 'supplier:write:information'])] private ?int $employeesCount = null; #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)] #[Groups(['supplier:read', 'supplier:write:information'])] private ?string $revenueAmount = null; #[ORM\Column(length: 120, nullable: true)] #[Assert\Length(max: 120, maxMessage: 'Le nom du dirigeant ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['supplier:read', 'supplier:write:information'])] private ?string $directorName = null; #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)] #[Groups(['supplier:read', 'supplier:write:information'])] private ?string $profitAmount = null; // NEW vs Client : Volume previsionnel (entier). #[ORM\Column(nullable: true)] #[Assert\PositiveOrZero(message: 'Le volume prévisionnel doit être un nombre positif ou nul.')] #[Groups(['supplier:read', 'supplier:write:information'])] private ?int $volumeForecast = null; // === Onglet Comptabilite === // Lecture conditionnee via le groupe `supplier:read:accounting` (ajoute au // contexte par le SupplierReadGroupContextBuilder si l'user a accounting.view, // ERP-87 — un Provider ne peut pas influencer les groupes de serialisation). // Ecriture via `supplier:write:accounting` (le Processor exige accounting.manage). #[ORM\Column(length: 20, nullable: true)] #[Assert\Length(max: 20, maxMessage: 'Le SIREN ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] private ?string $siren = null; #[ORM\Column(length: 40, nullable: true)] #[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] private ?string $accountNumber = null; #[ORM\ManyToOne(targetEntity: TvaMode::class)] #[ORM\JoinColumn(name: 'tva_mode_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] private ?TvaMode $tvaMode = null; #[ORM\Column(length: 40, nullable: true)] #[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] private ?string $nTva = null; #[ORM\ManyToOne(targetEntity: PaymentDelay::class)] #[ORM\JoinColumn(name: 'payment_delay_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] private ?PaymentDelay $paymentDelay = null; #[ORM\ManyToOne(targetEntity: PaymentType::class)] #[ORM\JoinColumn(name: 'payment_type_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] private ?PaymentType $paymentType = null; #[ORM\ManyToOne(targetEntity: Bank::class)] #[ORM\JoinColumn(name: 'bank_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] private ?Bank $bank = null; // === Sous-collections — EMBARQUEES dans le DETAIL (RETEX M1 §2) === // Maillon (a) : le read-group est porte par le GETTER (getContacts / getAddresses // / getRibs) — sans #[Groups], jamais serialisees. Edition via sous-ressources // POST/PATCH/DELETE (ERP-88). /** @var Collection */ #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: SupplierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $contacts; /** @var Collection */ #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: SupplierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $addresses; /** @var Collection */ #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: SupplierRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $ribs; // === Archive / Soft delete === // Groupe d'ECRITURE uniquement sur la propriete (denormalisation PATCH archive). // Le groupe de LECTURE est declare sur le getter isArchived() avec // SerializedName('isArchived') : sans cela, Symfony strip le prefixe "is" et // exposerait la cle JSON "archived" — en pratique la cle est totalement // DROPPEE (piege n°3 du M1). Pattern corrige : Groups + SerializedName sur le getter. #[ORM\Column(name: 'is_archived', options: ['default' => false])] #[Groups(['supplier:write:archive'])] private bool $isArchived = false; #[ORM\Column(type: 'datetime_immutable', nullable: true)] #[Groups(['supplier:read'])] private ?DateTimeImmutable $archivedAt = null; // Soft delete technique (HP M3) : non expose en lecture/ecriture au M2. #[ORM\Column(type: 'datetime_immutable', nullable: true)] private ?DateTimeImmutable $deletedAt = null; public function __construct() { $this->categories = new ArrayCollection(); $this->contacts = new ArrayCollection(); $this->addresses = new ArrayCollection(); $this->ribs = new ArrayCollection(); } /** * RG-2.10 : toute categorie posee sur le fournisseur doit etre de type * FOURNISSEUR -> sinon 422 avec violation sur le champ `categories` * (propertyPath aligne ERP-101, message FR ERP-107). Miroir de * SupplierAddress::validateCategoryType (ERP-88). S'appuie sur * CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est * acceptee des qu'elle PORTE le type FOURNISSEUR ; pas d'import du module * Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API * Platform, sur POST (categories ∈ supplier:write:main) comme sur PATCH. */ #[Assert\Callback] public function validateCategoryType(ExecutionContextInterface $context): void { foreach ($this->categories as $category) { if ($category instanceof CategoryInterface && !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) { $context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).') ->atPath('categories') ->addViolation() ; return; } } } /** * RG-2.07 / RG-2.08 : coherence du type de reglement comptable. Decision * figee ERP-89 : ces RG inter-champs passent par une contrainte d'entite * (Assert\Callback + ->atPath()) et NON par le SupplierProcessor, afin que * chaque 422 porte un propertyPath exploitable par extractApiViolations * (mapping inline sous le champ, pas un toast — convention ERP-101). * - RG-2.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`. * - RG-2.08 : paymentType = LCR impose au moins un RIB -> violation sur `ribs` * (le 409 sur DELETE du dernier RIB en LCR est porte par ERP-88). * * Ces champs vivant dans le groupe d'ecriture comptable (absent du POST, qui * n'expose que supplier:write:main), la contrainte ne mord en pratique que * sur le PATCH de l'onglet Comptabilite. */ #[Assert\Callback] public function validatePaymentTypeConsistency(ExecutionContextInterface $context): void { $paymentCode = $this->paymentType?->getCode(); if (self::PAYMENT_TYPE_VIREMENT === $paymentCode && null === $this->bank) { $context->buildViolation('La banque est obligatoire pour le type de règlement Virement.') ->atPath('bank') ->addViolation() ; } if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) { $context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.') ->atPath('ribs') ->addViolation() ; } } public function getId(): ?int { return $this->id; } public function getCompanyName(): ?string { return $this->companyName; } public function setCompanyName(string $companyName): static { $this->companyName = $companyName; 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; } public function getDescription(): ?string { return $this->description; } public function setDescription(?string $description): static { $this->description = $description; return $this; } public function getCompetitors(): ?string { return $this->competitors; } public function setCompetitors(?string $competitors): static { $this->competitors = $competitors; return $this; } public function getFoundedAt(): ?DateTimeImmutable { return $this->foundedAt; } public function setFoundedAt(?DateTimeImmutable $foundedAt): static { $this->foundedAt = $foundedAt; return $this; } public function getEmployeesCount(): ?int { return $this->employeesCount; } public function setEmployeesCount(?int $employeesCount): static { $this->employeesCount = $employeesCount; return $this; } public function getRevenueAmount(): ?string { return $this->revenueAmount; } public function setRevenueAmount(?string $revenueAmount): static { $this->revenueAmount = $revenueAmount; return $this; } public function getDirectorName(): ?string { return $this->directorName; } public function setDirectorName(?string $directorName): static { $this->directorName = $directorName; return $this; } public function getProfitAmount(): ?string { return $this->profitAmount; } public function setProfitAmount(?string $profitAmount): static { $this->profitAmount = $profitAmount; return $this; } public function getVolumeForecast(): ?int { return $this->volumeForecast; } public function setVolumeForecast(?int $volumeForecast): static { $this->volumeForecast = $volumeForecast; return $this; } public function getSiren(): ?string { return $this->siren; } public function setSiren(?string $siren): static { $this->siren = $siren; return $this; } public function getAccountNumber(): ?string { return $this->accountNumber; } public function setAccountNumber(?string $accountNumber): static { $this->accountNumber = $accountNumber; return $this; } public function getTvaMode(): ?TvaMode { return $this->tvaMode; } public function setTvaMode(?TvaMode $tvaMode): static { $this->tvaMode = $tvaMode; return $this; } public function getNTva(): ?string { return $this->nTva; } public function setNTva(?string $nTva): static { $this->nTva = $nTva; return $this; } public function getPaymentDelay(): ?PaymentDelay { return $this->paymentDelay; } public function setPaymentDelay(?PaymentDelay $paymentDelay): static { $this->paymentDelay = $paymentDelay; return $this; } public function getPaymentType(): ?PaymentType { return $this->paymentType; } public function setPaymentType(?PaymentType $paymentType): static { $this->paymentType = $paymentType; return $this; } public function getBank(): ?Bank { return $this->bank; } public function setBank(?Bank $bank): static { $this->bank = $bank; return $this; } /** @return Collection */ #[Groups(['supplier:item:read'])] public function getContacts(): Collection { return $this->contacts; } public function addContact(SupplierContact $contact): static { if (!$this->contacts->contains($contact)) { $this->contacts->add($contact); $contact->setSupplier($this); } return $this; } public function removeContact(SupplierContact $contact): static { if ($this->contacts->removeElement($contact) && $contact->getSupplier() === $this) { $contact->setSupplier(null); } return $this; } /** @return Collection */ #[Groups(['supplier:item:read'])] public function getAddresses(): Collection { return $this->addresses; } public function addAddress(SupplierAddress $address): static { if (!$this->addresses->contains($address)) { $this->addresses->add($address); $address->setSupplier($this); } return $this; } public function removeAddress(SupplierAddress $address): static { if ($this->addresses->removeElement($address) && $address->getSupplier() === $this) { $address->setSupplier(null); } return $this; } /** * Sites distincts rattaches a au moins une adresse du fournisseur (RG-2.06). * Le fournisseur ne porte pas de sites en propre : ils vivent sur les * adresses. Agrege en lecture seule pour la colonne « Site(s) » du Repertoire * (badges colores) — expose en LISTE via le groupe supplier:read (les adresses * completes restent reservees au detail, supplier:item:read). Site n'a pas de * champ `code` : libelle = `name`, prefixe = `postalCode` (§ 2.4 / § 4.0.ter). * * Fetch-join obligatoire (addresses.sites) cote repository pour eviter le N+1 * a la serialisation de la liste (cf. DoctrineSupplierRepository, § 2.12). * * @return list */ #[Groups(['supplier:read'])] public function getSites(): array { $sites = []; foreach ($this->addresses as $address) { foreach ($address->getSites() as $site) { // Deduplication par identite d'objet : un meme site peut etre // rattache a plusieurs adresses du fournisseur. $sites[spl_object_id($site)] = $site; } } return array_values($sites); } // Embed gate sur le groupe COMPTABLE (et non supplier:item:read comme contacts/ // adresses) : supplier:read:accounting n'est ajoute au contexte que si l'user a // accounting.view (SupplierReadGroupContextBuilder, ERP-87). Resultat : la cle `ribs` est // TOTALEMENT ABSENTE du detail pour un user sans accounting.view (ex. Commerciale), // au meme titre que les scalaires comptables — evite la fuite IBAN/BIC (piege n°4 M1). /** @return Collection */ #[Groups(['supplier:read:accounting'])] public function getRibs(): Collection { return $this->ribs; } public function addRib(SupplierRib $rib): static { if (!$this->ribs->contains($rib)) { $this->ribs->add($rib); $rib->setSupplier($this); } return $this; } public function removeRib(SupplierRib $rib): static { if ($this->ribs->removeElement($rib) && $rib->getSupplier() === $this) { $rib->setSupplier(null); } return $this; } // Groupe de lecture + nom serialise explicite : sans SerializedName, Symfony // exposerait la cle "archived" (strip du prefixe "is" sur les getters) et // droppait silencieusement la cle du JSON (piege n°3 du M1). #[Groups(['supplier:read'])] #[SerializedName('isArchived')] public function isArchived(): bool { return $this->isArchived; } public function setIsArchived(bool $isArchived): static { $this->isArchived = $isArchived; return $this; } public function getArchivedAt(): ?DateTimeImmutable { return $this->archivedAt; } public function setArchivedAt(?DateTimeImmutable $archivedAt): static { $this->archivedAt = $archivedAt; return $this; } public function getDeletedAt(): ?DateTimeImmutable { return $this->deletedAt; } public function setDeletedAt(?DateTimeImmutable $deletedAt): static { $this->deletedAt = $deletedAt; return $this; } }