feat(commercial) : add M1 client entities + accounting referentials + repositories

Entites metier (Client, ClientContact, ClientAddress, ClientRib) avec
#[Auditable] + Timestampable/Blamable, et 4 referentiels comptables statiques
(TvaMode, PaymentDelay, PaymentType, Bank). 8 repositories interfaces + impl
Doctrine. Aucun ApiResource (Provider/Processor = ERP-55).

- Client : 2 FK auto-referentes distributor/broker (mutuellement exclusives,
  CHECK en base), M2M categories, FK referentiels comptables, groupes de
  serialisation par onglet. Pas de #[ORM\UniqueConstraint] : unicite du nom de
  societe portee par l'index partiel Postgres (decision Q4).
- ClientRib : tous les champs audites, aucun #[AuditIgnore] sur iban/bic
  (decision 29/05, audit admin-only).
- M2M Category via le contrat Shared CategoryInterface + resolve_target_entities
  (regle n°1, pas d'import inter-modules) ; sites via SiteInterface.
- CommercialReferentialFixtures : re-seed idempotent des 4 referentiels (sinon
  vides apres db-reset car desormais tables mappees, purgees par les fixtures).
- Referentiels whitelistes dans EntitiesAreTimestampableBlamableTest::EXCLUDED.
- doctrine.yaml : mapping ORM du module Commercial + resolve CategoryInterface.
- ColumnCommentsCatalog : ajout des colonnes M1 (chemin schema:update/test) ;
  migration retrofit Version20260528120000 filtree sur les tables existantes
  pour ne pas casser sur les tables des modules crees plus tard.
- makefile test-db-setup : recreation de l'index partiel uq_client_company_name_active.

Refs ERP-54.
This commit is contained in:
Matthieu
2026-05-29 15:36:14 +02:00
parent 9f96d1c40d
commit 311e758dea
32 changed files with 2373 additions and 10 deletions
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineBankRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Banque selectionnable pour le reglement par virement (Societe Generale,
* CIC, Credit Agricole) : referentiel statique seede par la migration M1 et
* re-seede en dev/test par CommercialReferentialFixtures.
*
* Lecture seule au M1 (HP-M2-2). Pas de Timestampable/Blamable (referentiel
* statique whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED). Le
* groupe `client:read:accounting` permet l'embarquement dans la reponse Client.
*/
#[ORM\Entity(repositoryClass: DoctrineBankRepository::class)]
#[ORM\Table(name: 'bank')]
#[ORM\UniqueConstraint(name: 'uq_bank_code', columns: ['code'])]
class Bank
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['bank:read', 'client:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['bank:read', 'client:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['bank:read', 'client:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['bank:read'])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,638 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Client (M1 Commercial) — entite racine du repertoire clients. Porte le
* formulaire principal, l'onglet Information, l'onglet Comptabilite, le
* mecanisme d'archivage (is_archived / archived_at) et le soft-delete technique
* prepare mais non expose au M1 (deleted_at, HP-M2-1).
*
* Decisions structurantes :
* - Audit complet (#[Auditable]) sur tous les champs (M2M categories audite
* automatiquement). Timestampable/Blamable via le trait Shared.
* - PAS de #[ORM\UniqueConstraint] : l'unicite du nom de societe (RG-1.16) est
* portee par l'index partiel fonctionnel uq_client_company_name_active
* (LOWER(company_name) WHERE is_archived = FALSE AND deleted_at IS NULL),
* inexprimable en attribut ORM, donc possede par la seule migration. Le SIREN
* et l'email NE SONT PAS uniques (RG-1.15/1.17 supprimees, decision Q4).
* - distributor / broker : 2 FK auto-referentes mutuellement exclusives
* (RG-1.03, CHECK chk_client_distrib_or_broker en base).
* - categories : M2M vers Category (module Catalog) via le contrat
* CategoryInterface + resolve_target_entities (regle n°1, pas d'import direct).
*
* Aucun ApiResource au M1.1 (ERP-54) : les operations API (Provider + Processor,
* normalisation, archivage, accounting conditionnel) sont branchees en ERP-55.
*/
#[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<int, CategoryInterface> */
#[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<int, ClientContact> */
#[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contacts;
/** @var Collection<int, ClientAddress> */
#[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $addresses;
/** @var Collection<int, ClientRib> */
#[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $ribs;
// === Archive / Soft delete ===
#[ORM\Column(name: 'is_archived', options: ['default' => false])]
#[Groups(['client:read', '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<int, CategoryInterface> */
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<int, ClientContact> */
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<int, ClientAddress> */
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<int, ClientRib> */
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;
}
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;
}
}
@@ -0,0 +1,337 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientAddressRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Adresse d'un client (1:n) — onglet Adresse. Une adresse de prospection
* (isProspect) est exclusive d'une adresse de livraison/facturation
* (RG-1.06/07/08, CHECK BDD). Un email de facturation est obligatoire ssi
* isBilling (RG-1.11, CHECK BDD). Au moins un site doit etre rattache
* (RG-1.10, Assert\Count).
*
* Relations M2M :
* - sites : SiteInterface (module Sites) via resolve_target_entities
* - contacts : ClientContact (meme module)
* - categories : CategoryInterface (module Catalog) via resolve_target_entities
* — limitees aux types SECTEUR/AUTRE cote validation (RG-1.29, futur Processor)
*
* Audite (#[Auditable]) + Timestampable/Blamable. Aucun ApiResource au M1.1
* (sous-ressources branchees a un ticket dedie).
*/
#[ORM\Entity(repositoryClass: DoctrineClientAddressRepository::class)]
#[ORM\Table(name: 'client_address')]
#[ORM\Index(name: 'idx_client_address_client', columns: ['client_id'])]
#[Auditable]
class ClientAddress implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['client_address:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'addresses')]
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Client $client = null;
#[ORM\Column(name: 'is_prospect', options: ['default' => false])]
#[Groups(['client_address:read', 'client_address:write'])]
private bool $isProspect = false;
#[ORM\Column(name: 'is_delivery', options: ['default' => false])]
#[Groups(['client_address:read', 'client_address:write'])]
private bool $isDelivery = false;
#[ORM\Column(name: 'is_billing', options: ['default' => false])]
#[Groups(['client_address:read', 'client_address:write'])]
private bool $isBilling = false;
#[ORM\Column(length: 80, options: ['default' => 'France'])]
#[Groups(['client_address:read', 'client_address:write'])]
private string $country = 'France';
// RG-1.09 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur).
#[ORM\Column(length: 20)]
#[Assert\NotBlank]
#[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $postalCode = null;
#[ORM\Column(length: 120)]
#[Assert\NotBlank]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $city = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $street = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $streetComplement = null;
// RG-1.11 : obligatoire ssi isBilling (CHECK BDD + futur Processor).
#[ORM\Column(length: 180, nullable: true)]
#[Assert\Email]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $billingEmail = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['client_address:read', 'client_address:write'])]
private int $position = 0;
// RG-1.10 : au moins un site rattache a chaque adresse.
/** @var Collection<int, SiteInterface> */
#[ORM\ManyToMany(targetEntity: SiteInterface::class)]
#[ORM\JoinTable(name: 'client_address_site')]
#[ORM\JoinColumn(name: 'client_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(['client_address:read', 'client_address:write'])]
private Collection $sites;
/** @var Collection<int, ClientContact> */
#[ORM\ManyToMany(targetEntity: ClientContact::class)]
#[ORM\JoinTable(name: 'client_address_contact')]
#[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'client_contact_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[Groups(['client_address:read', 'client_address:write'])]
private Collection $contacts;
// RG-1.29 : categories de type SECTEUR/AUTRE uniquement (filtre au Processor).
/** @var Collection<int, CategoryInterface> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[ORM\JoinTable(name: 'client_address_category')]
#[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
#[Groups(['client_address:read', 'client_address:write'])]
private Collection $categories;
public function __construct()
{
$this->sites = new ArrayCollection();
$this->contacts = new ArrayCollection();
$this->categories = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getClient(): ?Client
{
return $this->client;
}
public function setClient(?Client $client): static
{
$this->client = $client;
return $this;
}
public function isProspect(): bool
{
return $this->isProspect;
}
public function setIsProspect(bool $isProspect): static
{
$this->isProspect = $isProspect;
return $this;
}
public function isDelivery(): bool
{
return $this->isDelivery;
}
public function setIsDelivery(bool $isDelivery): static
{
$this->isDelivery = $isDelivery;
return $this;
}
public function isBilling(): bool
{
return $this->isBilling;
}
public function setIsBilling(bool $isBilling): static
{
$this->isBilling = $isBilling;
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 getBillingEmail(): ?string
{
return $this->billingEmail;
}
public function setBillingEmail(?string $billingEmail): static
{
$this->billingEmail = $billingEmail;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
/** @return Collection<int, SiteInterface> */
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<int, ClientContact> */
public function getContacts(): Collection
{
return $this->contacts;
}
public function addContact(ClientContact $contact): static
{
if (!$this->contacts->contains($contact)) {
$this->contacts->add($contact);
}
return $this;
}
public function removeContact(ClientContact $contact): static
{
$this->contacts->removeElement($contact);
return $this;
}
/** @return Collection<int, CategoryInterface> */
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;
}
}
@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientContactRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Contact d'un client (1:n) — onglet Contact. Au moins firstName OU lastName
* doit etre renseigne (RG-1.05) : la contrainte est portee par un CHECK BDD
* (chk_client_contact_name) et validee dans le futur ClientContactProcessor ;
* l'entite reste permissive (les deux champs sont nullable).
*
* Audite (#[Auditable]) + Timestampable/Blamable (pattern Shared standard).
* Les operations CRUD (sous-ressources POST/PATCH/DELETE) sont branchees au
* ticket dedie des sous-ressources — aucun ApiResource au M1.1 (ERP-54).
*/
#[ORM\Entity(repositoryClass: DoctrineClientContactRepository::class)]
#[ORM\Table(name: 'client_contact')]
#[ORM\Index(name: 'idx_client_contact_client', columns: ['client_id'])]
#[Auditable]
class ClientContact implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['client_contact:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'contacts')]
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Client $client = null;
// RG-1.05 : firstName OU lastName obligatoire (CHECK BDD + Processor). Les
// deux restent nullable au niveau ORM.
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $firstName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $lastName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $jobTitle = null;
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $phonePrimary = null;
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $phoneSecondary = null;
#[ORM\Column(length: 180, nullable: true)]
#[Assert\Email]
#[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $email = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['client_contact:read', 'client_contact:write'])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getClient(): ?Client
{
return $this->client;
}
public function setClient(?Client $client): static
{
$this->client = $client;
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 getJobTitle(): ?string
{
return $this->jobTitle;
}
public function setJobTitle(?string $jobTitle): static
{
$this->jobTitle = $jobTitle;
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 getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRibRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Coordonnees bancaires d'un client (1:n) — onglet Comptabilite. Au moins un
* RIB est obligatoire si le type de reglement du client est LCR (RG-1.13,
* verifie au futur Processor).
*
* Audit (#[Auditable]) : TOUS les champs sont audites, y compris `iban` et
* `bic` — AUCUN #[AuditIgnore] (decision Matthieu en revue MR 29/05/2026 :
* l'audit etant admin-only, la tracabilite RIB est necessaire pour le suivi
* comptable et la conformite, cf. spec § 2.5 / § 6.1).
*
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony au M1
* (HP-M2-14 : pas de controle externe banque reelle). Timestampable/Blamable
* standard. Aucun ApiResource au M1.1 (sous-ressource branchee ulterieurement).
*/
#[ORM\Entity(repositoryClass: DoctrineClientRibRepository::class)]
#[ORM\Table(name: 'client_rib')]
#[ORM\Index(name: 'idx_client_rib_client', columns: ['client_id'])]
#[Auditable]
class ClientRib implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['client_rib:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'ribs')]
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Client $client = null;
#[ORM\Column(length: 120)]
#[Assert\NotBlank]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client_rib:read', 'client_rib:write'])]
private ?string $label = null;
#[ORM\Column(length: 20)]
#[Assert\NotBlank]
#[Assert\Bic]
#[Groups(['client_rib:read', 'client_rib:write'])]
private ?string $bic = null;
#[ORM\Column(length: 34)]
#[Assert\NotBlank]
#[Assert\Iban]
#[Groups(['client_rib:read', 'client_rib:write'])]
private ?string $iban = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['client_rib:read', 'client_rib:write'])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getClient(): ?Client
{
return $this->client;
}
public function setClient(?Client $client): static
{
$this->client = $client;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
public function getBic(): ?string
{
return $this->bic;
}
public function setBic(string $bic): static
{
$this->bic = $bic;
return $this;
}
public function getIban(): ?string
{
return $this->iban;
}
public function setIban(string $iban): static
{
$this->iban = $iban;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrinePaymentDelayRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Delai de reglement applique a un client (15 jours, 30 jours, a reception) :
* referentiel statique seede par la migration M1 et re-seede en dev/test par
* CommercialReferentialFixtures.
*
* Lecture seule au M1 (HP-M2-2). Pas de Timestampable/Blamable (referentiel
* statique whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED). Le
* groupe `client:read:accounting` permet l'embarquement dans la reponse Client.
*/
#[ORM\Entity(repositoryClass: DoctrinePaymentDelayRepository::class)]
#[ORM\Table(name: 'payment_delay')]
#[ORM\UniqueConstraint(name: 'uq_payment_delay_code', columns: ['code'])]
class PaymentDelay
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['payment_delay:read', 'client:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['payment_delay:read', 'client:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['payment_delay:read', 'client:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['payment_delay:read'])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrinePaymentTypeRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Type de reglement applique a un client (virement, LCR, cheque, non soumise) :
* referentiel statique seede par la migration M1 et re-seede en dev/test par
* CommercialReferentialFixtures.
*
* Le `code` porte une semantique metier : VIREMENT impose une banque (RG-1.12),
* LCR impose au moins un RIB (RG-1.13).
*
* Lecture seule au M1 (HP-M2-2). Pas de Timestampable/Blamable (referentiel
* statique whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED). Le
* groupe `client:read:accounting` permet l'embarquement dans la reponse Client.
*/
#[ORM\Entity(repositoryClass: DoctrinePaymentTypeRepository::class)]
#[ORM\Table(name: 'payment_type')]
#[ORM\UniqueConstraint(name: 'uq_payment_type_code', columns: ['code'])]
class PaymentType
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['payment_type:read', 'client:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['payment_type:read', 'client:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['payment_type:read', 'client:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['payment_type:read'])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineTvaModeRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Mode de TVA applique a un client (France ventes, Export, Intracom) :
* referentiel statique seede par la migration M1 (Version20260601000000) et
* re-seede en dev/test par CommercialReferentialFixtures.
*
* Lecture seule au M1 : pas de POST/PATCH/DELETE (HP-M2-2). L'ApiResource
* (GetCollection + Get, tri position ASC) est branche au ticket dedie des
* referentiels lecture seule.
*
* Referentiel statique : pas de Timestampable/Blamable (whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le
* groupe `client:read:accounting` permet d'embarquer le mode dans la reponse
* d'un Client (onglet Comptabilite) au lieu d'un IRI.
*/
#[ORM\Entity(repositoryClass: DoctrineTvaModeRepository::class)]
#[ORM\Table(name: 'tva_mode')]
#[ORM\UniqueConstraint(name: 'uq_tva_mode_code', columns: ['code'])]
class TvaMode
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['tva_mode:read', 'client:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['tva_mode:read', 'client:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['tva_mode:read', 'client:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['tva_mode:read'])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\Bank;
interface BankRepositoryInterface
{
public function findById(int $id): ?Bank;
/**
* Retourne toutes les banques triees position ASC puis label ASC.
*
* @return list<Bank>
*/
public function findAllOrdered(): array;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\ClientAddress;
interface ClientAddressRepositoryInterface
{
public function findById(int $id): ?ClientAddress;
public function save(ClientAddress $address): void;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\ClientContact;
interface ClientContactRepositoryInterface
{
public function findById(int $id): ?ClientContact;
public function save(ClientContact $contact): void;
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\Client;
use Doctrine\ORM\QueryBuilder;
interface ClientRepositoryInterface
{
public function findById(int $id): ?Client;
public function save(Client $client): void;
/**
* Construit un QueryBuilder de liste pour le repertoire clients.
* - Exclut toujours les clients soft-deletes (deleted_at IS NOT NULL, RG-1.24).
* - Exclut les archives sauf si $includeArchived = true (RG-1.25).
* - Tri par defaut : companyName ASC (RG-1.26).
*/
public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\ClientRib;
interface ClientRibRepositoryInterface
{
public function findById(int $id): ?ClientRib;
public function save(ClientRib $rib): void;
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
interface PaymentDelayRepositoryInterface
{
public function findById(int $id): ?PaymentDelay;
/**
* Retourne tous les delais de reglement tries position ASC puis label ASC.
*
* @return list<PaymentDelay>
*/
public function findAllOrdered(): array;
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\PaymentType;
interface PaymentTypeRepositoryInterface
{
public function findById(int $id): ?PaymentType;
/**
* Retourne tous les types de reglement tries position ASC puis label ASC.
*
* @return list<PaymentType>
*/
public function findAllOrdered(): array;
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\TvaMode;
interface TvaModeRepositoryInterface
{
public function findById(int $id): ?TvaMode;
/**
* Retourne tous les modes de TVA tries position ASC puis label ASC
* (ordre des selecteurs, reutilise par la fixture de re-seed).
*
* @return list<TvaMode>
*/
public function findAllOrdered(): array;
}