From 56cf492dcc6111d75b47c39b852675c4fe7451d5 Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Mon, 1 Jun 2026 15:19:43 +0000 Subject: [PATCH] =?UTF-8?q?[ERP-53]=20Migrer=20les=20tables=20Client=20+?= =?UTF-8?q?=20sous-collections=20+=20r=C3=A9f=C3=A9rentiels=20comptables?= =?UTF-8?q?=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(); + } +}