['client:read', 'default:read']], provider: ClientProvider::class, ), new Get( security: "is_granted('commercial.clients.view')", // Detail : client + sous-collections embarquees. Le groupe // client:read:accounting est ajoute par le context builder selon la // permission, donc absent ici volontairement. normalizationContext: ['groups' => [ 'client:read', 'client:item:read', 'client_contact:read', 'client_address:read', 'client_rib:read', 'default:read', ]], provider: ClientProvider::class, ), new Post( security: "is_granted('commercial.clients.manage')", normalizationContext: ['groups' => ['client:read', 'default:read']], denormalizationContext: ['groups' => ['client:write:main']], processor: ClientProcessor::class, ), new Patch( // Security elargie (ERP-74) : `manage` OU `accounting.manage`. Le // role Compta n'a pas `manage` mais doit pouvoir editer l'onglet // Comptabilite d'un client existant (§ 2.7). Le ClientProcessor // re-gate ensuite onglet par onglet : // - champs accounting -> accounting.manage (guardAccounting, RG-1.28) ; // - champs main/information -> manage (guardManage : empeche Compta // d'editer les autres onglets) ; // - isArchived -> archive (guardArchive, RG-1.22). security: "is_granted('commercial.clients.manage') or is_granted('commercial.clients.accounting.manage')", // Le ClientProcessor inspecte les champs reellement envoyes pour // autoriser/refuser onglet par onglet (RG-1.22 / RG-1.28) : les // champs accounting exigent accounting.manage, isArchived exige // archive, le reste (main/information) exige manage. normalizationContext: ['groups' => ['client:read', 'default:read']], denormalizationContext: ['groups' => [ 'client:write:main', 'client:write:information', 'client:write:accounting', 'client:write:archive', ]], provider: ClientProvider::class, processor: ClientProcessor::class, ), ], )] #[ORM\Entity(repositoryClass: DoctrineClientRepository::class)] #[ORM\Table(name: 'client')] // Index nommes pour matcher la migration (Version20260601000000). L'index // unique partiel uq_client_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] (decision Q4). #[ORM\Index(name: 'idx_client_is_archived', columns: ['is_archived'])] #[ORM\Index(name: 'idx_client_deleted_at', columns: ['deleted_at'])] #[ORM\Index(name: 'idx_client_distributor_id', columns: ['distributor_id'])] #[ORM\Index(name: 'idx_client_broker_id', columns: ['broker_id'])] #[ORM\Index(name: 'idx_client_created_by', columns: ['created_by'])] #[ORM\Index(name: 'idx_client_updated_by', columns: ['updated_by'])] #[Auditable] class Client implements TimestampableInterface, BlamableInterface { use TimestampableBlamableTrait; #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['client:read'])] private ?int $id = null; // === Formulaire principal === #[ORM\Column(length: 180)] #[Assert\NotBlank(message: 'Le nom de l\'entreprise est obligatoire.', normalizer: 'trim')] #[Assert\Length(min: 2, max: 180, normalizer: 'trim')] #[Groups(['client:read', 'client:write:main'])] private ?string $companyName = null; // RG-1.01 : firstName OU lastName obligatoire (validation au futur Processor). #[ORM\Column(length: 120, nullable: true)] #[Assert\Length(max: 120, normalizer: 'trim')] #[Groups(['client:read', 'client:write:main'])] private ?string $firstName = null; #[ORM\Column(length: 120, nullable: true)] #[Assert\Length(max: 120, normalizer: 'trim')] #[Groups(['client:read', 'client:write:main'])] private ?string $lastName = null; #[ORM\Column(length: 20)] #[Assert\NotBlank] #[Groups(['client:read', 'client:write:main'])] private ?string $phonePrimary = null; #[ORM\Column(length: 20, nullable: true)] #[Groups(['client:read', 'client:write:main'])] private ?string $phoneSecondary = null; #[ORM\Column(length: 180)] #[Assert\NotBlank] #[Assert\Email] #[Groups(['client:read', 'client:write:main'])] private ?string $email = null; // RG-1.03 : distributor / broker auto-references mutuellement exclusives // (CHECK chk_client_distrib_or_broker en base). #[ORM\ManyToOne(targetEntity: self::class)] #[ORM\JoinColumn(name: 'distributor_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] #[Groups(['client:read', 'client:write:main'])] private ?Client $distributor = null; #[ORM\ManyToOne(targetEntity: self::class)] #[ORM\JoinColumn(name: 'broker_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] #[Groups(['client:read', 'client:write:main'])] private ?Client $broker = null; #[ORM\Column(name: 'triage_service', options: ['default' => false])] #[Groups(['client:read', 'client:write:main'])] private bool $triageService = false; // RG : au moins une categorie (Count min 1). M2M vers Category via le contrat // CategoryInterface (resolve_target_entities -> Category). /** @var Collection */ #[ORM\ManyToMany(targetEntity: CategoryInterface::class)] #[ORM\JoinTable(name: 'client_category')] #[ORM\JoinColumn(name: 'client_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(['client:read', 'client:write:main'])] private Collection $categories; // === Onglet Information === #[ORM\Column(type: 'text', nullable: true)] #[Groups(['client:read', 'client:write:information'])] private ?string $description = null; #[ORM\Column(length: 255, nullable: true)] #[Groups(['client:read', 'client:write:information'])] private ?string $competitors = null; #[ORM\Column(type: 'date_immutable', nullable: true)] #[Groups(['client:read', 'client:write:information'])] private ?DateTimeImmutable $foundedAt = null; #[ORM\Column(nullable: true)] #[Assert\PositiveOrZero] #[Groups(['client:read', 'client:write:information'])] private ?int $employeesCount = null; #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)] #[Groups(['client:read', 'client:write:information'])] private ?string $revenueAmount = null; #[ORM\Column(length: 120, nullable: true)] #[Groups(['client:read', 'client:write:information'])] private ?string $directorName = null; #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)] #[Groups(['client:read', 'client:write:information'])] private ?string $profitAmount = null; // === Onglet Comptabilite === // Lecture conditionnee via le groupe `client:read:accounting` (ajoute par le // futur Provider si l'user a la permission accounting.view). Ecriture via // `client:write:accounting` (le futur Processor exige accounting.manage). #[ORM\Column(length: 20, nullable: true)] #[Groups(['client:read:accounting', 'client:write:accounting'])] private ?string $siren = null; #[ORM\Column(length: 40, nullable: true)] #[Groups(['client:read:accounting', 'client:write:accounting'])] private ?string $accountNumber = null; #[ORM\ManyToOne(targetEntity: TvaMode::class)] #[ORM\JoinColumn(name: 'tva_mode_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] #[Groups(['client:read:accounting', 'client:write:accounting'])] private ?TvaMode $tvaMode = null; #[ORM\Column(length: 40, nullable: true)] #[Groups(['client:read:accounting', 'client:write:accounting'])] private ?string $nTva = null; #[ORM\ManyToOne(targetEntity: PaymentDelay::class)] #[ORM\JoinColumn(name: 'payment_delay_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] #[Groups(['client:read:accounting', 'client:write:accounting'])] private ?PaymentDelay $paymentDelay = null; #[ORM\ManyToOne(targetEntity: PaymentType::class)] #[ORM\JoinColumn(name: 'payment_type_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] #[Groups(['client:read:accounting', 'client:write:accounting'])] private ?PaymentType $paymentType = null; #[ORM\ManyToOne(targetEntity: Bank::class)] #[ORM\JoinColumn(name: 'bank_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] #[Groups(['client:read:accounting', 'client:write:accounting'])] private ?Bank $bank = null; // === Sous-collections (exposees via sous-ressources API dediees, ulterieur) === /** @var Collection */ #[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $contacts; /** @var Collection */ #[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $addresses; /** @var Collection */ #[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientRib::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" (meme pattern que User::isAdmin // et Role::isSystem). #[ORM\Column(name: 'is_archived', options: ['default' => false])] #[Groups(['client:write:archive'])] private bool $isArchived = false; #[ORM\Column(type: 'datetime_immutable', nullable: true)] #[Groups(['client:read'])] private ?DateTimeImmutable $archivedAt = null; // Soft delete technique (HP-M2-1) : non expose en lecture/ecriture au M1. #[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(); } 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; } public function getFirstName(): ?string { return $this->firstName; } public function setFirstName(?string $firstName): static { $this->firstName = $firstName; return $this; } public function getLastName(): ?string { return $this->lastName; } public function setLastName(?string $lastName): static { $this->lastName = $lastName; return $this; } public function getPhonePrimary(): ?string { return $this->phonePrimary; } public function setPhonePrimary(string $phonePrimary): static { $this->phonePrimary = $phonePrimary; return $this; } public function getPhoneSecondary(): ?string { return $this->phoneSecondary; } public function setPhoneSecondary(?string $phoneSecondary): static { $this->phoneSecondary = $phoneSecondary; return $this; } public function getEmail(): ?string { return $this->email; } public function setEmail(string $email): static { $this->email = $email; return $this; } public function getDistributor(): ?Client { return $this->distributor; } public function setDistributor(?Client $distributor): static { $this->distributor = $distributor; return $this; } public function getBroker(): ?Client { return $this->broker; } public function setBroker(?Client $broker): static { $this->broker = $broker; return $this; } public function isTriageService(): bool { return $this->triageService; } public function setTriageService(bool $triageService): static { $this->triageService = $triageService; 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 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(['client:item:read'])] public function getContacts(): Collection { return $this->contacts; } public function addContact(ClientContact $contact): static { if (!$this->contacts->contains($contact)) { $this->contacts->add($contact); $contact->setClient($this); } return $this; } public function removeContact(ClientContact $contact): static { if ($this->contacts->removeElement($contact) && $contact->getClient() === $this) { $contact->setClient(null); } return $this; } /** @return Collection */ #[Groups(['client:item:read'])] public function getAddresses(): Collection { return $this->addresses; } public function addAddress(ClientAddress $address): static { if (!$this->addresses->contains($address)) { $this->addresses->add($address); $address->setClient($this); } return $this; } public function removeAddress(ClientAddress $address): static { if ($this->addresses->removeElement($address) && $address->getClient() === $this) { $address->setClient(null); } return $this; } /** @return Collection */ #[Groups(['client:item:read'])] public function getRibs(): Collection { return $this->ribs; } public function addRib(ClientRib $rib): static { if (!$this->ribs->contains($rib)) { $this->ribs->add($rib); $rib->setClient($this); } return $this; } public function removeRib(ClientRib $rib): static { if ($this->ribs->removeElement($rib) && $rib->getClient() === $this) { $rib->setClient(null); } return $this; } // Groupe de lecture + nom serialise explicite : sans SerializedName, Symfony // exposerait la cle "archived" (strip du prefixe "is" sur les getters). #[Groups(['client: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; } }