diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index e2488bd..5aeb29c 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -37,6 +37,10 @@ doctrine: # Permet a Shared de referencer UserInterface dans ses ORM mappings sans # importer la classe concrete du module Core (cf. spec-back M0 § 2.8). Symfony\Component\Security\Core\User\UserInterface: App\Module\Core\Domain\Entity\User + # Cible des ManyToMany Client.categories / ClientAddress.categories (M1). + # Permet au module Commercial de referencer une Category via le contrat + # Shared sans importer la classe concrete du module Catalog (regle n°1). + App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category mappings: Core: type: attribute @@ -66,6 +70,16 @@ doctrine: dir: '%kernel.project_dir%/src/Module/Catalog/Domain/Entity' prefix: 'App\Module\Catalog\Domain\Entity' alias: Catalog + # Mapping inconditionnel du module Commercial (meme logique que Catalog) : + # les tables (client, sous-collections, referentiels comptables) creees + # par la migration M1 (Version20260601000000) doivent etre connues de + # l'ORM. L'activation fonctionnelle passe par config/modules.php. + Commercial: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/src/Module/Commercial/Domain/Entity' + prefix: 'App\Module\Commercial\Domain\Entity' + alias: Commercial controller_resolver: auto_mapping: false diff --git a/makefile b/makefile index 71ab933..74857f9 100644 --- a/makefile +++ b/makefile @@ -200,13 +200,14 @@ migration-migrate: # en DB, le purger crash. # 3. fixtures -> sync-permissions : fixtures:load purge la table permission, # donc sync doit passer apres. -# 4. recreation index `uq_category_name_type_active` : schema:update drop -# les index orphelins du mapping ORM. L'index partiel (LOWER + WHERE) du -# M0 Catalog n'est pas exprimable via les attributs Doctrine ORM 3 -# (fonctionnel + partiel), donc il disparait apres schema:update. On le -# recree par dbal:run-sql pour que les tests RG-1.07 (unicite -# case-insensitive) voient bien la contrainte SQL. Sans ce restore, les -# POST doublons remontent 201 au lieu de 409. +# 4. recreation des index partiels uniques : schema:update drop les index +# orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas +# exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc +# ils disparaissent apres schema:update. On les recree par dbal:run-sql : +# - `uq_category_name_type_active` (M0 Catalog) : tests RG-1.07. +# - `uq_client_company_name_active` (M1 Commercial) : unicite nom societe +# parmi actifs non archives/non supprimes (RG-1.16), tests ERP-55. +# Sans ces restores, les POST doublons remontent 201 au lieu de 409. # 5. app:apply-column-comments : meme cause, schema:update drop les COMMENT # ON COLUMN/TABLE des tables managees par l'ORM (le mapping PHP ne porte # pas d'attribut options['comment']). On rejoue le catalogue partage @@ -220,6 +221,7 @@ test-db-setup: $(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load $(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL" + $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" fixtures: $(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load diff --git a/migrations/Version20260528120000.php b/migrations/Version20260528120000.php index 6e38e14..e82ad37 100644 --- a/migrations/Version20260528120000.php +++ b/migrations/Version20260528120000.php @@ -39,7 +39,19 @@ final class Version20260528120000 extends AbstractMigration public function up(Schema $schema): void { - foreach (ColumnCommentsCatalog::toSqlStatements() as $sql) { + // Ne commente que les tables deja presentes a ce stade de la chaine de + // migrations. Les modules crees plus tard (ex: M1 Commercial, 06-01) + // figurent desormais dans le catalogue partage mais leurs tables + // n'existent pas encore ici : elles posent leurs propres COMMENT dans + // leur migration dediee (regle ABSOLUE n°12). Garde-fou indispensable, + // sinon l'ajout d'un module au catalogue casse ce retrofit avec un + // "relation X does not exist". + $existingTables = array_values(array_filter( + array_keys(ColumnCommentsCatalog::comments()), + static fn (string $table): bool => $schema->hasTable($table), + )); + + foreach (ColumnCommentsCatalog::toSqlStatements($existingTables) as $sql) { $this->addSql($sql); } } @@ -47,6 +59,13 @@ final class Version20260528120000 extends AbstractMigration public function down(Schema $schema): void { foreach (ColumnCommentsCatalog::comments() as $table => $entries) { + // Symetrie avec up() : on n'efface que les commentaires des tables + // presentes (les tables des modules ulterieurs sont gerees par leur + // propre migration). + if (!$schema->hasTable($table)) { + continue; + } + $quotedTable = '"'.str_replace('"', '""', $table).'"'; foreach ($entries as $column => $_) { if ('_table' === $column) { diff --git a/src/Module/Catalog/Domain/Entity/Category.php b/src/Module/Catalog/Domain/Entity/Category.php index 6bbb52d..9ae95fd 100644 --- a/src/Module/Catalog/Domain/Entity/Category.php +++ b/src/Module/Catalog/Domain/Entity/Category.php @@ -15,6 +15,7 @@ use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\CategoryProvide use App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository; 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; @@ -82,7 +83,7 @@ use Symfony\Component\Validator\Constraints as Assert; #[ORM\Index(name: 'idx_category_created_by', columns: ['created_by'])] #[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])] #[Auditable] -class Category implements TimestampableInterface, BlamableInterface +class Category implements TimestampableInterface, BlamableInterface, CategoryInterface { // === Timestampable + Blamable === // Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs diff --git a/src/Module/Commercial/Domain/Entity/Bank.php b/src/Module/Commercial/Domain/Entity/Bank.php new file mode 100644 index 0000000..1524a0a --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/Bank.php @@ -0,0 +1,83 @@ + 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; + } +} diff --git a/src/Module/Commercial/Domain/Entity/Client.php b/src/Module/Commercial/Domain/Entity/Client.php new file mode 100644 index 0000000..8eee053 --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/Client.php @@ -0,0 +1,638 @@ + 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 === + #[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 */ + 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 */ + 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 */ + 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 */ + 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; + } +} diff --git a/src/Module/Commercial/Domain/Entity/ClientAddress.php b/src/Module/Commercial/Domain/Entity/ClientAddress.php new file mode 100644 index 0000000..26e5f8d --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/ClientAddress.php @@ -0,0 +1,337 @@ + 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 */ + #[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 */ + #[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 */ + #[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 */ + 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(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 */ + 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; + } +} diff --git a/src/Module/Commercial/Domain/Entity/ClientContact.php b/src/Module/Commercial/Domain/Entity/ClientContact.php new file mode 100644 index 0000000..1b04ef8 --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/ClientContact.php @@ -0,0 +1,178 @@ + 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; + } +} diff --git a/src/Module/Commercial/Domain/Entity/ClientRib.php b/src/Module/Commercial/Domain/Entity/ClientRib.php new file mode 100644 index 0000000..f1c589d --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/ClientRib.php @@ -0,0 +1,134 @@ + 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; + } +} diff --git a/src/Module/Commercial/Domain/Entity/PaymentDelay.php b/src/Module/Commercial/Domain/Entity/PaymentDelay.php new file mode 100644 index 0000000..cdfbed0 --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/PaymentDelay.php @@ -0,0 +1,83 @@ + 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; + } +} diff --git a/src/Module/Commercial/Domain/Entity/PaymentType.php b/src/Module/Commercial/Domain/Entity/PaymentType.php new file mode 100644 index 0000000..3930c42 --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/PaymentType.php @@ -0,0 +1,86 @@ + 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; + } +} diff --git a/src/Module/Commercial/Domain/Entity/TvaMode.php b/src/Module/Commercial/Domain/Entity/TvaMode.php new file mode 100644 index 0000000..5a366fb --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/TvaMode.php @@ -0,0 +1,88 @@ + 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; + } +} diff --git a/src/Module/Commercial/Domain/Repository/BankRepositoryInterface.php b/src/Module/Commercial/Domain/Repository/BankRepositoryInterface.php new file mode 100644 index 0000000..3e07010 --- /dev/null +++ b/src/Module/Commercial/Domain/Repository/BankRepositoryInterface.php @@ -0,0 +1,19 @@ + + */ + public function findAllOrdered(): array; +} diff --git a/src/Module/Commercial/Domain/Repository/ClientAddressRepositoryInterface.php b/src/Module/Commercial/Domain/Repository/ClientAddressRepositoryInterface.php new file mode 100644 index 0000000..9e070b3 --- /dev/null +++ b/src/Module/Commercial/Domain/Repository/ClientAddressRepositoryInterface.php @@ -0,0 +1,14 @@ + + */ + public function findAllOrdered(): array; +} diff --git a/src/Module/Commercial/Domain/Repository/PaymentTypeRepositoryInterface.php b/src/Module/Commercial/Domain/Repository/PaymentTypeRepositoryInterface.php new file mode 100644 index 0000000..9c4b749 --- /dev/null +++ b/src/Module/Commercial/Domain/Repository/PaymentTypeRepositoryInterface.php @@ -0,0 +1,19 @@ + + */ + public function findAllOrdered(): array; +} diff --git a/src/Module/Commercial/Domain/Repository/TvaModeRepositoryInterface.php b/src/Module/Commercial/Domain/Repository/TvaModeRepositoryInterface.php new file mode 100644 index 0000000..80144a4 --- /dev/null +++ b/src/Module/Commercial/Domain/Repository/TvaModeRepositoryInterface.php @@ -0,0 +1,20 @@ + + */ + public function findAllOrdered(): array; +} diff --git a/src/Module/Commercial/Infrastructure/DataFixtures/CommercialReferentialFixtures.php b/src/Module/Commercial/Infrastructure/DataFixtures/CommercialReferentialFixtures.php new file mode 100644 index 0000000..41dc6b4 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/DataFixtures/CommercialReferentialFixtures.php @@ -0,0 +1,94 @@ + referentiels et les tests + * RG-1.12/1.13. Le seed migration couvre la prod (ou les fixtures ne tournent + * pas) ; cette fixture re-aligne dev et test. Memes valeurs des deux cotes. + * + * Idempotence : lookup par `code` avant insertion (sur le modele de + * CategoryTypeFixtures). Rejouable sans doublon meme si le purger est desactive. + */ +class CommercialReferentialFixtures extends Fixture +{ + /** + * Source unique des referentiels : classe d'entite => [code => [label, position]]. + * Doit rester aligne sur le seed de la migration Version20260601000000. + * + * @var array> + */ + private const REFERENTIALS = [ + TvaMode::class => [ + 'FRANCE_VENTES' => ['France (ventes)', 10], + 'EXPORT_VENTES' => ['Export (ventes)', 20], + 'INTRACOM_VENTES' => ['Intracom (ventes)', 30], + ], + PaymentDelay::class => [ + 'J15' => ['15 jours', 10], + 'J30' => ['30 jours', 20], + 'A_RECEPTION' => ['À réception', 30], + ], + PaymentType::class => [ + 'VIREMENT' => ['Virement', 10], + 'LCR' => ['LCR', 20], + 'NON_SOUMISE' => ['Non soumise', 30], + 'CHEQUE' => ['Chèque', 40], + ], + Bank::class => [ + 'SG' => ['Société Générale', 10], + 'CIC' => ['CIC', 20], + 'CA' => ['Crédit Agricole', 30], + ], + ]; + + public function load(ObjectManager $manager): void + { + foreach (self::REFERENTIALS as $entityClass => $rows) { + $this->seedReferential($manager, $entityClass, $rows); + } + + $manager->flush(); + } + + /** + * Upsert idempotent d'un referentiel : indexe l'existant par code puis + * cree/met a jour chaque entree. Les 4 entites partagent le meme contrat + * setCode/setLabel/setPosition. + * + * @param class-string $entityClass + * @param array $rows + */ + private function seedReferential(ObjectManager $manager, string $entityClass, array $rows): void + { + $existingByCode = []; + foreach ($manager->getRepository($entityClass)->findAll() as $entity) { + $existingByCode[$entity->getCode()] = $entity; + } + + foreach ($rows as $code => [$label, $position]) { + $entity = $existingByCode[$code] ?? new $entityClass(); + $entity->setCode($code); + $entity->setLabel($label); + $entity->setPosition($position); + $manager->persist($entity); + } + } +} diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineBankRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineBankRepository.php new file mode 100644 index 0000000..2f10660 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineBankRepository.php @@ -0,0 +1,36 @@ + + */ +class DoctrineBankRepository extends ServiceEntityRepository implements BankRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Bank::class); + } + + public function findById(int $id): ?Bank + { + return $this->find($id); + } + + public function findAllOrdered(): array + { + return $this->createQueryBuilder('b') + ->orderBy('b.position', 'ASC') + ->addOrderBy('b.label', 'ASC') + ->getQuery() + ->getResult() + ; + } +} diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientAddressRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientAddressRepository.php new file mode 100644 index 0000000..76b6325 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientAddressRepository.php @@ -0,0 +1,32 @@ + + */ +class DoctrineClientAddressRepository extends ServiceEntityRepository implements ClientAddressRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ClientAddress::class); + } + + public function findById(int $id): ?ClientAddress + { + return $this->find($id); + } + + public function save(ClientAddress $address): void + { + $this->getEntityManager()->persist($address); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientContactRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientContactRepository.php new file mode 100644 index 0000000..417db34 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientContactRepository.php @@ -0,0 +1,32 @@ + + */ +class DoctrineClientContactRepository extends ServiceEntityRepository implements ClientContactRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ClientContact::class); + } + + public function findById(int $id): ?ClientContact + { + return $this->find($id); + } + + public function save(ClientContact $contact): void + { + $this->getEntityManager()->persist($contact); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php new file mode 100644 index 0000000..f10aff2 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php @@ -0,0 +1,47 @@ + + */ +class DoctrineClientRepository extends ServiceEntityRepository implements ClientRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Client::class); + } + + public function findById(int $id): ?Client + { + return $this->find($id); + } + + public function save(Client $client): void + { + $this->getEntityManager()->persist($client); + $this->getEntityManager()->flush(); + } + + public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder + { + $qb = $this->createQueryBuilder('c') + ->andWhere('c.deletedAt IS NULL') + ->orderBy('c.companyName', 'ASC') + ; + + if (!$includeArchived) { + $qb->andWhere('c.isArchived = false'); + } + + return $qb; + } +} diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRibRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRibRepository.php new file mode 100644 index 0000000..113827c --- /dev/null +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRibRepository.php @@ -0,0 +1,32 @@ + + */ +class DoctrineClientRibRepository extends ServiceEntityRepository implements ClientRibRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ClientRib::class); + } + + public function findById(int $id): ?ClientRib + { + return $this->find($id); + } + + public function save(ClientRib $rib): void + { + $this->getEntityManager()->persist($rib); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrinePaymentDelayRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrinePaymentDelayRepository.php new file mode 100644 index 0000000..529fbe9 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrinePaymentDelayRepository.php @@ -0,0 +1,36 @@ + + */ +class DoctrinePaymentDelayRepository extends ServiceEntityRepository implements PaymentDelayRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, PaymentDelay::class); + } + + public function findById(int $id): ?PaymentDelay + { + return $this->find($id); + } + + public function findAllOrdered(): array + { + return $this->createQueryBuilder('p') + ->orderBy('p.position', 'ASC') + ->addOrderBy('p.label', 'ASC') + ->getQuery() + ->getResult() + ; + } +} diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrinePaymentTypeRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrinePaymentTypeRepository.php new file mode 100644 index 0000000..41af0ba --- /dev/null +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrinePaymentTypeRepository.php @@ -0,0 +1,36 @@ + + */ +class DoctrinePaymentTypeRepository extends ServiceEntityRepository implements PaymentTypeRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, PaymentType::class); + } + + public function findById(int $id): ?PaymentType + { + return $this->find($id); + } + + public function findAllOrdered(): array + { + return $this->createQueryBuilder('p') + ->orderBy('p.position', 'ASC') + ->addOrderBy('p.label', 'ASC') + ->getQuery() + ->getResult() + ; + } +} diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineTvaModeRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineTvaModeRepository.php new file mode 100644 index 0000000..bf356b7 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineTvaModeRepository.php @@ -0,0 +1,36 @@ + + */ +class DoctrineTvaModeRepository extends ServiceEntityRepository implements TvaModeRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, TvaMode::class); + } + + public function findById(int $id): ?TvaMode + { + return $this->find($id); + } + + public function findAllOrdered(): array + { + return $this->createQueryBuilder('t') + ->orderBy('t.position', 'ASC') + ->addOrderBy('t.label', 'ASC') + ->getQuery() + ->getResult() + ; + } +} diff --git a/src/Shared/Domain/Contract/CategoryInterface.php b/src/Shared/Domain/Contract/CategoryInterface.php new file mode 100644 index 0000000..e345ac0 --- /dev/null +++ b/src/Shared/Domain/Contract/CategoryInterface.php @@ -0,0 +1,22 @@ + 'FK -> user.id, ON DELETE CASCADE — utilisateur ayant acces au site.', 'site_id' => 'FK -> site.id, ON DELETE CASCADE — site accessible par l utilisateur.', ], + + // === M1 Commercial (ERP-53/54) — miroir des COMMENT de la migration + // Version20260601000000 pour le chemin schema:update (dev/test). === + + 'tva_mode' => [ + '_table' => 'Referentiel des modes de TVA appliques a un client (France, Export, Intracom).', + 'id' => 'Identifiant interne auto-incremente.', + 'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.', + 'label' => 'Libelle affichable (FR, ≤ 120 caracteres).', + 'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).', + ], + + 'payment_delay' => [ + '_table' => 'Referentiel des delais de reglement (15 jours, 30 jours, a reception).', + 'id' => 'Identifiant interne auto-incremente.', + 'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.', + 'label' => 'Libelle affichable (FR, ≤ 120 caracteres).', + 'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).', + ], + + 'payment_type' => [ + '_table' => 'Referentiel des types de reglement (virement, LCR, cheque, non soumise).', + 'id' => 'Identifiant interne auto-incremente.', + 'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.', + 'label' => 'Libelle affichable (FR, ≤ 120 caracteres).', + 'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).', + ], + + 'bank' => [ + '_table' => 'Referentiel des banques selectionnables pour le reglement par virement.', + 'id' => 'Identifiant interne auto-incremente.', + 'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.', + 'label' => 'Libelle affichable (FR, ≤ 120 caracteres).', + 'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).', + ], + + 'client' => [ + '_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).', + 'id' => 'Identifiant interne auto-incremente.', + 'company_name' => 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).', + 'first_name' => 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).', + 'last_name' => 'Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).', + 'phone_primary' => 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.', + 'phone_secondary' => 'Telephone secondaire optionnel — chiffres uniquement (RG-1.20).', + 'email' => 'Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).', + 'distributor_id' => 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.', + 'broker_id' => 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.', + 'triage_service' => 'Drapeau service triage active pour le client. Faux par defaut.', + 'description' => 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.', + 'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).', + 'founded_at' => 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).', + 'employees_count' => 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).', + 'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).', + 'director_name' => 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).', + 'profit_amount' => 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).', + 'siren' => 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).', + 'account_number' => 'Onglet Comptabilite : numero de compte comptable du client.', + 'tva_mode_id' => 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.', + 'n_tva' => 'Onglet Comptabilite : numero de TVA intracommunautaire.', + 'payment_delay_id' => 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id, ON DELETE RESTRICT.', + 'payment_type_id' => 'Onglet Comptabilite : type de reglement — FK -> payment_type.id, ON DELETE RESTRICT. Code LCR impose >= 1 RIB (RG-1.13), VIREMENT impose une banque (RG-1.12).', + 'bank_id' => 'Onglet Comptabilite : banque — FK -> bank.id, ON DELETE RESTRICT. Obligatoire si payment_type = VIREMENT (RG-1.12).', + 'is_archived' => 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.clients.archive (RG-1.22/23).', + 'archived_at' => 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration (RG-1.22/23).', + 'deleted_at' => 'Horodatage du soft-delete technique (HP M2) — non expose par l API au M1. Null = ligne active.', + ] + self::timestampableBlamableComments(), + + 'client_category' => [ + '_table' => 'Jointure M2M client <-> category (Catalog) — categories metier du client (au moins une obligatoire).', + 'client_id' => 'FK -> client.id, ON DELETE CASCADE — client porteur de la categorie.', + 'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie rattachee au client.', + ], + + 'client_contact' => [ + '_table' => 'Contacts d un client (1:n) — au moins firstName OU lastName par contact (RG-1.05).', + 'id' => 'Identifiant interne auto-incremente.', + 'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire du contact.', + 'first_name' => 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).', + 'last_name' => 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).', + 'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).', + 'phone_primary' => 'Telephone principal du contact — chiffres uniquement (RG-1.20).', + 'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (RG-1.20).', + 'email' => 'Email du contact (lowercase serveur, RG-1.21).', + 'position' => 'Ordre d affichage du contact dans la liste du client (croissant).', + ] + self::timestampableBlamableComments(), + + 'client_address' => [ + '_table' => 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).', + 'id' => 'Identifiant interne auto-incremente.', + 'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.', + 'is_prospect' => 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.', + 'is_delivery' => 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.', + 'is_billing' => 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.', + 'country' => 'Pays de l adresse — defaut France.', + 'postal_code' => 'Code postal (4-5 chiffres attendus, RG-1.09).', + 'city' => 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).', + 'street' => 'Numero et voie de l adresse.', + 'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.', + 'billing_email' => 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).', + 'position' => 'Ordre d affichage de l adresse dans la liste du client (croissant).', + ] + self::timestampableBlamableComments(), + + 'client_address_site' => [ + '_table' => 'Jointure M2M client_address <-> site (Sites) — sites desservis par l adresse (>= 1 obligatoire, RG-1.10).', + 'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.', + 'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.', + ], + + 'client_address_contact' => [ + '_table' => 'Jointure M2M client_address <-> client_contact — contacts associes a une adresse.', + 'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.', + 'client_contact_id' => 'FK -> client_contact.id, ON DELETE CASCADE — contact associe a l adresse.', + ], + + 'client_address_category' => [ + '_table' => 'Jointure M2M client_address <-> category — categories d adresse (types SECTEUR/AUTRE uniquement, RG-1.29).', + 'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.', + 'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse (type SECTEUR ou AUTRE, RG-1.29).', + ], + + 'client_rib' => [ + '_table' => 'Coordonnees bancaires d un client (1:n) — >= 1 RIB obligatoire si payment_type = LCR (RG-1.13). Tous les champs audites (pas d AuditIgnore).', + 'id' => 'Identifiant interne auto-incremente.', + 'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire du RIB.', + 'label' => 'Libelle du RIB (ex: compte principal).', + 'bic' => 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).', + 'iban' => 'IBAN du compte (≤ 34 caracteres).', + 'position' => 'Ordre d affichage du RIB dans la liste du client (croissant).', + ] + self::timestampableBlamableComments(), ]; } @@ -151,12 +280,25 @@ final class ColumnCommentsCatalog * Construit la liste des requetes SQL `COMMENT ON TABLE/COLUMN` (en * dollar-quoting Postgres `$_$`) a partir du catalogue. * + * @param null|list $onlyTables Restreint la generation a ces tables + * (utile pour la migration retrofit qui + * ne doit commenter que les tables deja + * presentes a son instant T — les tables + * des modules crees plus tard posent + * leurs propres COMMENT). null = tout. + * * @return list */ - public static function toSqlStatements(): array + public static function toSqlStatements(?array $onlyTables = null): array { + $allowed = null === $onlyTables ? null : array_fill_keys($onlyTables, true); + $statements = []; foreach (self::comments() as $table => $entries) { + if (null !== $allowed && !isset($allowed[$table])) { + continue; + } + $quotedTable = self::quoteIdent($table); foreach ($entries as $column => $description) { if ('_table' === $column) { diff --git a/tests/Architecture/EntitiesAreTimestampableBlamableTest.php b/tests/Architecture/EntitiesAreTimestampableBlamableTest.php index 20c7e26..0b8fb17 100644 --- a/tests/Architecture/EntitiesAreTimestampableBlamableTest.php +++ b/tests/Architecture/EntitiesAreTimestampableBlamableTest.php @@ -5,6 +5,10 @@ declare(strict_types=1); namespace App\Tests\Architecture; use App\Module\Catalog\Domain\Entity\CategoryType; +use App\Module\Commercial\Domain\Entity\Bank; +use App\Module\Commercial\Domain\Entity\PaymentDelay; +use App\Module\Commercial\Domain\Entity\PaymentType; +use App\Module\Commercial\Domain\Entity\TvaMode; use App\Module\Core\Domain\Entity\Permission; use App\Module\Core\Domain\Entity\Role; use App\Module\Core\Domain\Entity\User; @@ -49,6 +53,11 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase * - CategoryType : referentiel statique (codes de typage des categories), * pas de besoin de tracabilite user-driven (cree par migration/seed, * pas pilote utilisateur au M0). Cf. spec-back § 2.8.bis + RG-1.17. + * - TvaMode / PaymentDelay / PaymentType / Bank (M1 Commercial) : referentiels + * comptables statiques (id/code/label/position), seedes par migration + + * CommercialReferentialFixtures, lecture seule au M1 (HP-M2-2). Pas de + * tracabilite user-driven, meme justification que CategoryType. Cf. + * spec-back M1 § 2.6 + § 3.5. * * Les futurs referentiels statiques s'ajoutent ici avec une justification. */ @@ -58,6 +67,10 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase Permission::class, Site::class, CategoryType::class, + TvaMode::class, + PaymentDelay::class, + PaymentType::class, + Bank::class, ]; public function testAllBusinessEntitiesImplementBothInterfaces(): void