From 56cf492dcc6111d75b47c39b852675c4fe7451d5 Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Mon, 1 Jun 2026 15:19:43 +0000 Subject: [PATCH 1/2] =?UTF-8?q?[ERP-53]=20Migrer=20les=20tables=20Client?= =?UTF-8?q?=20+=20sous-collections=20+=20r=C3=A9f=C3=A9rentiels=20comptabl?= =?UTF-8?q?es=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Contexte Ticket Lesstime : **#53** (ERP-53) — premier ticket back du M1 (Répertoire clients). Spec back : `docs/specs/M1-clients/spec-back.md` § 3.2 + § 3.3. ## Implémentation - Migration Doctrine `migrations/Version20260601000000.php` (12 tables) + fixture `CategoryTypeFixtures`. - **4 référentiels comptables** seedés : `tva_mode` (3), `payment_delay` (3), `payment_type` (4), `bank` (3). - **Table `client`** : 31 colonnes (formulaire + Information + Comptabilité + archive + soft-delete + Timestampable/Blamable). - **4 sous-collections** : `client_category` (M2M), `client_contact`, `client_address`, `client_rib` + **3 jointures** d'adresse (`client_address_site`, `client_address_contact`, `client_address_category`). - **4 CHECK** : mutex distributor/broker, contact name, address prospect exclusif, billing email conditionnel. - **1 index partiel unique** : `uq_client_company_name_active` sur `LOWER(company_name) WHERE is_archived=false AND deleted_at IS NULL` (décision Q4 — **pas** d'unicité siren/email). - **Seed `category_type`** : DISTRIBUTEUR / COURTIER / SECTEUR / AUTRE (`ON CONFLICT (code) DO NOTHING` en migration pour la prod, + fixture idempotente pour dev/test purgés). - `COMMENT ON COLUMN` sur **chaque** colonne (convention ERP-67, garde-fou vert). ## RG couvertes (niveau BDD) RG-1.03 (mutex distrib/broker), RG-1.05 (contact name), RG-1.06/07/08 (adresse prospect exclusif), RG-1.11 (billing email), RG-1.16 (unicité company_name — RG-1.15/1.17 supprimées par Q4), RG-1.22 (is_archived + archived_at). ## Écarts assumés vs spec (cf. docblock migration + cahier de test du ticket) 1. **Namespace `migrations/` racine** au lieu de `App\Module\Commercial\…` : vérifié empiriquement que Doctrine 3.9.6 (AlphabeticalComparator → strcmp FQCN) trierait le namespace Commercial **avant** `DoctrineMigrations` → migration client exécutée avant user/category/site → échec FK sur base vide. Le namespace racine garantit l'ordre par timestamp. 2. **DDL aligné Doctrine** : `INT GENERATED BY DEFAULT AS IDENTITY` + `TIMESTAMP(0) WITHOUT TIME ZONE` (et non SERIAL/TIMESTAMPTZ) → forward-compatible avec les entités du ticket 1.1 (schema:update no-op). 3. **Seed `category_type (code, label)` sans `position`** : la table M0 n'a pas de colonne `position` (coquille du pseudo-SQL § 3.3). > **Note ERP-54** : à l'arrivée des entités Client*, `schema:update` droppera leurs COMMENT + l'index partiel. Prévoir l'ajout au `ColumnCommentsCatalog` + recréation de l'index dans `test-db-setup` (pattern `uq_category_name_type_active`). ## Tests - `make php-cs-fixer-allow-risky` ✓ - `make db-reset` ✓ + vérifications psql manuelles (8 cas : CHECK, unicité partielle, archivage, siren/email dupliqués, seeds) - `make test` ✓ — **312 tests OK, 0 régression** --------- Co-authored-by: Matthieu Co-authored-by: Matthieu Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/27 Co-authored-by: THOLOT DECHENE Matthieu Co-committed-by: THOLOT DECHENE Matthieu --- docs/specs/M1-clients/spec-back.md | 15 +- migrations/Version20260601000000.php | 554 ++++++++++++++++++ .../DataFixtures/CategoryTypeFixtures.php | 63 ++ 3 files changed, 629 insertions(+), 3 deletions(-) create mode 100644 migrations/Version20260601000000.php create mode 100644 src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php diff --git a/docs/specs/M1-clients/spec-back.md b/docs/specs/M1-clients/spec-back.md index c8f5a0d..cd7359a 100644 --- a/docs/specs/M1-clients/spec-back.md +++ b/docs/specs/M1-clients/spec-back.md @@ -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 -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 -- ===================================================================== @@ -475,7 +477,14 @@ INSERT INTO category_type (code, label, position) VALUES ('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 @@ -971,7 +980,7 @@ Cf. § 2.6. Pattern Shared standard. - [ ] **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) - [ ] **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) diff --git a/migrations/Version20260601000000.php b/migrations/Version20260601000000.php new file mode 100644 index 0000000..75a0934 --- /dev/null +++ b/migrations/Version20260601000000.php @@ -0,0 +1,554 @@ +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 ET restes + // orphelins (aucune `category` ne les reference). Sans la clause + // NOT EXISTS, le DELETE casse sur la FK RESTRICT category.category_type_id + // des qu'une categorie pointe sur l'un d'eux. Symetrique du + // `ON CONFLICT (code) DO NOTHING` du up() : on ne defait que ce qu'on a + // reellement cree et qui n'est pas reutilise. + $this->addSql(<<<'SQL' + DELETE FROM category_type + WHERE code IN ('DISTRIBUTEUR', 'COURTIER', 'SECTEUR', 'AUTRE') + AND NOT EXISTS ( + SELECT 1 FROM category c WHERE c.category_type_id = category_type.id + ) + 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)'); + + // Index sur les FK des referentiels comptables — coherence avec les autres + // FK deja indexees ci-dessus (Postgres n'indexe pas automatiquement les + // colonnes portant une FOREIGN KEY). + $this->addSql('CREATE INDEX idx_client_tva_mode_id ON client (tva_mode_id)'); + $this->addSql('CREATE INDEX idx_client_payment_delay_id ON client (payment_delay_id)'); + $this->addSql('CREATE INDEX idx_client_payment_type_id ON client (payment_type_id)'); + $this->addSql('CREATE INDEX idx_client_bank_id ON client (bank_id)'); + + // 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, + )); + } +} diff --git a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php new file mode 100644 index 0000000..43b7686 --- /dev/null +++ b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php @@ -0,0 +1,63 @@ + 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(); + } +} From b495e4030a1fb6b386ddc29dbda52943bfc7b34e Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Mon, 1 Jun 2026 15:20:22 +0000 Subject: [PATCH 2/2] =?UTF-8?q?[ERP-54]=20Cr=C3=A9er=20les=20entit=C3=A9s?= =?UTF-8?q?=20Client=20+=20sous-entit=C3=A9s=20+=20r=C3=A9f=C3=A9rentiels?= =?UTF-8?q?=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Contexte Ticket Lesstime **#54** (1.1 / Backend / M) — spec `docs/specs/M1-clients/spec-back.md` § 3.4 / § 3.5. > 🔗 **MR stackée sur ERP-53** — cible `feature/ERP-53-migrer-tables-client-m1`, **pas** `develop`. À repointer vers `develop` quand ERP-53 sera mergé (cf. `STACK-BRANCHES-PROCEDURE.md`). Le diff ne montre que les fichiers d'ERP-54. ## Contenu **9 entités** (`src/Module/Commercial/Domain/Entity/`) : - Métier : `Client`, `ClientContact`, `ClientAddress`, `ClientRib` — `#[Auditable]` + Timestampable/Blamable. - Référentiels statiques lecture seule : `TvaMode`, `PaymentDelay`, `PaymentType`, `Bank` — whitelistés dans `EntitiesAreTimestampableBlamableTest::EXCLUDED`. **8 repositories** interfaces (`Domain/Repository/`) + impl Doctrine (`Infrastructure/Doctrine/`). > La spec § 3.5 ne définit que 8 entités (4 métier + 4 référentiels) ; pas de 9ᵉ entité malgré la formulation « 9 paires » du ticket. ## Décisions - **Aucun `#[ApiResource]` dans ce ticket** : le bloc ApiResource du `Client` (§ 3.4) référence `ClientProvider`/`ClientProcessor` = périmètre **ERP-55**. L'inclure casserait `cache:clear`/`make test`/`schema:validate`. Les entités sont des entités Doctrine pures (ORM + Assert + Groups). Endpoints lecture seule des référentiels → ticket dédié. - **Q4** : `Client` sans `#[ORM\UniqueConstraint]` — unicité du nom de société portée par l'index partiel Postgres `uq_client_company_name_active` (inexprimable en attribut ORM). - **Audit RIB (29/05)** : aucun `#[AuditIgnore]` sur `ClientRib.iban`/`bic` (tous champs audités, audit admin-only). - **Cross-module (règle n°1)** : M2M `Category` via le contrat `Shared\Domain\Contract\CategoryInterface` + `resolve_target_entities` (pas d'import direct Catalog→Commercial) ; `ClientAddress.sites` via `SiteInterface` existant. ## Infra nécessaire (découvert pendant le dev) - `doctrine.yaml` : mapping ORM du module `Commercial` (mappings explicites par module) + résolution `CategoryInterface → Category`. - `CommercialReferentialFixtures` **créée** (n'existait pas — ERP-53 avait seedé les CategoryType côté Catalog) : re-seed idempotent des 4 référentiels, sinon vidés au `db-reset` (désormais tables mappées). - `ColumnCommentsCatalog` étendu (colonnes M1) pour le chemin `schema:update`/test — sinon `ColumnsHaveSqlCommentTest` (garde-fou n°12) échoue. - Migration retrofit `Version20260528120000` (ERP-67) rendue résiliente (`$schema->hasTable()`) : elle rejouait tout le catalogue mais s'exécute avant la création des tables M1 → `relation tva_mode does not exist`. Conforme à son docblock (« les futures migrations posent leurs propres COMMENT »). - `makefile test-db-setup` : recréation de l'index partiel `uq_client_company_name_active` (analogue de la ligne existante pour `category`). ## Vérifications - `make php-cs-fixer-allow-risky` ✓ - `make db-reset` ✓ (bout en bout ; 4 référentiels + 4 CategoryType présents, 2 index partiels créés) - `make test` ✓ **312/312** (Architecture vert, 0 régression M0) - `doctrine:schema:validate` : Mapping **OK** ; « not in sync » = bruit cosmétique pré-existant du projet (clear COMMENT hors-ORM, drop index partiels, renommages d'index). Seul diff introduit : renommage cosmétique de l'index M2M `idx_client_category_category` (même colonne) — aucun écart de type/colonne/FK vs migration ERP-53. --------- Co-authored-by: admin malio Co-authored-by: Matthieu Co-authored-by: Matthieu Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/29 Co-authored-by: THOLOT DECHENE Matthieu Co-committed-by: THOLOT DECHENE Matthieu --- config/packages/doctrine.yaml | 14 + makefile | 16 +- migrations/Version20260528120000.php | 21 +- src/Module/Catalog/Domain/Entity/Category.php | 3 +- src/Module/Commercial/Domain/Entity/Bank.php | 83 +++ .../Commercial/Domain/Entity/Client.php | 638 ++++++++++++++++++ .../Domain/Entity/ClientAddress.php | 337 +++++++++ .../Domain/Entity/ClientContact.php | 178 +++++ .../Commercial/Domain/Entity/ClientRib.php | 134 ++++ .../Commercial/Domain/Entity/PaymentDelay.php | 83 +++ .../Commercial/Domain/Entity/PaymentType.php | 86 +++ .../Commercial/Domain/Entity/TvaMode.php | 88 +++ .../Repository/BankRepositoryInterface.php | 19 + .../ClientAddressRepositoryInterface.php | 14 + .../ClientContactRepositoryInterface.php | 14 + .../Repository/ClientRepositoryInterface.php | 23 + .../ClientRibRepositoryInterface.php | 14 + .../PaymentDelayRepositoryInterface.php | 19 + .../PaymentTypeRepositoryInterface.php | 19 + .../Repository/TvaModeRepositoryInterface.php | 20 + .../CommercialReferentialFixtures.php | 94 +++ .../Doctrine/DoctrineBankRepository.php | 36 + .../DoctrineClientAddressRepository.php | 32 + .../DoctrineClientContactRepository.php | 32 + .../Doctrine/DoctrineClientRepository.php | 47 ++ .../Doctrine/DoctrineClientRibRepository.php | 32 + .../DoctrinePaymentDelayRepository.php | 36 + .../DoctrinePaymentTypeRepository.php | 36 + .../Doctrine/DoctrineTvaModeRepository.php | 36 + .../Domain/Contract/CategoryInterface.php | 22 + .../Database/ColumnCommentsCatalog.php | 144 +++- .../EntitiesAreTimestampableBlamableTest.php | 13 + 32 files changed, 2373 insertions(+), 10 deletions(-) create mode 100644 src/Module/Commercial/Domain/Entity/Bank.php create mode 100644 src/Module/Commercial/Domain/Entity/Client.php create mode 100644 src/Module/Commercial/Domain/Entity/ClientAddress.php create mode 100644 src/Module/Commercial/Domain/Entity/ClientContact.php create mode 100644 src/Module/Commercial/Domain/Entity/ClientRib.php create mode 100644 src/Module/Commercial/Domain/Entity/PaymentDelay.php create mode 100644 src/Module/Commercial/Domain/Entity/PaymentType.php create mode 100644 src/Module/Commercial/Domain/Entity/TvaMode.php create mode 100644 src/Module/Commercial/Domain/Repository/BankRepositoryInterface.php create mode 100644 src/Module/Commercial/Domain/Repository/ClientAddressRepositoryInterface.php create mode 100644 src/Module/Commercial/Domain/Repository/ClientContactRepositoryInterface.php create mode 100644 src/Module/Commercial/Domain/Repository/ClientRepositoryInterface.php create mode 100644 src/Module/Commercial/Domain/Repository/ClientRibRepositoryInterface.php create mode 100644 src/Module/Commercial/Domain/Repository/PaymentDelayRepositoryInterface.php create mode 100644 src/Module/Commercial/Domain/Repository/PaymentTypeRepositoryInterface.php create mode 100644 src/Module/Commercial/Domain/Repository/TvaModeRepositoryInterface.php create mode 100644 src/Module/Commercial/Infrastructure/DataFixtures/CommercialReferentialFixtures.php create mode 100644 src/Module/Commercial/Infrastructure/Doctrine/DoctrineBankRepository.php create mode 100644 src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientAddressRepository.php create mode 100644 src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientContactRepository.php create mode 100644 src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php create mode 100644 src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRibRepository.php create mode 100644 src/Module/Commercial/Infrastructure/Doctrine/DoctrinePaymentDelayRepository.php create mode 100644 src/Module/Commercial/Infrastructure/Doctrine/DoctrinePaymentTypeRepository.php create mode 100644 src/Module/Commercial/Infrastructure/Doctrine/DoctrineTvaModeRepository.php create mode 100644 src/Shared/Domain/Contract/CategoryInterface.php diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index e2488bd..5aeb29c 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -37,6 +37,10 @@ doctrine: # Permet a Shared de referencer UserInterface dans ses ORM mappings sans # importer la classe concrete du module Core (cf. spec-back M0 § 2.8). Symfony\Component\Security\Core\User\UserInterface: App\Module\Core\Domain\Entity\User + # Cible des ManyToMany Client.categories / ClientAddress.categories (M1). + # Permet au module Commercial de referencer une Category via le contrat + # Shared sans importer la classe concrete du module Catalog (regle n°1). + App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category mappings: Core: type: attribute @@ -66,6 +70,16 @@ doctrine: dir: '%kernel.project_dir%/src/Module/Catalog/Domain/Entity' prefix: 'App\Module\Catalog\Domain\Entity' alias: Catalog + # Mapping inconditionnel du module Commercial (meme logique que Catalog) : + # les tables (client, sous-collections, referentiels comptables) creees + # par la migration M1 (Version20260601000000) doivent etre connues de + # l'ORM. L'activation fonctionnelle passe par config/modules.php. + Commercial: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/src/Module/Commercial/Domain/Entity' + prefix: 'App\Module\Commercial\Domain\Entity' + alias: Commercial controller_resolver: auto_mapping: false diff --git a/makefile b/makefile index 71ab933..74857f9 100644 --- a/makefile +++ b/makefile @@ -200,13 +200,14 @@ migration-migrate: # en DB, le purger crash. # 3. fixtures -> sync-permissions : fixtures:load purge la table permission, # donc sync doit passer apres. -# 4. recreation index `uq_category_name_type_active` : schema:update drop -# les index orphelins du mapping ORM. L'index partiel (LOWER + WHERE) du -# M0 Catalog n'est pas exprimable via les attributs Doctrine ORM 3 -# (fonctionnel + partiel), donc il disparait apres schema:update. On le -# recree par dbal:run-sql pour que les tests RG-1.07 (unicite -# case-insensitive) voient bien la contrainte SQL. Sans ce restore, les -# POST doublons remontent 201 au lieu de 409. +# 4. recreation des index partiels uniques : schema:update drop les index +# orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas +# exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc +# ils disparaissent apres schema:update. On les recree par dbal:run-sql : +# - `uq_category_name_type_active` (M0 Catalog) : tests RG-1.07. +# - `uq_client_company_name_active` (M1 Commercial) : unicite nom societe +# parmi actifs non archives/non supprimes (RG-1.16), tests ERP-55. +# Sans ces restores, les POST doublons remontent 201 au lieu de 409. # 5. app:apply-column-comments : meme cause, schema:update drop les COMMENT # ON COLUMN/TABLE des tables managees par l'ORM (le mapping PHP ne porte # pas d'attribut options['comment']). On rejoue le catalogue partage @@ -220,6 +221,7 @@ test-db-setup: $(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load $(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL" + $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" fixtures: $(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load diff --git a/migrations/Version20260528120000.php b/migrations/Version20260528120000.php index 6e38e14..e82ad37 100644 --- a/migrations/Version20260528120000.php +++ b/migrations/Version20260528120000.php @@ -39,7 +39,19 @@ final class Version20260528120000 extends AbstractMigration public function up(Schema $schema): void { - foreach (ColumnCommentsCatalog::toSqlStatements() as $sql) { + // Ne commente que les tables deja presentes a ce stade de la chaine de + // migrations. Les modules crees plus tard (ex: M1 Commercial, 06-01) + // figurent desormais dans le catalogue partage mais leurs tables + // n'existent pas encore ici : elles posent leurs propres COMMENT dans + // leur migration dediee (regle ABSOLUE n°12). Garde-fou indispensable, + // sinon l'ajout d'un module au catalogue casse ce retrofit avec un + // "relation X does not exist". + $existingTables = array_values(array_filter( + array_keys(ColumnCommentsCatalog::comments()), + static fn (string $table): bool => $schema->hasTable($table), + )); + + foreach (ColumnCommentsCatalog::toSqlStatements($existingTables) as $sql) { $this->addSql($sql); } } @@ -47,6 +59,13 @@ final class Version20260528120000 extends AbstractMigration public function down(Schema $schema): void { foreach (ColumnCommentsCatalog::comments() as $table => $entries) { + // Symetrie avec up() : on n'efface que les commentaires des tables + // presentes (les tables des modules ulterieurs sont gerees par leur + // propre migration). + if (!$schema->hasTable($table)) { + continue; + } + $quotedTable = '"'.str_replace('"', '""', $table).'"'; foreach ($entries as $column => $_) { if ('_table' === $column) { diff --git a/src/Module/Catalog/Domain/Entity/Category.php b/src/Module/Catalog/Domain/Entity/Category.php index 6bbb52d..9ae95fd 100644 --- a/src/Module/Catalog/Domain/Entity/Category.php +++ b/src/Module/Catalog/Domain/Entity/Category.php @@ -15,6 +15,7 @@ use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\CategoryProvide use App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; +use App\Shared\Domain\Contract\CategoryInterface; use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Trait\TimestampableBlamableTrait; use DateTimeImmutable; @@ -82,7 +83,7 @@ use Symfony\Component\Validator\Constraints as Assert; #[ORM\Index(name: 'idx_category_created_by', columns: ['created_by'])] #[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])] #[Auditable] -class Category implements TimestampableInterface, BlamableInterface +class Category implements TimestampableInterface, BlamableInterface, CategoryInterface { // === Timestampable + Blamable === // Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs diff --git a/src/Module/Commercial/Domain/Entity/Bank.php b/src/Module/Commercial/Domain/Entity/Bank.php new file mode 100644 index 0000000..1524a0a --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/Bank.php @@ -0,0 +1,83 @@ + 0])] + #[Groups(['bank:read'])] + private int $position = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): static + { + $this->code = $code; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): static + { + $this->label = $label; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } +} diff --git a/src/Module/Commercial/Domain/Entity/Client.php b/src/Module/Commercial/Domain/Entity/Client.php new file mode 100644 index 0000000..8eee053 --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/Client.php @@ -0,0 +1,638 @@ + false])] + #[Groups(['client:read', 'client:write:main'])] + private bool $triageService = false; + + // RG : au moins une categorie (Count min 1). M2M vers Category via le contrat + // CategoryInterface (resolve_target_entities -> Category). + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: CategoryInterface::class)] + #[ORM\JoinTable(name: 'client_category')] + #[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] + #[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')] + #[Groups(['client:read', 'client:write:main'])] + private Collection $categories; + + // === Onglet Information === + #[ORM\Column(type: 'text', nullable: true)] + #[Groups(['client:read', 'client:write:information'])] + private ?string $description = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['client:read', 'client:write:information'])] + private ?string $competitors = null; + + #[ORM\Column(type: 'date_immutable', nullable: true)] + #[Groups(['client:read', 'client:write:information'])] + private ?DateTimeImmutable $foundedAt = null; + + #[ORM\Column(nullable: true)] + #[Assert\PositiveOrZero] + #[Groups(['client:read', 'client:write:information'])] + private ?int $employeesCount = null; + + #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)] + #[Groups(['client:read', 'client:write:information'])] + private ?string $revenueAmount = null; + + #[ORM\Column(length: 120, nullable: true)] + #[Groups(['client:read', 'client:write:information'])] + private ?string $directorName = null; + + #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)] + #[Groups(['client:read', 'client:write:information'])] + private ?string $profitAmount = null; + + // === Onglet Comptabilite === + // Lecture conditionnee via le groupe `client:read:accounting` (ajoute par le + // futur Provider si l'user a la permission accounting.view). Ecriture via + // `client:write:accounting` (le futur Processor exige accounting.manage). + #[ORM\Column(length: 20, nullable: true)] + #[Groups(['client:read:accounting', 'client:write:accounting'])] + private ?string $siren = null; + + #[ORM\Column(length: 40, nullable: true)] + #[Groups(['client:read:accounting', 'client:write:accounting'])] + private ?string $accountNumber = null; + + #[ORM\ManyToOne(targetEntity: TvaMode::class)] + #[ORM\JoinColumn(name: 'tva_mode_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['client:read:accounting', 'client:write:accounting'])] + private ?TvaMode $tvaMode = null; + + #[ORM\Column(length: 40, nullable: true)] + #[Groups(['client:read:accounting', 'client:write:accounting'])] + private ?string $nTva = null; + + #[ORM\ManyToOne(targetEntity: PaymentDelay::class)] + #[ORM\JoinColumn(name: 'payment_delay_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['client:read:accounting', 'client:write:accounting'])] + private ?PaymentDelay $paymentDelay = null; + + #[ORM\ManyToOne(targetEntity: PaymentType::class)] + #[ORM\JoinColumn(name: 'payment_type_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['client:read:accounting', 'client:write:accounting'])] + private ?PaymentType $paymentType = null; + + #[ORM\ManyToOne(targetEntity: Bank::class)] + #[ORM\JoinColumn(name: 'bank_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['client:read:accounting', 'client:write:accounting'])] + private ?Bank $bank = null; + + // === Sous-collections (exposees via sous-ressources API dediees, ulterieur) === + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $contacts; + + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $addresses; + + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $ribs; + + // === Archive / Soft delete === + #[ORM\Column(name: 'is_archived', options: ['default' => false])] + #[Groups(['client:read', 'client:write:archive'])] + private bool $isArchived = false; + + #[ORM\Column(type: 'datetime_immutable', nullable: true)] + #[Groups(['client:read'])] + private ?DateTimeImmutable $archivedAt = null; + + // Soft delete technique (HP-M2-1) : non expose en lecture/ecriture au M1. + #[ORM\Column(type: 'datetime_immutable', nullable: true)] + private ?DateTimeImmutable $deletedAt = null; + + public function __construct() + { + $this->categories = new ArrayCollection(); + $this->contacts = new ArrayCollection(); + $this->addresses = new ArrayCollection(); + $this->ribs = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCompanyName(): ?string + { + return $this->companyName; + } + + public function setCompanyName(string $companyName): static + { + $this->companyName = $companyName; + + return $this; + } + + public function getFirstName(): ?string + { + return $this->firstName; + } + + public function setFirstName(?string $firstName): static + { + $this->firstName = $firstName; + + return $this; + } + + public function getLastName(): ?string + { + return $this->lastName; + } + + public function setLastName(?string $lastName): static + { + $this->lastName = $lastName; + + return $this; + } + + public function getPhonePrimary(): ?string + { + return $this->phonePrimary; + } + + public function setPhonePrimary(string $phonePrimary): static + { + $this->phonePrimary = $phonePrimary; + + return $this; + } + + public function getPhoneSecondary(): ?string + { + return $this->phoneSecondary; + } + + public function setPhoneSecondary(?string $phoneSecondary): static + { + $this->phoneSecondary = $phoneSecondary; + + return $this; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(string $email): static + { + $this->email = $email; + + return $this; + } + + public function getDistributor(): ?Client + { + return $this->distributor; + } + + public function setDistributor(?Client $distributor): static + { + $this->distributor = $distributor; + + return $this; + } + + public function getBroker(): ?Client + { + return $this->broker; + } + + public function setBroker(?Client $broker): static + { + $this->broker = $broker; + + return $this; + } + + public function isTriageService(): bool + { + return $this->triageService; + } + + public function setTriageService(bool $triageService): static + { + $this->triageService = $triageService; + + return $this; + } + + /** @return Collection */ + public function getCategories(): Collection + { + return $this->categories; + } + + public function addCategory(CategoryInterface $category): static + { + if (!$this->categories->contains($category)) { + $this->categories->add($category); + } + + return $this; + } + + public function removeCategory(CategoryInterface $category): static + { + $this->categories->removeElement($category); + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): static + { + $this->description = $description; + + return $this; + } + + public function getCompetitors(): ?string + { + return $this->competitors; + } + + public function setCompetitors(?string $competitors): static + { + $this->competitors = $competitors; + + return $this; + } + + public function getFoundedAt(): ?DateTimeImmutable + { + return $this->foundedAt; + } + + public function setFoundedAt(?DateTimeImmutable $foundedAt): static + { + $this->foundedAt = $foundedAt; + + return $this; + } + + public function getEmployeesCount(): ?int + { + return $this->employeesCount; + } + + public function setEmployeesCount(?int $employeesCount): static + { + $this->employeesCount = $employeesCount; + + return $this; + } + + public function getRevenueAmount(): ?string + { + return $this->revenueAmount; + } + + public function setRevenueAmount(?string $revenueAmount): static + { + $this->revenueAmount = $revenueAmount; + + return $this; + } + + public function getDirectorName(): ?string + { + return $this->directorName; + } + + public function setDirectorName(?string $directorName): static + { + $this->directorName = $directorName; + + return $this; + } + + public function getProfitAmount(): ?string + { + return $this->profitAmount; + } + + public function setProfitAmount(?string $profitAmount): static + { + $this->profitAmount = $profitAmount; + + return $this; + } + + public function getSiren(): ?string + { + return $this->siren; + } + + public function setSiren(?string $siren): static + { + $this->siren = $siren; + + return $this; + } + + public function getAccountNumber(): ?string + { + return $this->accountNumber; + } + + public function setAccountNumber(?string $accountNumber): static + { + $this->accountNumber = $accountNumber; + + return $this; + } + + public function getTvaMode(): ?TvaMode + { + return $this->tvaMode; + } + + public function setTvaMode(?TvaMode $tvaMode): static + { + $this->tvaMode = $tvaMode; + + return $this; + } + + public function getNTva(): ?string + { + return $this->nTva; + } + + public function setNTva(?string $nTva): static + { + $this->nTva = $nTva; + + return $this; + } + + public function getPaymentDelay(): ?PaymentDelay + { + return $this->paymentDelay; + } + + public function setPaymentDelay(?PaymentDelay $paymentDelay): static + { + $this->paymentDelay = $paymentDelay; + + return $this; + } + + public function getPaymentType(): ?PaymentType + { + return $this->paymentType; + } + + public function setPaymentType(?PaymentType $paymentType): static + { + $this->paymentType = $paymentType; + + return $this; + } + + public function getBank(): ?Bank + { + return $this->bank; + } + + public function setBank(?Bank $bank): static + { + $this->bank = $bank; + + return $this; + } + + /** @return Collection */ + public function getContacts(): Collection + { + return $this->contacts; + } + + public function addContact(ClientContact $contact): static + { + if (!$this->contacts->contains($contact)) { + $this->contacts->add($contact); + $contact->setClient($this); + } + + return $this; + } + + public function removeContact(ClientContact $contact): static + { + if ($this->contacts->removeElement($contact) && $contact->getClient() === $this) { + $contact->setClient(null); + } + + return $this; + } + + /** @return Collection */ + public function getAddresses(): Collection + { + return $this->addresses; + } + + public function addAddress(ClientAddress $address): static + { + if (!$this->addresses->contains($address)) { + $this->addresses->add($address); + $address->setClient($this); + } + + return $this; + } + + public function removeAddress(ClientAddress $address): static + { + if ($this->addresses->removeElement($address) && $address->getClient() === $this) { + $address->setClient(null); + } + + return $this; + } + + /** @return Collection */ + public function getRibs(): Collection + { + return $this->ribs; + } + + public function addRib(ClientRib $rib): static + { + if (!$this->ribs->contains($rib)) { + $this->ribs->add($rib); + $rib->setClient($this); + } + + return $this; + } + + public function removeRib(ClientRib $rib): static + { + if ($this->ribs->removeElement($rib) && $rib->getClient() === $this) { + $rib->setClient(null); + } + + return $this; + } + + public function isArchived(): bool + { + return $this->isArchived; + } + + public function setIsArchived(bool $isArchived): static + { + $this->isArchived = $isArchived; + + return $this; + } + + public function getArchivedAt(): ?DateTimeImmutable + { + return $this->archivedAt; + } + + public function setArchivedAt(?DateTimeImmutable $archivedAt): static + { + $this->archivedAt = $archivedAt; + + return $this; + } + + public function getDeletedAt(): ?DateTimeImmutable + { + return $this->deletedAt; + } + + public function setDeletedAt(?DateTimeImmutable $deletedAt): static + { + $this->deletedAt = $deletedAt; + + return $this; + } +} diff --git a/src/Module/Commercial/Domain/Entity/ClientAddress.php b/src/Module/Commercial/Domain/Entity/ClientAddress.php new file mode 100644 index 0000000..26e5f8d --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/ClientAddress.php @@ -0,0 +1,337 @@ + false])] + #[Groups(['client_address:read', 'client_address:write'])] + private bool $isProspect = false; + + #[ORM\Column(name: 'is_delivery', options: ['default' => false])] + #[Groups(['client_address:read', 'client_address:write'])] + private bool $isDelivery = false; + + #[ORM\Column(name: 'is_billing', options: ['default' => false])] + #[Groups(['client_address:read', 'client_address:write'])] + private bool $isBilling = false; + + #[ORM\Column(length: 80, options: ['default' => 'France'])] + #[Groups(['client_address:read', 'client_address:write'])] + private string $country = 'France'; + + // RG-1.09 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur). + #[ORM\Column(length: 20)] + #[Assert\NotBlank] + #[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')] + #[Groups(['client_address:read', 'client_address:write'])] + private ?string $postalCode = null; + + #[ORM\Column(length: 120)] + #[Assert\NotBlank] + #[Groups(['client_address:read', 'client_address:write'])] + private ?string $city = null; + + #[ORM\Column(length: 255)] + #[Assert\NotBlank] + #[Groups(['client_address:read', 'client_address:write'])] + private ?string $street = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['client_address:read', 'client_address:write'])] + private ?string $streetComplement = null; + + // RG-1.11 : obligatoire ssi isBilling (CHECK BDD + futur Processor). + #[ORM\Column(length: 180, nullable: true)] + #[Assert\Email] + #[Groups(['client_address:read', 'client_address:write'])] + private ?string $billingEmail = null; + + #[ORM\Column(options: ['default' => 0])] + #[Groups(['client_address:read', 'client_address:write'])] + private int $position = 0; + + // RG-1.10 : au moins un site rattache a chaque adresse. + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: SiteInterface::class)] + #[ORM\JoinTable(name: 'client_address_site')] + #[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] + #[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')] + #[Groups(['client_address:read', 'client_address:write'])] + private Collection $sites; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: ClientContact::class)] + #[ORM\JoinTable(name: 'client_address_contact')] + #[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[ORM\InverseJoinColumn(name: 'client_contact_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[Groups(['client_address:read', 'client_address:write'])] + private Collection $contacts; + + // RG-1.29 : categories de type SECTEUR/AUTRE uniquement (filtre au Processor). + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: CategoryInterface::class)] + #[ORM\JoinTable(name: 'client_address_category')] + #[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] + #[Groups(['client_address:read', 'client_address:write'])] + private Collection $categories; + + public function __construct() + { + $this->sites = new ArrayCollection(); + $this->contacts = new ArrayCollection(); + $this->categories = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getClient(): ?Client + { + return $this->client; + } + + public function setClient(?Client $client): static + { + $this->client = $client; + + return $this; + } + + public function isProspect(): bool + { + return $this->isProspect; + } + + public function setIsProspect(bool $isProspect): static + { + $this->isProspect = $isProspect; + + return $this; + } + + public function isDelivery(): bool + { + return $this->isDelivery; + } + + public function setIsDelivery(bool $isDelivery): static + { + $this->isDelivery = $isDelivery; + + return $this; + } + + public function isBilling(): bool + { + return $this->isBilling; + } + + public function setIsBilling(bool $isBilling): static + { + $this->isBilling = $isBilling; + + return $this; + } + + public function getCountry(): string + { + return $this->country; + } + + public function setCountry(string $country): static + { + $this->country = $country; + + return $this; + } + + public function getPostalCode(): ?string + { + return $this->postalCode; + } + + public function setPostalCode(?string $postalCode): static + { + $this->postalCode = $postalCode; + + return $this; + } + + public function getCity(): ?string + { + return $this->city; + } + + public function setCity(?string $city): static + { + $this->city = $city; + + return $this; + } + + public function getStreet(): ?string + { + return $this->street; + } + + public function setStreet(?string $street): static + { + $this->street = $street; + + return $this; + } + + public function getStreetComplement(): ?string + { + return $this->streetComplement; + } + + public function setStreetComplement(?string $streetComplement): static + { + $this->streetComplement = $streetComplement; + + return $this; + } + + public function getBillingEmail(): ?string + { + return $this->billingEmail; + } + + public function setBillingEmail(?string $billingEmail): static + { + $this->billingEmail = $billingEmail; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } + + /** @return Collection */ + public function getSites(): Collection + { + return $this->sites; + } + + public function addSite(SiteInterface $site): static + { + if (!$this->sites->contains($site)) { + $this->sites->add($site); + } + + return $this; + } + + public function removeSite(SiteInterface $site): static + { + $this->sites->removeElement($site); + + return $this; + } + + /** @return Collection */ + public function getContacts(): Collection + { + return $this->contacts; + } + + public function addContact(ClientContact $contact): static + { + if (!$this->contacts->contains($contact)) { + $this->contacts->add($contact); + } + + return $this; + } + + public function removeContact(ClientContact $contact): static + { + $this->contacts->removeElement($contact); + + return $this; + } + + /** @return Collection */ + public function getCategories(): Collection + { + return $this->categories; + } + + public function addCategory(CategoryInterface $category): static + { + if (!$this->categories->contains($category)) { + $this->categories->add($category); + } + + return $this; + } + + public function removeCategory(CategoryInterface $category): static + { + $this->categories->removeElement($category); + + return $this; + } +} diff --git a/src/Module/Commercial/Domain/Entity/ClientContact.php b/src/Module/Commercial/Domain/Entity/ClientContact.php new file mode 100644 index 0000000..1b04ef8 --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/ClientContact.php @@ -0,0 +1,178 @@ + 0])] + #[Groups(['client_contact:read', 'client_contact:write'])] + private int $position = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getClient(): ?Client + { + return $this->client; + } + + public function setClient(?Client $client): static + { + $this->client = $client; + + return $this; + } + + public function getFirstName(): ?string + { + return $this->firstName; + } + + public function setFirstName(?string $firstName): static + { + $this->firstName = $firstName; + + return $this; + } + + public function getLastName(): ?string + { + return $this->lastName; + } + + public function setLastName(?string $lastName): static + { + $this->lastName = $lastName; + + return $this; + } + + public function getJobTitle(): ?string + { + return $this->jobTitle; + } + + public function setJobTitle(?string $jobTitle): static + { + $this->jobTitle = $jobTitle; + + return $this; + } + + public function getPhonePrimary(): ?string + { + return $this->phonePrimary; + } + + public function setPhonePrimary(?string $phonePrimary): static + { + $this->phonePrimary = $phonePrimary; + + return $this; + } + + public function getPhoneSecondary(): ?string + { + return $this->phoneSecondary; + } + + public function setPhoneSecondary(?string $phoneSecondary): static + { + $this->phoneSecondary = $phoneSecondary; + + return $this; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(?string $email): static + { + $this->email = $email; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } +} diff --git a/src/Module/Commercial/Domain/Entity/ClientRib.php b/src/Module/Commercial/Domain/Entity/ClientRib.php new file mode 100644 index 0000000..f1c589d --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/ClientRib.php @@ -0,0 +1,134 @@ + 0])] + #[Groups(['client_rib:read', 'client_rib:write'])] + private int $position = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getClient(): ?Client + { + return $this->client; + } + + public function setClient(?Client $client): static + { + $this->client = $client; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): static + { + $this->label = $label; + + return $this; + } + + public function getBic(): ?string + { + return $this->bic; + } + + public function setBic(string $bic): static + { + $this->bic = $bic; + + return $this; + } + + public function getIban(): ?string + { + return $this->iban; + } + + public function setIban(string $iban): static + { + $this->iban = $iban; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } +} diff --git a/src/Module/Commercial/Domain/Entity/PaymentDelay.php b/src/Module/Commercial/Domain/Entity/PaymentDelay.php new file mode 100644 index 0000000..cdfbed0 --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/PaymentDelay.php @@ -0,0 +1,83 @@ + 0])] + #[Groups(['payment_delay:read'])] + private int $position = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): static + { + $this->code = $code; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): static + { + $this->label = $label; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } +} diff --git a/src/Module/Commercial/Domain/Entity/PaymentType.php b/src/Module/Commercial/Domain/Entity/PaymentType.php new file mode 100644 index 0000000..3930c42 --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/PaymentType.php @@ -0,0 +1,86 @@ + 0])] + #[Groups(['payment_type:read'])] + private int $position = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): static + { + $this->code = $code; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): static + { + $this->label = $label; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } +} diff --git a/src/Module/Commercial/Domain/Entity/TvaMode.php b/src/Module/Commercial/Domain/Entity/TvaMode.php new file mode 100644 index 0000000..5a366fb --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/TvaMode.php @@ -0,0 +1,88 @@ + 0])] + #[Groups(['tva_mode:read'])] + private int $position = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): static + { + $this->code = $code; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): static + { + $this->label = $label; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } +} diff --git a/src/Module/Commercial/Domain/Repository/BankRepositoryInterface.php b/src/Module/Commercial/Domain/Repository/BankRepositoryInterface.php new file mode 100644 index 0000000..3e07010 --- /dev/null +++ b/src/Module/Commercial/Domain/Repository/BankRepositoryInterface.php @@ -0,0 +1,19 @@ + + */ + public function findAllOrdered(): array; +} diff --git a/src/Module/Commercial/Domain/Repository/ClientAddressRepositoryInterface.php b/src/Module/Commercial/Domain/Repository/ClientAddressRepositoryInterface.php new file mode 100644 index 0000000..9e070b3 --- /dev/null +++ b/src/Module/Commercial/Domain/Repository/ClientAddressRepositoryInterface.php @@ -0,0 +1,14 @@ + + */ + public function findAllOrdered(): array; +} diff --git a/src/Module/Commercial/Domain/Repository/PaymentTypeRepositoryInterface.php b/src/Module/Commercial/Domain/Repository/PaymentTypeRepositoryInterface.php new file mode 100644 index 0000000..9c4b749 --- /dev/null +++ b/src/Module/Commercial/Domain/Repository/PaymentTypeRepositoryInterface.php @@ -0,0 +1,19 @@ + + */ + public function findAllOrdered(): array; +} diff --git a/src/Module/Commercial/Domain/Repository/TvaModeRepositoryInterface.php b/src/Module/Commercial/Domain/Repository/TvaModeRepositoryInterface.php new file mode 100644 index 0000000..80144a4 --- /dev/null +++ b/src/Module/Commercial/Domain/Repository/TvaModeRepositoryInterface.php @@ -0,0 +1,20 @@ + + */ + public function findAllOrdered(): array; +} diff --git a/src/Module/Commercial/Infrastructure/DataFixtures/CommercialReferentialFixtures.php b/src/Module/Commercial/Infrastructure/DataFixtures/CommercialReferentialFixtures.php new file mode 100644 index 0000000..41dc6b4 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/DataFixtures/CommercialReferentialFixtures.php @@ -0,0 +1,94 @@ + referentiels et les tests + * RG-1.12/1.13. Le seed migration couvre la prod (ou les fixtures ne tournent + * pas) ; cette fixture re-aligne dev et test. Memes valeurs des deux cotes. + * + * Idempotence : lookup par `code` avant insertion (sur le modele de + * CategoryTypeFixtures). Rejouable sans doublon meme si le purger est desactive. + */ +class CommercialReferentialFixtures extends Fixture +{ + /** + * Source unique des referentiels : classe d'entite => [code => [label, position]]. + * Doit rester aligne sur le seed de la migration Version20260601000000. + * + * @var array> + */ + private const REFERENTIALS = [ + TvaMode::class => [ + 'FRANCE_VENTES' => ['France (ventes)', 10], + 'EXPORT_VENTES' => ['Export (ventes)', 20], + 'INTRACOM_VENTES' => ['Intracom (ventes)', 30], + ], + PaymentDelay::class => [ + 'J15' => ['15 jours', 10], + 'J30' => ['30 jours', 20], + 'A_RECEPTION' => ['À réception', 30], + ], + PaymentType::class => [ + 'VIREMENT' => ['Virement', 10], + 'LCR' => ['LCR', 20], + 'NON_SOUMISE' => ['Non soumise', 30], + 'CHEQUE' => ['Chèque', 40], + ], + Bank::class => [ + 'SG' => ['Société Générale', 10], + 'CIC' => ['CIC', 20], + 'CA' => ['Crédit Agricole', 30], + ], + ]; + + public function load(ObjectManager $manager): void + { + foreach (self::REFERENTIALS as $entityClass => $rows) { + $this->seedReferential($manager, $entityClass, $rows); + } + + $manager->flush(); + } + + /** + * Upsert idempotent d'un referentiel : indexe l'existant par code puis + * cree/met a jour chaque entree. Les 4 entites partagent le meme contrat + * setCode/setLabel/setPosition. + * + * @param class-string $entityClass + * @param array $rows + */ + private function seedReferential(ObjectManager $manager, string $entityClass, array $rows): void + { + $existingByCode = []; + foreach ($manager->getRepository($entityClass)->findAll() as $entity) { + $existingByCode[$entity->getCode()] = $entity; + } + + foreach ($rows as $code => [$label, $position]) { + $entity = $existingByCode[$code] ?? new $entityClass(); + $entity->setCode($code); + $entity->setLabel($label); + $entity->setPosition($position); + $manager->persist($entity); + } + } +} diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineBankRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineBankRepository.php new file mode 100644 index 0000000..2f10660 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineBankRepository.php @@ -0,0 +1,36 @@ + + */ +class DoctrineBankRepository extends ServiceEntityRepository implements BankRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Bank::class); + } + + public function findById(int $id): ?Bank + { + return $this->find($id); + } + + public function findAllOrdered(): array + { + return $this->createQueryBuilder('b') + ->orderBy('b.position', 'ASC') + ->addOrderBy('b.label', 'ASC') + ->getQuery() + ->getResult() + ; + } +} diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientAddressRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientAddressRepository.php new file mode 100644 index 0000000..76b6325 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientAddressRepository.php @@ -0,0 +1,32 @@ + + */ +class DoctrineClientAddressRepository extends ServiceEntityRepository implements ClientAddressRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ClientAddress::class); + } + + public function findById(int $id): ?ClientAddress + { + return $this->find($id); + } + + public function save(ClientAddress $address): void + { + $this->getEntityManager()->persist($address); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientContactRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientContactRepository.php new file mode 100644 index 0000000..417db34 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientContactRepository.php @@ -0,0 +1,32 @@ + + */ +class DoctrineClientContactRepository extends ServiceEntityRepository implements ClientContactRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ClientContact::class); + } + + public function findById(int $id): ?ClientContact + { + return $this->find($id); + } + + public function save(ClientContact $contact): void + { + $this->getEntityManager()->persist($contact); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php new file mode 100644 index 0000000..f10aff2 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php @@ -0,0 +1,47 @@ + + */ +class DoctrineClientRepository extends ServiceEntityRepository implements ClientRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Client::class); + } + + public function findById(int $id): ?Client + { + return $this->find($id); + } + + public function save(Client $client): void + { + $this->getEntityManager()->persist($client); + $this->getEntityManager()->flush(); + } + + public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder + { + $qb = $this->createQueryBuilder('c') + ->andWhere('c.deletedAt IS NULL') + ->orderBy('c.companyName', 'ASC') + ; + + if (!$includeArchived) { + $qb->andWhere('c.isArchived = false'); + } + + return $qb; + } +} diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRibRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRibRepository.php new file mode 100644 index 0000000..113827c --- /dev/null +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRibRepository.php @@ -0,0 +1,32 @@ + + */ +class DoctrineClientRibRepository extends ServiceEntityRepository implements ClientRibRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ClientRib::class); + } + + public function findById(int $id): ?ClientRib + { + return $this->find($id); + } + + public function save(ClientRib $rib): void + { + $this->getEntityManager()->persist($rib); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrinePaymentDelayRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrinePaymentDelayRepository.php new file mode 100644 index 0000000..529fbe9 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrinePaymentDelayRepository.php @@ -0,0 +1,36 @@ + + */ +class DoctrinePaymentDelayRepository extends ServiceEntityRepository implements PaymentDelayRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, PaymentDelay::class); + } + + public function findById(int $id): ?PaymentDelay + { + return $this->find($id); + } + + public function findAllOrdered(): array + { + return $this->createQueryBuilder('p') + ->orderBy('p.position', 'ASC') + ->addOrderBy('p.label', 'ASC') + ->getQuery() + ->getResult() + ; + } +} diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrinePaymentTypeRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrinePaymentTypeRepository.php new file mode 100644 index 0000000..41af0ba --- /dev/null +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrinePaymentTypeRepository.php @@ -0,0 +1,36 @@ + + */ +class DoctrinePaymentTypeRepository extends ServiceEntityRepository implements PaymentTypeRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, PaymentType::class); + } + + public function findById(int $id): ?PaymentType + { + return $this->find($id); + } + + public function findAllOrdered(): array + { + return $this->createQueryBuilder('p') + ->orderBy('p.position', 'ASC') + ->addOrderBy('p.label', 'ASC') + ->getQuery() + ->getResult() + ; + } +} diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineTvaModeRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineTvaModeRepository.php new file mode 100644 index 0000000..bf356b7 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineTvaModeRepository.php @@ -0,0 +1,36 @@ + + */ +class DoctrineTvaModeRepository extends ServiceEntityRepository implements TvaModeRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, TvaMode::class); + } + + public function findById(int $id): ?TvaMode + { + return $this->find($id); + } + + public function findAllOrdered(): array + { + return $this->createQueryBuilder('t') + ->orderBy('t.position', 'ASC') + ->addOrderBy('t.label', 'ASC') + ->getQuery() + ->getResult() + ; + } +} diff --git a/src/Shared/Domain/Contract/CategoryInterface.php b/src/Shared/Domain/Contract/CategoryInterface.php new file mode 100644 index 0000000..e345ac0 --- /dev/null +++ b/src/Shared/Domain/Contract/CategoryInterface.php @@ -0,0 +1,22 @@ + 'FK -> user.id, ON DELETE CASCADE — utilisateur ayant acces au site.', 'site_id' => 'FK -> site.id, ON DELETE CASCADE — site accessible par l utilisateur.', ], + + // === M1 Commercial (ERP-53/54) — miroir des COMMENT de la migration + // Version20260601000000 pour le chemin schema:update (dev/test). === + + 'tva_mode' => [ + '_table' => 'Referentiel des modes de TVA appliques a un client (France, Export, Intracom).', + 'id' => 'Identifiant interne auto-incremente.', + 'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.', + 'label' => 'Libelle affichable (FR, ≤ 120 caracteres).', + 'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).', + ], + + 'payment_delay' => [ + '_table' => 'Referentiel des delais de reglement (15 jours, 30 jours, a reception).', + 'id' => 'Identifiant interne auto-incremente.', + 'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.', + 'label' => 'Libelle affichable (FR, ≤ 120 caracteres).', + 'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).', + ], + + 'payment_type' => [ + '_table' => 'Referentiel des types de reglement (virement, LCR, cheque, non soumise).', + 'id' => 'Identifiant interne auto-incremente.', + 'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.', + 'label' => 'Libelle affichable (FR, ≤ 120 caracteres).', + 'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).', + ], + + 'bank' => [ + '_table' => 'Referentiel des banques selectionnables pour le reglement par virement.', + 'id' => 'Identifiant interne auto-incremente.', + 'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.', + 'label' => 'Libelle affichable (FR, ≤ 120 caracteres).', + 'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).', + ], + + 'client' => [ + '_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).', + 'id' => 'Identifiant interne auto-incremente.', + 'company_name' => 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).', + 'first_name' => 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).', + 'last_name' => 'Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).', + 'phone_primary' => 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.', + 'phone_secondary' => 'Telephone secondaire optionnel — chiffres uniquement (RG-1.20).', + 'email' => 'Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).', + 'distributor_id' => 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.', + 'broker_id' => 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.', + 'triage_service' => 'Drapeau service triage active pour le client. Faux par defaut.', + 'description' => 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.', + 'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).', + 'founded_at' => 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).', + 'employees_count' => 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).', + 'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).', + 'director_name' => 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).', + 'profit_amount' => 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).', + 'siren' => 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).', + 'account_number' => 'Onglet Comptabilite : numero de compte comptable du client.', + 'tva_mode_id' => 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.', + 'n_tva' => 'Onglet Comptabilite : numero de TVA intracommunautaire.', + 'payment_delay_id' => 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id, ON DELETE RESTRICT.', + 'payment_type_id' => 'Onglet Comptabilite : type de reglement — FK -> payment_type.id, ON DELETE RESTRICT. Code LCR impose >= 1 RIB (RG-1.13), VIREMENT impose une banque (RG-1.12).', + 'bank_id' => 'Onglet Comptabilite : banque — FK -> bank.id, ON DELETE RESTRICT. Obligatoire si payment_type = VIREMENT (RG-1.12).', + 'is_archived' => 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.clients.archive (RG-1.22/23).', + 'archived_at' => 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration (RG-1.22/23).', + 'deleted_at' => 'Horodatage du soft-delete technique (HP M2) — non expose par l API au M1. Null = ligne active.', + ] + self::timestampableBlamableComments(), + + 'client_category' => [ + '_table' => 'Jointure M2M client <-> category (Catalog) — categories metier du client (au moins une obligatoire).', + 'client_id' => 'FK -> client.id, ON DELETE CASCADE — client porteur de la categorie.', + 'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie rattachee au client.', + ], + + 'client_contact' => [ + '_table' => 'Contacts d un client (1:n) — au moins firstName OU lastName par contact (RG-1.05).', + 'id' => 'Identifiant interne auto-incremente.', + 'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire du contact.', + 'first_name' => 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).', + 'last_name' => 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).', + 'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).', + 'phone_primary' => 'Telephone principal du contact — chiffres uniquement (RG-1.20).', + 'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (RG-1.20).', + 'email' => 'Email du contact (lowercase serveur, RG-1.21).', + 'position' => 'Ordre d affichage du contact dans la liste du client (croissant).', + ] + self::timestampableBlamableComments(), + + 'client_address' => [ + '_table' => 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).', + 'id' => 'Identifiant interne auto-incremente.', + 'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.', + 'is_prospect' => 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.', + 'is_delivery' => 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.', + 'is_billing' => 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.', + 'country' => 'Pays de l adresse — defaut France.', + 'postal_code' => 'Code postal (4-5 chiffres attendus, RG-1.09).', + 'city' => 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).', + 'street' => 'Numero et voie de l adresse.', + 'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.', + 'billing_email' => 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).', + 'position' => 'Ordre d affichage de l adresse dans la liste du client (croissant).', + ] + self::timestampableBlamableComments(), + + 'client_address_site' => [ + '_table' => 'Jointure M2M client_address <-> site (Sites) — sites desservis par l adresse (>= 1 obligatoire, RG-1.10).', + 'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.', + 'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.', + ], + + 'client_address_contact' => [ + '_table' => 'Jointure M2M client_address <-> client_contact — contacts associes a une adresse.', + 'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.', + 'client_contact_id' => 'FK -> client_contact.id, ON DELETE CASCADE — contact associe a l adresse.', + ], + + 'client_address_category' => [ + '_table' => 'Jointure M2M client_address <-> category — categories d adresse (types SECTEUR/AUTRE uniquement, RG-1.29).', + 'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.', + 'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse (type SECTEUR ou AUTRE, RG-1.29).', + ], + + 'client_rib' => [ + '_table' => 'Coordonnees bancaires d un client (1:n) — >= 1 RIB obligatoire si payment_type = LCR (RG-1.13). Tous les champs audites (pas d AuditIgnore).', + 'id' => 'Identifiant interne auto-incremente.', + 'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire du RIB.', + 'label' => 'Libelle du RIB (ex: compte principal).', + 'bic' => 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).', + 'iban' => 'IBAN du compte (≤ 34 caracteres).', + 'position' => 'Ordre d affichage du RIB dans la liste du client (croissant).', + ] + self::timestampableBlamableComments(), ]; } @@ -151,12 +280,25 @@ final class ColumnCommentsCatalog * Construit la liste des requetes SQL `COMMENT ON TABLE/COLUMN` (en * dollar-quoting Postgres `$_$`) a partir du catalogue. * + * @param null|list $onlyTables Restreint la generation a ces tables + * (utile pour la migration retrofit qui + * ne doit commenter que les tables deja + * presentes a son instant T — les tables + * des modules crees plus tard posent + * leurs propres COMMENT). null = tout. + * * @return list */ - public static function toSqlStatements(): array + public static function toSqlStatements(?array $onlyTables = null): array { + $allowed = null === $onlyTables ? null : array_fill_keys($onlyTables, true); + $statements = []; foreach (self::comments() as $table => $entries) { + if (null !== $allowed && !isset($allowed[$table])) { + continue; + } + $quotedTable = self::quoteIdent($table); foreach ($entries as $column => $description) { if ('_table' === $column) { diff --git a/tests/Architecture/EntitiesAreTimestampableBlamableTest.php b/tests/Architecture/EntitiesAreTimestampableBlamableTest.php index 20c7e26..0b8fb17 100644 --- a/tests/Architecture/EntitiesAreTimestampableBlamableTest.php +++ b/tests/Architecture/EntitiesAreTimestampableBlamableTest.php @@ -5,6 +5,10 @@ declare(strict_types=1); namespace App\Tests\Architecture; use App\Module\Catalog\Domain\Entity\CategoryType; +use App\Module\Commercial\Domain\Entity\Bank; +use App\Module\Commercial\Domain\Entity\PaymentDelay; +use App\Module\Commercial\Domain\Entity\PaymentType; +use App\Module\Commercial\Domain\Entity\TvaMode; use App\Module\Core\Domain\Entity\Permission; use App\Module\Core\Domain\Entity\Role; use App\Module\Core\Domain\Entity\User; @@ -49,6 +53,11 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase * - CategoryType : referentiel statique (codes de typage des categories), * pas de besoin de tracabilite user-driven (cree par migration/seed, * pas pilote utilisateur au M0). Cf. spec-back § 2.8.bis + RG-1.17. + * - TvaMode / PaymentDelay / PaymentType / Bank (M1 Commercial) : referentiels + * comptables statiques (id/code/label/position), seedes par migration + + * CommercialReferentialFixtures, lecture seule au M1 (HP-M2-2). Pas de + * tracabilite user-driven, meme justification que CategoryType. Cf. + * spec-back M1 § 2.6 + § 3.5. * * Les futurs referentiels statiques s'ajoutent ici avec une justification. */ @@ -58,6 +67,10 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase Permission::class, Site::class, CategoryType::class, + TvaMode::class, + PaymentDelay::class, + PaymentType::class, + Bank::class, ]; public function testAllBusinessEntitiesImplementBothInterfaces(): void