Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 311e758dea | |||
| 9f96d1c40d | |||
| 836f177ff9 | |||
| 1d91b4dea9 |
@@ -37,6 +37,10 @@ doctrine:
|
|||||||
# Permet a Shared de referencer UserInterface dans ses ORM mappings sans
|
# Permet a Shared de referencer UserInterface dans ses ORM mappings sans
|
||||||
# importer la classe concrete du module Core (cf. spec-back M0 § 2.8).
|
# 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
|
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:
|
mappings:
|
||||||
Core:
|
Core:
|
||||||
type: attribute
|
type: attribute
|
||||||
@@ -66,6 +70,16 @@ doctrine:
|
|||||||
dir: '%kernel.project_dir%/src/Module/Catalog/Domain/Entity'
|
dir: '%kernel.project_dir%/src/Module/Catalog/Domain/Entity'
|
||||||
prefix: 'App\Module\Catalog\Domain\Entity'
|
prefix: 'App\Module\Catalog\Domain\Entity'
|
||||||
alias: Catalog
|
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:
|
controller_resolver:
|
||||||
auto_mapping: false
|
auto_mapping: false
|
||||||
|
|
||||||
|
|||||||
@@ -235,7 +235,9 @@ Le **formatage `XX XX XX XX XX`** est fait à l'affichage côté front (filter V
|
|||||||
|
|
||||||
### 3.2 Migration Doctrine — SQL Postgres
|
### 3.2 Migration Doctrine — SQL Postgres
|
||||||
|
|
||||||
Namespace : `App\Module\Commercial\Infrastructure\Doctrine\Migrations` (modulaire, post-init). Fichier : `Version20260601000000.php` (à dater par le dev).
|
Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrations/Version20260601000000.php` (à dater par le dev).
|
||||||
|
|
||||||
|
> **Décision 29/05/2026 (vérifiée empiriquement en dev)** : cette migration crée un **schéma avec FK cross-module** (`user`, `category`, `site`) → elle a la même dépendance d'ordre que les migrations d'init. Le namespace modulaire `App\Module\Commercial\…` casse `make db-reset` : Doctrine Migrations 3.x trie par **FQCN alphabétique** (`App\…` < `DoctrineMigrations\…`), donc la migration client tournerait AVANT `user`/`category`/`site` et ses FK échoueraient. Elle relève donc de l'**exception racine** de la règle ABSOLUE n°11 (même choix que la migration cross-module ERP-67). Le namespace modulaire reste réservé aux évolutions post-schéma (ajout de colonnes/index). La correction long-terme (MigrationsComparator custom, tri par timestamp) est un ticket archi dédié, hors scope M1.
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
@@ -475,7 +477,14 @@ INSERT INTO category_type (code, label, position) VALUES
|
|||||||
('AUTRE', 'Autre', 99);
|
('AUTRE', 'Autre', 99);
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note** : le CRUD admin de `CategoryType` reste HP (cf. M0). Le seed est fait via migration ou fixture déclenchée à chaque `make db-reset`.
|
> **Note** : le CRUD admin de `CategoryType` reste HP (cf. M0).
|
||||||
|
>
|
||||||
|
> **Seed en DEUX endroits (décision 29/05, vérifiée empiriquement)** : le `make db-reset` lance les fixtures, dont le purger Doctrine **vide `category_type`** (entité M0 mappée) avant `load()` → un seed posé uniquement en migration disparaît en dev/test. Donc :
|
||||||
|
> 1. **Migration** (`ON CONFLICT (code) DO NOTHING`) → sert en **prod** (pas de fixtures).
|
||||||
|
> 2. **Fixture Commercial idempotente** (ex. `CommercialReferentialFixtures`) re-seedant les 4 types → survit au `db-reset`, satisfait le critère « 4 types présents après db-reset ».
|
||||||
|
>
|
||||||
|
> ⚠ **À venir en ERP-54** : `tva_mode` / `payment_delay` / `payment_type` / `bank` ne sont pas encore des entités mappées au M1.0 → le purger ne les touche pas, leur seed migration survit. **Dès qu'ERP-54 crée leurs entités, ils seront purgés au db-reset** → il faudra les ajouter à la même fixture référentielle.
|
||||||
|
> 🔗 **Coordination ERP-68** : ERP-53 pose la fixture référentielle minimale (4 category_types). ERP-68 l'**étend** (clients de démo, ~12-15) sans la dupliquer.
|
||||||
|
|
||||||
### 3.4 Entité `Client` — squelette
|
### 3.4 Entité `Client` — squelette
|
||||||
|
|
||||||
@@ -722,7 +731,7 @@ class Client implements TimestampableInterface, BlamableInterface
|
|||||||
|
|
||||||
### 3.5 Squelettes des autres entités
|
### 3.5 Squelettes des autres entités
|
||||||
|
|
||||||
**`ClientContact`**, **`ClientAddress`**, **`ClientRib`** : même pattern (`#[Auditable]`, `TimestampableBlamableTrait`, FK `client_id`). `ClientRib.iban` et `ClientRib.bic` portent `#[AuditIgnore]` (champs sensibles).
|
**`ClientContact`**, **`ClientAddress`**, **`ClientRib`** : même pattern (`#[Auditable]`, `TimestampableBlamableTrait`, FK `client_id`). ⚠ **Aucun `#[AuditIgnore]`** sur `ClientRib.iban`/`bic` — tous les champs RIB sont audités (décision Matthieu en revue MR du 29/05/2026, cf. § 2.5 : audit admin-only → traçabilité comptable nécessaire). Source de vérité : § 2.5.
|
||||||
|
|
||||||
**Référentiels (`TvaMode`, `PaymentDelay`, `PaymentType`, `Bank`)** : entités lecture seule via API Platform `GetCollection` + `Get` uniquement (security `commercial.clients.view`). Pas de POST/PATCH/DELETE au M1 (HP). Pas de Timestampable+Blamable (whitelistés dans `EntitiesAreTimestampableBlamableTest::EXCLUDED`).
|
**Référentiels (`TvaMode`, `PaymentDelay`, `PaymentType`, `Bank`)** : entités lecture seule via API Platform `GetCollection` + `Get` uniquement (security `commercial.clients.view`). Pas de POST/PATCH/DELETE au M1 (HP). Pas de Timestampable+Blamable (whitelistés dans `EntitiesAreTimestampableBlamableTest::EXCLUDED`).
|
||||||
|
|
||||||
@@ -971,7 +980,7 @@ Cf. § 2.6. Pattern Shared standard.
|
|||||||
- [ ] **Compta POST création** : Compta → 403 (pas de `manage` global)
|
- [ ] **Compta POST création** : Compta → 403 (pas de `manage` global)
|
||||||
- [ ] **PATCH mix groupes** : Bureau envoie payload avec `companyName` (write:main) + `siren` (write:accounting) → **403 sur tout le payload** (strict, RG-1.28)
|
- [ ] **PATCH mix groupes** : Bureau envoie payload avec `companyName` (write:main) + `siren` (write:accounting) → **403 sur tout le payload** (strict, RG-1.28)
|
||||||
- [ ] **Audit** : POST + PATCH + archive → audit_log avec entity_type='Client', `changes` correct ; **iban/bic présents dans le diff** (pas d'AuditIgnore, cf. § 6.1)
|
- [ ] **Audit** : POST + PATCH + archive → audit_log avec entity_type='Client', `changes` correct ; **iban/bic présents dans le diff** (pas d'AuditIgnore, cf. § 6.1)
|
||||||
- [ ] **Migration** : `make db-reset` → schéma OK, seed des 4 référentiels + CategoryType (DISTRIBUTEUR/COURTIER/SECTEUR/AUTRE) présent ; index partiel unique `uq_client_company_name_active` présent (un seul — cf. Q4)
|
- [ ] **Migration** : `make db-reset` → schéma OK ; migration en racine `migrations/` (namespace `DoctrineMigrations`, ordre garanti) ; 4 référentiels comptables seedés ; **4 CategoryType présents APRÈS db-reset** (via fixture idempotente, car le purger vide category_type) ; index partiel unique `uq_client_company_name_active` présent (un seul — cf. Q4)
|
||||||
|
|
||||||
### 8.2 Cas à couvrir (front — Vitest)
|
### 8.2 Cas à couvrir (front — Vitest)
|
||||||
|
|
||||||
|
|||||||
@@ -200,13 +200,14 @@ migration-migrate:
|
|||||||
# en DB, le purger crash.
|
# en DB, le purger crash.
|
||||||
# 3. fixtures -> sync-permissions : fixtures:load purge la table permission,
|
# 3. fixtures -> sync-permissions : fixtures:load purge la table permission,
|
||||||
# donc sync doit passer apres.
|
# donc sync doit passer apres.
|
||||||
# 4. recreation index `uq_category_name_type_active` : schema:update drop
|
# 4. recreation des index partiels uniques : schema:update drop les index
|
||||||
# les index orphelins du mapping ORM. L'index partiel (LOWER + WHERE) du
|
# orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas
|
||||||
# M0 Catalog n'est pas exprimable via les attributs Doctrine ORM 3
|
# exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc
|
||||||
# (fonctionnel + partiel), donc il disparait apres schema:update. On le
|
# ils disparaissent apres schema:update. On les recree par dbal:run-sql :
|
||||||
# recree par dbal:run-sql pour que les tests RG-1.07 (unicite
|
# - `uq_category_name_type_active` (M0 Catalog) : tests RG-1.07.
|
||||||
# case-insensitive) voient bien la contrainte SQL. Sans ce restore, les
|
# - `uq_client_company_name_active` (M1 Commercial) : unicite nom societe
|
||||||
# POST doublons remontent 201 au lieu de 409.
|
# 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
|
# 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
|
# ON COLUMN/TABLE des tables managees par l'ORM (le mapping PHP ne porte
|
||||||
# pas d'attribut options['comment']). On rejoue le catalogue partage
|
# 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 doctrine:fixtures:load
|
||||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
|
$(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_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:
|
fixtures:
|
||||||
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
||||||
|
|||||||
@@ -39,7 +39,19 @@ final class Version20260528120000 extends AbstractMigration
|
|||||||
|
|
||||||
public function up(Schema $schema): void
|
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);
|
$this->addSql($sql);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,6 +59,13 @@ final class Version20260528120000 extends AbstractMigration
|
|||||||
public function down(Schema $schema): void
|
public function down(Schema $schema): void
|
||||||
{
|
{
|
||||||
foreach (ColumnCommentsCatalog::comments() as $table => $entries) {
|
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).'"';
|
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||||
foreach ($entries as $column => $_) {
|
foreach ($entries as $column => $_) {
|
||||||
if ('_table' === $column) {
|
if ('_table' === $column) {
|
||||||
|
|||||||
@@ -0,0 +1,538 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M1 — Repertoire clients (ERP-53) : creation de toute la structure BDD du
|
||||||
|
* module Commercial (clients + sous-collections + referentiels comptables).
|
||||||
|
*
|
||||||
|
* Tables creees :
|
||||||
|
* - Referentiels comptables (statiques, seedes ici) : tva_mode, payment_delay,
|
||||||
|
* payment_type, bank.
|
||||||
|
* - Table principale : client (formulaire + Information + Comptabilite +
|
||||||
|
* archive + soft-delete + Timestampable/Blamable).
|
||||||
|
* - Sous-collections : client_category (M2M), client_contact (1:n),
|
||||||
|
* client_address (1:n), client_rib (1:n).
|
||||||
|
* - Jointures de client_address : client_address_site, client_address_contact,
|
||||||
|
* client_address_category.
|
||||||
|
*
|
||||||
|
* Seed `category_type` (extension M0) : DISTRIBUTEUR / COURTIER / SECTEUR /
|
||||||
|
* AUTRE, en `ON CONFLICT (code) DO NOTHING` (idempotent — la table peut deja
|
||||||
|
* porter des donnees en prod). En dev/test, les fixtures purgent et re-seedent
|
||||||
|
* ces 4 types (cf. CategoryTypeFixtures) ; ce seed migration couvre la prod ou
|
||||||
|
* les fixtures ne tournent pas.
|
||||||
|
*
|
||||||
|
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et NON
|
||||||
|
* `App\Module\Commercial\...` : avec plusieurs migrations_paths, Doctrine
|
||||||
|
* Migrations 3.x trie par FQCN alphabetique (AlphabeticalComparator → strcmp).
|
||||||
|
* Un namespace `App\Module\Commercial\...` trierait AVANT `DoctrineMigrations\...`
|
||||||
|
* et la migration s'executerait avant la creation de user/category/site sur
|
||||||
|
* base vide → echec des FK. Le namespace racine garantit l'ordre par timestamp.
|
||||||
|
*
|
||||||
|
* Style DDL aligne sur la migration M0 (Version20260527164000) plutot que sur
|
||||||
|
* le pseudo-SQL de la spec § 3.2 : `INT GENERATED BY DEFAULT AS IDENTITY` (et
|
||||||
|
* non SERIAL), `TIMESTAMP(0) WITHOUT TIME ZONE` (et non TIMESTAMPTZ, car le
|
||||||
|
* `TimestampableBlamableTrait` mappe `datetime_immutable`). Garantit que
|
||||||
|
* `schema:update` restera un no-op quand les entites arriveront (ticket ERP-54).
|
||||||
|
*
|
||||||
|
* Decision Q4 (29/05/2026) : unicite metier sur le NOM DE SOCIETE uniquement.
|
||||||
|
* Pas d'index unique sur siren ni email (RG-1.15 / RG-1.17 supprimees).
|
||||||
|
*
|
||||||
|
* Chaque colonne porte un `COMMENT ON COLUMN` (regle ABSOLUE n°12, garde-fou
|
||||||
|
* ColumnsHaveSqlCommentTest). Les tables n'etant pas encore mappees par l'ORM,
|
||||||
|
* ces commentaires survivent au `schema:update --force` du setup de test.
|
||||||
|
*/
|
||||||
|
final class Version20260601000000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'ERP-53 (M1) : tables client + sous-collections + referentiels comptables + seed category_type.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->createAccountingReferentials();
|
||||||
|
$this->createClientTable();
|
||||||
|
$this->createClientCategory();
|
||||||
|
$this->createClientContact();
|
||||||
|
$this->createClientAddress();
|
||||||
|
$this->createClientAddressJoinTables();
|
||||||
|
$this->createClientRib();
|
||||||
|
$this->seedCategoryTypes();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Ordre inverse des dependances FK : on supprime d'abord les jointures
|
||||||
|
// et sous-collections, puis client, puis les referentiels.
|
||||||
|
$this->addSql('DROP TABLE client_address_category');
|
||||||
|
$this->addSql('DROP TABLE client_address_contact');
|
||||||
|
$this->addSql('DROP TABLE client_address_site');
|
||||||
|
$this->addSql('DROP TABLE client_rib');
|
||||||
|
$this->addSql('DROP TABLE client_address');
|
||||||
|
$this->addSql('DROP TABLE client_contact');
|
||||||
|
$this->addSql('DROP TABLE client_category');
|
||||||
|
$this->addSql('DROP TABLE client');
|
||||||
|
$this->addSql('DROP TABLE bank');
|
||||||
|
$this->addSql('DROP TABLE payment_type');
|
||||||
|
$this->addSql('DROP TABLE payment_delay');
|
||||||
|
$this->addSql('DROP TABLE tva_mode');
|
||||||
|
|
||||||
|
// Retire uniquement les 4 types seedes par cette migration. Les autres
|
||||||
|
// types eventuels (CRUD futur) sont preserves.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM category_type WHERE code IN ('DISTRIBUTEUR', 'COURTIER', 'SECTEUR', 'AUTRE')
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Referentiels comptables (4 tables statiques, memes colonnes)
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private function createAccountingReferentials(): void
|
||||||
|
{
|
||||||
|
$referentials = [
|
||||||
|
'tva_mode' => 'Referentiel des modes de TVA appliques a un client (France, Export, Intracom).',
|
||||||
|
'payment_delay' => 'Referentiel des delais de reglement (15 jours, 30 jours, a reception).',
|
||||||
|
'payment_type' => 'Referentiel des types de reglement (virement, LCR, cheque, non soumise).',
|
||||||
|
'bank' => 'Referentiel des banques selectionnables pour le reglement par virement.',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($referentials as $table => $tableComment) {
|
||||||
|
$this->addSql(sprintf(<<<'SQL'
|
||||||
|
CREATE TABLE %s (
|
||||||
|
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
code VARCHAR(30) NOT NULL,
|
||||||
|
label VARCHAR(120) NOT NULL,
|
||||||
|
position INT DEFAULT 0 NOT NULL,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
)
|
||||||
|
SQL, $table));
|
||||||
|
$this->addSql(sprintf('CREATE UNIQUE INDEX uq_%s_code ON %s (code)', $table, $table));
|
||||||
|
|
||||||
|
$this->comment($table, '_table', $tableComment);
|
||||||
|
$this->comment($table, 'id', 'Identifiant interne auto-incremente.');
|
||||||
|
$this->comment($table, 'code', 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.');
|
||||||
|
$this->comment($table, 'label', 'Libelle affichable (FR, ≤ 120 caracteres).');
|
||||||
|
$this->comment($table, 'position', 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed initial (cf. spec § 3.2). Tables fraichement creees donc vides :
|
||||||
|
// INSERT direct sans ON CONFLICT.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO tva_mode (code, label, position) VALUES
|
||||||
|
('FRANCE_VENTES', 'France (ventes)', 10),
|
||||||
|
('EXPORT_VENTES', 'Export (ventes)', 20),
|
||||||
|
('INTRACOM_VENTES', 'Intracom (ventes)', 30)
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO payment_delay (code, label, position) VALUES
|
||||||
|
('J15', '15 jours', 10),
|
||||||
|
('J30', '30 jours', 20),
|
||||||
|
('A_RECEPTION', 'À réception', 30)
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO payment_type (code, label, position) VALUES
|
||||||
|
('VIREMENT', 'Virement', 10),
|
||||||
|
('LCR', 'LCR', 20),
|
||||||
|
('NON_SOUMISE', 'Non soumise', 30),
|
||||||
|
('CHEQUE', 'Chèque', 40)
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO bank (code, label, position) VALUES
|
||||||
|
('SG', 'Société Générale', 10),
|
||||||
|
('CIC', 'CIC', 20),
|
||||||
|
('CA', 'Crédit Agricole', 30)
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Table principale `client`
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private function createClientTable(): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE client (
|
||||||
|
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
company_name VARCHAR(180) NOT NULL,
|
||||||
|
first_name VARCHAR(120) DEFAULT NULL,
|
||||||
|
last_name VARCHAR(120) DEFAULT NULL,
|
||||||
|
phone_primary VARCHAR(20) NOT NULL,
|
||||||
|
phone_secondary VARCHAR(20) DEFAULT NULL,
|
||||||
|
email VARCHAR(180) NOT NULL,
|
||||||
|
distributor_id INT DEFAULT NULL,
|
||||||
|
broker_id INT DEFAULT NULL,
|
||||||
|
triage_service BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
description TEXT DEFAULT NULL,
|
||||||
|
competitors VARCHAR(255) DEFAULT NULL,
|
||||||
|
founded_at DATE DEFAULT NULL,
|
||||||
|
employees_count INT DEFAULT NULL,
|
||||||
|
revenue_amount NUMERIC(15, 2) DEFAULT NULL,
|
||||||
|
director_name VARCHAR(120) DEFAULT NULL,
|
||||||
|
profit_amount NUMERIC(15, 2) DEFAULT NULL,
|
||||||
|
siren VARCHAR(20) DEFAULT NULL,
|
||||||
|
account_number VARCHAR(40) DEFAULT NULL,
|
||||||
|
tva_mode_id INT DEFAULT NULL,
|
||||||
|
n_tva VARCHAR(40) DEFAULT NULL,
|
||||||
|
payment_delay_id INT DEFAULT NULL,
|
||||||
|
payment_type_id INT DEFAULT NULL,
|
||||||
|
bank_id INT DEFAULT NULL,
|
||||||
|
is_archived BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||||
|
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
created_by INT DEFAULT NULL,
|
||||||
|
updated_by INT DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
CONSTRAINT chk_client_distrib_or_broker
|
||||||
|
CHECK (NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL)),
|
||||||
|
CONSTRAINT fk_client_distributor
|
||||||
|
FOREIGN KEY (distributor_id) REFERENCES client (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_client_broker
|
||||||
|
FOREIGN KEY (broker_id) REFERENCES client (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_client_tva_mode
|
||||||
|
FOREIGN KEY (tva_mode_id) REFERENCES tva_mode (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_client_payment_delay
|
||||||
|
FOREIGN KEY (payment_delay_id) REFERENCES payment_delay (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_client_payment_type
|
||||||
|
FOREIGN KEY (payment_type_id) REFERENCES payment_type (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_client_bank
|
||||||
|
FOREIGN KEY (bank_id) REFERENCES bank (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_client_created_by
|
||||||
|
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_client_updated_by
|
||||||
|
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql('CREATE INDEX idx_client_is_archived ON client (is_archived)');
|
||||||
|
$this->addSql('CREATE INDEX idx_client_deleted_at ON client (deleted_at)');
|
||||||
|
$this->addSql('CREATE INDEX idx_client_distributor_id ON client (distributor_id)');
|
||||||
|
$this->addSql('CREATE INDEX idx_client_broker_id ON client (broker_id)');
|
||||||
|
$this->addSql('CREATE INDEX idx_client_created_by ON client (created_by)');
|
||||||
|
$this->addSql('CREATE INDEX idx_client_updated_by ON client (updated_by)');
|
||||||
|
|
||||||
|
// Unicite metier partielle (Q4) : nom de societe insensible a la casse,
|
||||||
|
// parmi les non-archives ET non soft-deletes uniquement. Pas d'index
|
||||||
|
// unique sur siren ni email.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE UNIQUE INDEX uq_client_company_name_active
|
||||||
|
ON client (LOWER(company_name))
|
||||||
|
WHERE is_archived = FALSE AND deleted_at IS NULL
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->comment('client', '_table', 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).');
|
||||||
|
$this->comment('client', 'id', 'Identifiant interne auto-incremente.');
|
||||||
|
$this->comment('client', '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).');
|
||||||
|
$this->comment('client', 'first_name', 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).');
|
||||||
|
$this->comment('client', 'last_name', 'Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).');
|
||||||
|
$this->comment('client', 'phone_primary', 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.');
|
||||||
|
$this->comment('client', 'phone_secondary', 'Telephone secondaire optionnel — chiffres uniquement (RG-1.20).');
|
||||||
|
$this->comment('client', 'email', 'Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).');
|
||||||
|
$this->comment('client', '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.');
|
||||||
|
$this->comment('client', '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.');
|
||||||
|
$this->comment('client', 'triage_service', 'Drapeau service triage active pour le client. Faux par defaut.');
|
||||||
|
$this->comment('client', 'description', 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.');
|
||||||
|
$this->comment('client', 'competitors', 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).');
|
||||||
|
$this->comment('client', 'founded_at', 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).');
|
||||||
|
$this->comment('client', 'employees_count', 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).');
|
||||||
|
$this->comment('client', 'revenue_amount', 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).');
|
||||||
|
$this->comment('client', 'director_name', 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).');
|
||||||
|
$this->comment('client', 'profit_amount', 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).');
|
||||||
|
$this->comment('client', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).');
|
||||||
|
$this->comment('client', 'account_number', 'Onglet Comptabilite : numero de compte comptable du client.');
|
||||||
|
$this->comment('client', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.');
|
||||||
|
$this->comment('client', 'n_tva', 'Onglet Comptabilite : numero de TVA intracommunautaire.');
|
||||||
|
$this->comment('client', 'payment_delay_id', 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id, ON DELETE RESTRICT.');
|
||||||
|
$this->comment('client', '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).');
|
||||||
|
$this->comment('client', 'bank_id', 'Onglet Comptabilite : banque — FK -> bank.id, ON DELETE RESTRICT. Obligatoire si payment_type = VIREMENT (RG-1.12).');
|
||||||
|
$this->comment('client', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.clients.archive (RG-1.22/23).');
|
||||||
|
$this->comment('client', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration (RG-1.22/23).');
|
||||||
|
$this->comment('client', 'deleted_at', 'Horodatage du soft-delete technique (HP M2) — non expose par l API au M1. Null = ligne active.');
|
||||||
|
$this->addTimestampableBlamableComments('client');
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// M2M client <-> category
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private function createClientCategory(): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE client_category (
|
||||||
|
client_id INT NOT NULL,
|
||||||
|
category_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (client_id, category_id),
|
||||||
|
CONSTRAINT fk_client_category_client
|
||||||
|
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_client_category_category
|
||||||
|
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX idx_client_category_category ON client_category (category_id)');
|
||||||
|
|
||||||
|
$this->comment('client_category', '_table', 'Jointure M2M client <-> category (Catalog) — categories metier du client (au moins une obligatoire).');
|
||||||
|
$this->comment('client_category', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client porteur de la categorie.');
|
||||||
|
$this->comment('client_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie rattachee au client.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Sous-collection : contacts (1:n)
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private function createClientContact(): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE client_contact (
|
||||||
|
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
client_id INT NOT NULL,
|
||||||
|
first_name VARCHAR(120) DEFAULT NULL,
|
||||||
|
last_name VARCHAR(120) DEFAULT NULL,
|
||||||
|
job_title VARCHAR(120) DEFAULT NULL,
|
||||||
|
phone_primary VARCHAR(20) DEFAULT NULL,
|
||||||
|
phone_secondary VARCHAR(20) DEFAULT NULL,
|
||||||
|
email VARCHAR(180) DEFAULT NULL,
|
||||||
|
position INT DEFAULT 0 NOT NULL,
|
||||||
|
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
created_by INT DEFAULT NULL,
|
||||||
|
updated_by INT DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
CONSTRAINT chk_client_contact_name
|
||||||
|
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL),
|
||||||
|
CONSTRAINT fk_client_contact_client
|
||||||
|
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_client_contact_created_by
|
||||||
|
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_client_contact_updated_by
|
||||||
|
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX idx_client_contact_client ON client_contact (client_id)');
|
||||||
|
|
||||||
|
$this->comment('client_contact', '_table', 'Contacts d un client (1:n) — au moins firstName OU lastName par contact (RG-1.05).');
|
||||||
|
$this->comment('client_contact', 'id', 'Identifiant interne auto-incremente.');
|
||||||
|
$this->comment('client_contact', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire du contact.');
|
||||||
|
$this->comment('client_contact', 'first_name', 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).');
|
||||||
|
$this->comment('client_contact', 'last_name', 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).');
|
||||||
|
$this->comment('client_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).');
|
||||||
|
$this->comment('client_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (RG-1.20).');
|
||||||
|
$this->comment('client_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (RG-1.20).');
|
||||||
|
$this->comment('client_contact', 'email', 'Email du contact (lowercase serveur, RG-1.21).');
|
||||||
|
$this->comment('client_contact', 'position', 'Ordre d affichage du contact dans la liste du client (croissant).');
|
||||||
|
$this->addTimestampableBlamableComments('client_contact');
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Sous-collection : adresses (1:n)
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private function createClientAddress(): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE client_address (
|
||||||
|
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
client_id INT NOT NULL,
|
||||||
|
is_prospect BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
is_delivery BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
is_billing BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
country VARCHAR(80) DEFAULT 'France' NOT NULL,
|
||||||
|
postal_code VARCHAR(20) NOT NULL,
|
||||||
|
city VARCHAR(120) NOT NULL,
|
||||||
|
street VARCHAR(255) NOT NULL,
|
||||||
|
street_complement VARCHAR(255) DEFAULT NULL,
|
||||||
|
billing_email VARCHAR(180) DEFAULT NULL,
|
||||||
|
position INT DEFAULT 0 NOT NULL,
|
||||||
|
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
created_by INT DEFAULT NULL,
|
||||||
|
updated_by INT DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
CONSTRAINT chk_client_address_prospect_exclusive
|
||||||
|
CHECK (NOT (is_prospect = TRUE AND (is_delivery = TRUE OR is_billing = TRUE))),
|
||||||
|
CONSTRAINT chk_client_address_billing_email
|
||||||
|
CHECK ((is_billing = FALSE AND billing_email IS NULL)
|
||||||
|
OR (is_billing = TRUE AND billing_email IS NOT NULL)),
|
||||||
|
CONSTRAINT fk_client_address_client
|
||||||
|
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_client_address_created_by
|
||||||
|
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_client_address_updated_by
|
||||||
|
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX idx_client_address_client ON client_address (client_id)');
|
||||||
|
|
||||||
|
$this->comment('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).');
|
||||||
|
$this->comment('client_address', 'id', 'Identifiant interne auto-incremente.');
|
||||||
|
$this->comment('client_address', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.');
|
||||||
|
$this->comment('client_address', 'is_prospect', 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.');
|
||||||
|
$this->comment('client_address', 'is_delivery', 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.');
|
||||||
|
$this->comment('client_address', 'is_billing', 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.');
|
||||||
|
$this->comment('client_address', 'country', 'Pays de l adresse — defaut France.');
|
||||||
|
$this->comment('client_address', 'postal_code', 'Code postal (4-5 chiffres attendus, RG-1.09).');
|
||||||
|
$this->comment('client_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).');
|
||||||
|
$this->comment('client_address', 'street', 'Numero et voie de l adresse.');
|
||||||
|
$this->comment('client_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.');
|
||||||
|
$this->comment('client_address', 'billing_email', 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).');
|
||||||
|
$this->comment('client_address', 'position', 'Ordre d affichage de l adresse dans la liste du client (croissant).');
|
||||||
|
$this->addTimestampableBlamableComments('client_address');
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Jointures de client_address (M2M)
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private function createClientAddressJoinTables(): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE client_address_site (
|
||||||
|
client_address_id INT NOT NULL,
|
||||||
|
site_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (client_address_id, site_id),
|
||||||
|
CONSTRAINT fk_client_address_site_address
|
||||||
|
FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_client_address_site_site
|
||||||
|
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->comment('client_address_site', '_table', 'Jointure M2M client_address <-> site (Sites) — sites desservis par l adresse (>= 1 obligatoire, RG-1.10).');
|
||||||
|
$this->comment('client_address_site', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||||
|
$this->comment('client_address_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.');
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE client_address_contact (
|
||||||
|
client_address_id INT NOT NULL,
|
||||||
|
client_contact_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (client_address_id, client_contact_id),
|
||||||
|
CONSTRAINT fk_client_address_contact_address
|
||||||
|
FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_client_address_contact_contact
|
||||||
|
FOREIGN KEY (client_contact_id) REFERENCES client_contact (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->comment('client_address_contact', '_table', 'Jointure M2M client_address <-> client_contact — contacts associes a une adresse.');
|
||||||
|
$this->comment('client_address_contact', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||||
|
$this->comment('client_address_contact', 'client_contact_id', 'FK -> client_contact.id, ON DELETE CASCADE — contact associe a l adresse.');
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE client_address_category (
|
||||||
|
client_address_id INT NOT NULL,
|
||||||
|
category_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (client_address_id, category_id),
|
||||||
|
CONSTRAINT fk_client_address_category_address
|
||||||
|
FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_client_address_category_category
|
||||||
|
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->comment('client_address_category', '_table', 'Jointure M2M client_address <-> category — categories d adresse (types SECTEUR/AUTRE uniquement, RG-1.29).');
|
||||||
|
$this->comment('client_address_category', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||||
|
$this->comment('client_address_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse (type SECTEUR ou AUTRE, RG-1.29).');
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Sous-collection : RIB (1:n)
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private function createClientRib(): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE client_rib (
|
||||||
|
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
client_id INT NOT NULL,
|
||||||
|
label VARCHAR(120) NOT NULL,
|
||||||
|
bic VARCHAR(20) NOT NULL,
|
||||||
|
iban VARCHAR(34) NOT NULL,
|
||||||
|
position INT DEFAULT 0 NOT NULL,
|
||||||
|
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
created_by INT DEFAULT NULL,
|
||||||
|
updated_by INT DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_client_rib_client
|
||||||
|
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_client_rib_created_by
|
||||||
|
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_client_rib_updated_by
|
||||||
|
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX idx_client_rib_client ON client_rib (client_id)');
|
||||||
|
|
||||||
|
$this->comment('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).');
|
||||||
|
$this->comment('client_rib', 'id', 'Identifiant interne auto-incremente.');
|
||||||
|
$this->comment('client_rib', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire du RIB.');
|
||||||
|
$this->comment('client_rib', 'label', 'Libelle du RIB (ex: compte principal).');
|
||||||
|
$this->comment('client_rib', 'bic', 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).');
|
||||||
|
$this->comment('client_rib', 'iban', 'IBAN du compte (≤ 34 caracteres).');
|
||||||
|
$this->comment('client_rib', 'position', 'Ordre d affichage du RIB dans la liste du client (croissant).');
|
||||||
|
$this->addTimestampableBlamableComments('client_rib');
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Seed extension category_type (M0)
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private function seedCategoryTypes(): void
|
||||||
|
{
|
||||||
|
// Idempotent : la table category_type peut deja porter des donnees en
|
||||||
|
// prod. ON CONFLICT (code) s appuie sur l index unique uq_category_type_code.
|
||||||
|
// NB : la table M0 n a pas de colonne `position` (id/code/label seulement),
|
||||||
|
// contrairement au pseudo-SQL de la spec § 3.3.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO category_type (code, label) VALUES
|
||||||
|
('DISTRIBUTEUR', 'Distributeur'),
|
||||||
|
('COURTIER', 'Courtier'),
|
||||||
|
('SECTEUR', 'Secteur'),
|
||||||
|
('AUTRE', 'Autre')
|
||||||
|
ON CONFLICT (code) DO NOTHING
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Helpers
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
|
||||||
|
* en reutilisant le catalogue partage (source unique, cf. ERP-67).
|
||||||
|
*/
|
||||||
|
private function addTimestampableBlamableComments(string $table): void
|
||||||
|
{
|
||||||
|
foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
|
||||||
|
$this->comment($table, $column, $description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou
|
||||||
|
* `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour eviter
|
||||||
|
* tout echappement d apostrophe.
|
||||||
|
*/
|
||||||
|
private function comment(string $table, string $column, string $description): void
|
||||||
|
{
|
||||||
|
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||||
|
|
||||||
|
if ('_table' === $column) {
|
||||||
|
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addSql(sprintf(
|
||||||
|
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||||
|
$quotedTable,
|
||||||
|
'"'.str_replace('"', '""', $column).'"',
|
||||||
|
$description,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\CategoryProvide
|
|||||||
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository;
|
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository;
|
||||||
use App\Shared\Domain\Attribute\Auditable;
|
use App\Shared\Domain\Attribute\Auditable;
|
||||||
use App\Shared\Domain\Contract\BlamableInterface;
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
|
use App\Shared\Domain\Contract\CategoryInterface;
|
||||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||||
use DateTimeImmutable;
|
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_created_by', columns: ['created_by'])]
|
||||||
#[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])]
|
#[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])]
|
||||||
#[Auditable]
|
#[Auditable]
|
||||||
class Category implements TimestampableInterface, BlamableInterface
|
class Category implements TimestampableInterface, BlamableInterface, CategoryInterface
|
||||||
{
|
{
|
||||||
// === Timestampable + Blamable ===
|
// === Timestampable + Blamable ===
|
||||||
// Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs
|
// Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Infrastructure\DataFixtures;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||||
|
use App\Module\Catalog\Domain\Repository\CategoryTypeRepositoryInterface;
|
||||||
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixtures du module Catalog : seed des types de categorie metier (M1).
|
||||||
|
*
|
||||||
|
* La table `category_type` est creee vide au M0 ; le M1 la peuple avec les 4
|
||||||
|
* types DISTRIBUTEUR / COURTIER / SECTEUR / AUTRE (cf. spec M1 § 3.3).
|
||||||
|
*
|
||||||
|
* Pourquoi une fixture EN PLUS du seed de la migration (Version20260601000000) :
|
||||||
|
* `category_type` est une entite managee par l ORM, donc le purger Doctrine la
|
||||||
|
* vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les 4 types
|
||||||
|
* seedes par la migration disparaitraient apres `make db-reset` / setup de test.
|
||||||
|
* Le seed migration couvre la prod (ou les fixtures ne tournent pas) ; cette
|
||||||
|
* fixture re-aligne dev et test. Les deux chemins produisent un etat identique.
|
||||||
|
*
|
||||||
|
* Idempotence : lookup par `code` parmi les types existants avant insertion,
|
||||||
|
* sur le modele d AppFixtures::ensureSystemRole. Rejouable sans doublon meme
|
||||||
|
* si le purger est desactive.
|
||||||
|
*/
|
||||||
|
class CategoryTypeFixtures extends Fixture
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Source unique des 4 types metier : code technique => libelle FR.
|
||||||
|
* Doit rester aligne sur le seed de la migration Version20260601000000.
|
||||||
|
*/
|
||||||
|
private const TYPES = [
|
||||||
|
'DISTRIBUTEUR' => 'Distributeur',
|
||||||
|
'COURTIER' => 'Courtier',
|
||||||
|
'SECTEUR' => 'Secteur',
|
||||||
|
'AUTRE' => 'Autre',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly CategoryTypeRepositoryInterface $categoryTypeRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function load(ObjectManager $manager): void
|
||||||
|
{
|
||||||
|
// Index des types deja presents par code, pour ne pas creer de doublon.
|
||||||
|
$existingByCode = [];
|
||||||
|
foreach ($this->categoryTypeRepository->findAllOrderedByLabel() as $type) {
|
||||||
|
$existingByCode[$type->getCode()] = $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::TYPES as $code => $label) {
|
||||||
|
$type = $existingByCode[$code] ?? new CategoryType();
|
||||||
|
$type->setCode($code);
|
||||||
|
$type->setLabel($label);
|
||||||
|
$manager->persist($type);
|
||||||
|
}
|
||||||
|
|
||||||
|
$manager->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Commercial\Infrastructure\DataFixtures;
|
||||||
|
|
||||||
|
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 Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixtures du module Commercial : re-seed des 4 referentiels comptables
|
||||||
|
* (tva_mode, payment_delay, payment_type, bank) seedes par la migration M1
|
||||||
|
* (Version20260601000000).
|
||||||
|
*
|
||||||
|
* Pourquoi cette fixture EN PLUS du seed de la migration : depuis ERP-54 ces
|
||||||
|
* 4 tables sont des entites managees par l'ORM, donc le purger Doctrine les
|
||||||
|
* vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les
|
||||||
|
* referentiels seedes par la migration disparaitraient apres `make db-reset`
|
||||||
|
* (0 ligne en dev/test) — cassant les FK Client -> 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<class-string, array<string, array{string, int}>>
|
||||||
|
*/
|
||||||
|
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<string, array{string, int}> $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||||
|
|
||||||
|
use App\Module\Commercial\Domain\Entity\Bank;
|
||||||
|
use App\Module\Commercial\Domain\Repository\BankRepositoryInterface;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Bank>
|
||||||
|
*/
|
||||||
|
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()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||||
|
|
||||||
|
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||||
|
use App\Module\Commercial\Domain\Repository\ClientAddressRepositoryInterface;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<ClientAddress>
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||||
|
|
||||||
|
use App\Module\Commercial\Domain\Entity\ClientContact;
|
||||||
|
use App\Module\Commercial\Domain\Repository\ClientContactRepositoryInterface;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<ClientContact>
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||||
|
|
||||||
|
use App\Module\Commercial\Domain\Entity\Client;
|
||||||
|
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Client>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||||
|
|
||||||
|
use App\Module\Commercial\Domain\Entity\ClientRib;
|
||||||
|
use App\Module\Commercial\Domain\Repository\ClientRibRepositoryInterface;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<ClientRib>
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||||
|
|
||||||
|
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
||||||
|
use App\Module\Commercial\Domain\Repository\PaymentDelayRepositoryInterface;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<PaymentDelay>
|
||||||
|
*/
|
||||||
|
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()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||||
|
|
||||||
|
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||||
|
use App\Module\Commercial\Domain\Repository\PaymentTypeRepositoryInterface;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<PaymentType>
|
||||||
|
*/
|
||||||
|
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()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||||
|
|
||||||
|
use App\Module\Commercial\Domain\Entity\TvaMode;
|
||||||
|
use App\Module\Commercial\Domain\Repository\TvaModeRepositoryInterface;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<TvaMode>
|
||||||
|
*/
|
||||||
|
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()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Domain\Contract;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface minimale exposant ce qu'un module tiers (Commercial...) doit
|
||||||
|
* connaitre d'une Category, sans creer de couplage direct vers le module
|
||||||
|
* Catalog (regle ABSOLUE n°1 : pas d'import inter-modules).
|
||||||
|
*
|
||||||
|
* Implementee par App\Module\Catalog\Domain\Entity\Category.
|
||||||
|
* Utilisee comme cible des ManyToMany Client.categories et
|
||||||
|
* ClientAddress.categories via resolve_target_entities (cf. doctrine.yaml),
|
||||||
|
* sur le meme modele que SiteInterface / UserInterface.
|
||||||
|
*/
|
||||||
|
interface CategoryInterface
|
||||||
|
{
|
||||||
|
public function getId(): ?int;
|
||||||
|
|
||||||
|
public function getName(): ?string;
|
||||||
|
}
|
||||||
@@ -128,6 +128,135 @@ final class ColumnCommentsCatalog
|
|||||||
'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur ayant acces au site.',
|
'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur ayant acces au site.',
|
||||||
'site_id' => 'FK -> site.id, ON DELETE CASCADE — site accessible par l utilisateur.',
|
'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
|
* Construit la liste des requetes SQL `COMMENT ON TABLE/COLUMN` (en
|
||||||
* dollar-quoting Postgres `$_$`) a partir du catalogue.
|
* dollar-quoting Postgres `$_$`) a partir du catalogue.
|
||||||
*
|
*
|
||||||
|
* @param null|list<string> $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<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
public static function toSqlStatements(): array
|
public static function toSqlStatements(?array $onlyTables = null): array
|
||||||
{
|
{
|
||||||
|
$allowed = null === $onlyTables ? null : array_fill_keys($onlyTables, true);
|
||||||
|
|
||||||
$statements = [];
|
$statements = [];
|
||||||
foreach (self::comments() as $table => $entries) {
|
foreach (self::comments() as $table => $entries) {
|
||||||
|
if (null !== $allowed && !isset($allowed[$table])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$quotedTable = self::quoteIdent($table);
|
$quotedTable = self::quoteIdent($table);
|
||||||
foreach ($entries as $column => $description) {
|
foreach ($entries as $column => $description) {
|
||||||
if ('_table' === $column) {
|
if ('_table' === $column) {
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\Architecture;
|
namespace App\Tests\Architecture;
|
||||||
|
|
||||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
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\Permission;
|
||||||
use App\Module\Core\Domain\Entity\Role;
|
use App\Module\Core\Domain\Entity\Role;
|
||||||
use App\Module\Core\Domain\Entity\User;
|
use App\Module\Core\Domain\Entity\User;
|
||||||
@@ -49,6 +53,11 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
|
|||||||
* - CategoryType : referentiel statique (codes de typage des categories),
|
* - CategoryType : referentiel statique (codes de typage des categories),
|
||||||
* pas de besoin de tracabilite user-driven (cree par migration/seed,
|
* 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.
|
* 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.
|
* Les futurs referentiels statiques s'ajoutent ici avec une justification.
|
||||||
*/
|
*/
|
||||||
@@ -58,6 +67,10 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
|
|||||||
Permission::class,
|
Permission::class,
|
||||||
Site::class,
|
Site::class,
|
||||||
CategoryType::class,
|
CategoryType::class,
|
||||||
|
TvaMode::class,
|
||||||
|
PaymentDelay::class,
|
||||||
|
PaymentType::class,
|
||||||
|
Bank::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
public function testAllBusinessEntitiesImplementBothInterfaces(): void
|
public function testAllBusinessEntitiesImplementBothInterfaces(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user