From bc4b1d0492a609c8f5fbb6e81f67c50d7abc93af Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 29 May 2026 14:41:43 +0200 Subject: [PATCH 1/6] =?UTF-8?q?docs(commercial)=20:=20migration=20racine?= =?UTF-8?q?=20+=20seed=20fixture=20CategoryType=20(blocages=20ERP-53=20v?= =?UTF-8?q?=C3=A9rifi=C3=A9s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/specs/M1-clients/spec-back.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) 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) -- 2.39.5 From 8d0a9a67ef24140673c4fa61456f8484fc831a11 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 29 May 2026 14:48:40 +0200 Subject: [PATCH 2/6] feat(commercial) : migrate M1 client tables + accounting referentials + extend category_type seed --- migrations/Version20260601000000.php | 538 ++++++++++++++++++ .../DataFixtures/CategoryTypeFixtures.php | 63 ++ 2 files changed, 601 insertions(+) create mode 100644 migrations/Version20260601000000.php create mode 100644 src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php diff --git a/migrations/Version20260601000000.php b/migrations/Version20260601000000.php new file mode 100644 index 0000000..af26227 --- /dev/null +++ b/migrations/Version20260601000000.php @@ -0,0 +1,538 @@ +createAccountingReferentials(); + $this->createClientTable(); + $this->createClientCategory(); + $this->createClientContact(); + $this->createClientAddress(); + $this->createClientAddressJoinTables(); + $this->createClientRib(); + $this->seedCategoryTypes(); + } + + public function down(Schema $schema): void + { + // Ordre inverse des dependances FK : on supprime d'abord les jointures + // et sous-collections, puis client, puis les referentiels. + $this->addSql('DROP TABLE client_address_category'); + $this->addSql('DROP TABLE client_address_contact'); + $this->addSql('DROP TABLE client_address_site'); + $this->addSql('DROP TABLE client_rib'); + $this->addSql('DROP TABLE client_address'); + $this->addSql('DROP TABLE client_contact'); + $this->addSql('DROP TABLE client_category'); + $this->addSql('DROP TABLE client'); + $this->addSql('DROP TABLE bank'); + $this->addSql('DROP TABLE payment_type'); + $this->addSql('DROP TABLE payment_delay'); + $this->addSql('DROP TABLE tva_mode'); + + // Retire uniquement les 4 types seedes par cette migration. Les autres + // types eventuels (CRUD futur) sont preserves. + $this->addSql(<<<'SQL' + DELETE FROM category_type WHERE code IN ('DISTRIBUTEUR', 'COURTIER', 'SECTEUR', 'AUTRE') + SQL); + } + + // ================================================================= + // Referentiels comptables (4 tables statiques, memes colonnes) + // ================================================================= + + private function createAccountingReferentials(): void + { + $referentials = [ + 'tva_mode' => 'Referentiel des modes de TVA appliques a un client (France, Export, Intracom).', + 'payment_delay' => 'Referentiel des delais de reglement (15 jours, 30 jours, a reception).', + 'payment_type' => 'Referentiel des types de reglement (virement, LCR, cheque, non soumise).', + 'bank' => 'Referentiel des banques selectionnables pour le reglement par virement.', + ]; + + foreach ($referentials as $table => $tableComment) { + $this->addSql(sprintf(<<<'SQL' + CREATE TABLE %s ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + code VARCHAR(30) NOT NULL, + label VARCHAR(120) NOT NULL, + position INT DEFAULT 0 NOT NULL, + PRIMARY KEY (id) + ) + SQL, $table)); + $this->addSql(sprintf('CREATE UNIQUE INDEX uq_%s_code ON %s (code)', $table, $table)); + + $this->comment($table, '_table', $tableComment); + $this->comment($table, 'id', 'Identifiant interne auto-incremente.'); + $this->comment($table, 'code', 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.'); + $this->comment($table, 'label', 'Libelle affichable (FR, ≤ 120 caracteres).'); + $this->comment($table, 'position', 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).'); + } + + // Seed initial (cf. spec § 3.2). Tables fraichement creees donc vides : + // INSERT direct sans ON CONFLICT. + $this->addSql(<<<'SQL' + INSERT INTO tva_mode (code, label, position) VALUES + ('FRANCE_VENTES', 'France (ventes)', 10), + ('EXPORT_VENTES', 'Export (ventes)', 20), + ('INTRACOM_VENTES', 'Intracom (ventes)', 30) + SQL); + $this->addSql(<<<'SQL' + INSERT INTO payment_delay (code, label, position) VALUES + ('J15', '15 jours', 10), + ('J30', '30 jours', 20), + ('A_RECEPTION', 'À réception', 30) + SQL); + $this->addSql(<<<'SQL' + INSERT INTO payment_type (code, label, position) VALUES + ('VIREMENT', 'Virement', 10), + ('LCR', 'LCR', 20), + ('NON_SOUMISE', 'Non soumise', 30), + ('CHEQUE', 'Chèque', 40) + SQL); + $this->addSql(<<<'SQL' + INSERT INTO bank (code, label, position) VALUES + ('SG', 'Société Générale', 10), + ('CIC', 'CIC', 20), + ('CA', 'Crédit Agricole', 30) + SQL); + } + + // ================================================================= + // Table principale `client` + // ================================================================= + + private function createClientTable(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE client ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + company_name VARCHAR(180) NOT NULL, + first_name VARCHAR(120) DEFAULT NULL, + last_name VARCHAR(120) DEFAULT NULL, + phone_primary VARCHAR(20) NOT NULL, + phone_secondary VARCHAR(20) DEFAULT NULL, + email VARCHAR(180) NOT NULL, + distributor_id INT DEFAULT NULL, + broker_id INT DEFAULT NULL, + triage_service BOOLEAN DEFAULT FALSE NOT NULL, + description TEXT DEFAULT NULL, + competitors VARCHAR(255) DEFAULT NULL, + founded_at DATE DEFAULT NULL, + employees_count INT DEFAULT NULL, + revenue_amount NUMERIC(15, 2) DEFAULT NULL, + director_name VARCHAR(120) DEFAULT NULL, + profit_amount NUMERIC(15, 2) DEFAULT NULL, + siren VARCHAR(20) DEFAULT NULL, + account_number VARCHAR(40) DEFAULT NULL, + tva_mode_id INT DEFAULT NULL, + n_tva VARCHAR(40) DEFAULT NULL, + payment_delay_id INT DEFAULT NULL, + payment_type_id INT DEFAULT NULL, + bank_id INT DEFAULT NULL, + is_archived BOOLEAN DEFAULT FALSE NOT NULL, + archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT DEFAULT NULL, + updated_by INT DEFAULT NULL, + PRIMARY KEY (id), + CONSTRAINT chk_client_distrib_or_broker + CHECK (NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL)), + CONSTRAINT fk_client_distributor + FOREIGN KEY (distributor_id) REFERENCES client (id) ON DELETE SET NULL, + CONSTRAINT fk_client_broker + FOREIGN KEY (broker_id) REFERENCES client (id) ON DELETE SET NULL, + CONSTRAINT fk_client_tva_mode + FOREIGN KEY (tva_mode_id) REFERENCES tva_mode (id) ON DELETE RESTRICT, + CONSTRAINT fk_client_payment_delay + FOREIGN KEY (payment_delay_id) REFERENCES payment_delay (id) ON DELETE RESTRICT, + CONSTRAINT fk_client_payment_type + FOREIGN KEY (payment_type_id) REFERENCES payment_type (id) ON DELETE RESTRICT, + CONSTRAINT fk_client_bank + FOREIGN KEY (bank_id) REFERENCES bank (id) ON DELETE RESTRICT, + CONSTRAINT fk_client_created_by + FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, + CONSTRAINT fk_client_updated_by + FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL + ) + SQL); + + $this->addSql('CREATE INDEX idx_client_is_archived ON client (is_archived)'); + $this->addSql('CREATE INDEX idx_client_deleted_at ON client (deleted_at)'); + $this->addSql('CREATE INDEX idx_client_distributor_id ON client (distributor_id)'); + $this->addSql('CREATE INDEX idx_client_broker_id ON client (broker_id)'); + $this->addSql('CREATE INDEX idx_client_created_by ON client (created_by)'); + $this->addSql('CREATE INDEX idx_client_updated_by ON client (updated_by)'); + + // Unicite metier partielle (Q4) : nom de societe insensible a la casse, + // parmi les non-archives ET non soft-deletes uniquement. Pas d'index + // unique sur siren ni email. + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX uq_client_company_name_active + ON client (LOWER(company_name)) + WHERE is_archived = FALSE AND deleted_at IS NULL + SQL); + + $this->comment('client', '_table', 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).'); + $this->comment('client', 'id', 'Identifiant interne auto-incremente.'); + $this->comment('client', 'company_name', 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).'); + $this->comment('client', 'first_name', 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).'); + $this->comment('client', 'last_name', 'Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).'); + $this->comment('client', 'phone_primary', 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.'); + $this->comment('client', 'phone_secondary', 'Telephone secondaire optionnel — chiffres uniquement (RG-1.20).'); + $this->comment('client', 'email', 'Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).'); + $this->comment('client', 'distributor_id', 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.'); + $this->comment('client', 'broker_id', 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.'); + $this->comment('client', 'triage_service', 'Drapeau service triage active pour le client. Faux par defaut.'); + $this->comment('client', 'description', 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.'); + $this->comment('client', 'competitors', 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).'); + $this->comment('client', 'founded_at', 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).'); + $this->comment('client', 'employees_count', 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).'); + $this->comment('client', 'revenue_amount', 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).'); + $this->comment('client', 'director_name', 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).'); + $this->comment('client', 'profit_amount', 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).'); + $this->comment('client', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).'); + $this->comment('client', 'account_number', 'Onglet Comptabilite : numero de compte comptable du client.'); + $this->comment('client', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.'); + $this->comment('client', 'n_tva', 'Onglet Comptabilite : numero de TVA intracommunautaire.'); + $this->comment('client', 'payment_delay_id', 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id, ON DELETE RESTRICT.'); + $this->comment('client', 'payment_type_id', 'Onglet Comptabilite : type de reglement — FK -> payment_type.id, ON DELETE RESTRICT. Code LCR impose >= 1 RIB (RG-1.13), VIREMENT impose une banque (RG-1.12).'); + $this->comment('client', 'bank_id', 'Onglet Comptabilite : banque — FK -> bank.id, ON DELETE RESTRICT. Obligatoire si payment_type = VIREMENT (RG-1.12).'); + $this->comment('client', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.clients.archive (RG-1.22/23).'); + $this->comment('client', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration (RG-1.22/23).'); + $this->comment('client', 'deleted_at', 'Horodatage du soft-delete technique (HP M2) — non expose par l API au M1. Null = ligne active.'); + $this->addTimestampableBlamableComments('client'); + } + + // ================================================================= + // M2M client <-> category + // ================================================================= + + private function createClientCategory(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE client_category ( + client_id INT NOT NULL, + category_id INT NOT NULL, + PRIMARY KEY (client_id, category_id), + CONSTRAINT fk_client_category_client + FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE, + CONSTRAINT fk_client_category_category + FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT + ) + SQL); + $this->addSql('CREATE INDEX idx_client_category_category ON client_category (category_id)'); + + $this->comment('client_category', '_table', 'Jointure M2M client <-> category (Catalog) — categories metier du client (au moins une obligatoire).'); + $this->comment('client_category', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client porteur de la categorie.'); + $this->comment('client_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie rattachee au client.'); + } + + // ================================================================= + // Sous-collection : contacts (1:n) + // ================================================================= + + private function createClientContact(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE client_contact ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + client_id INT NOT NULL, + first_name VARCHAR(120) DEFAULT NULL, + last_name VARCHAR(120) DEFAULT NULL, + job_title VARCHAR(120) DEFAULT NULL, + phone_primary VARCHAR(20) DEFAULT NULL, + phone_secondary VARCHAR(20) DEFAULT NULL, + email VARCHAR(180) DEFAULT NULL, + position INT DEFAULT 0 NOT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT DEFAULT NULL, + updated_by INT DEFAULT NULL, + PRIMARY KEY (id), + CONSTRAINT chk_client_contact_name + CHECK (first_name IS NOT NULL OR last_name IS NOT NULL), + CONSTRAINT fk_client_contact_client + FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE, + CONSTRAINT fk_client_contact_created_by + FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, + CONSTRAINT fk_client_contact_updated_by + FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL + ) + SQL); + $this->addSql('CREATE INDEX idx_client_contact_client ON client_contact (client_id)'); + + $this->comment('client_contact', '_table', 'Contacts d un client (1:n) — au moins firstName OU lastName par contact (RG-1.05).'); + $this->comment('client_contact', 'id', 'Identifiant interne auto-incremente.'); + $this->comment('client_contact', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire du contact.'); + $this->comment('client_contact', 'first_name', 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).'); + $this->comment('client_contact', 'last_name', 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).'); + $this->comment('client_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).'); + $this->comment('client_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (RG-1.20).'); + $this->comment('client_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (RG-1.20).'); + $this->comment('client_contact', 'email', 'Email du contact (lowercase serveur, RG-1.21).'); + $this->comment('client_contact', 'position', 'Ordre d affichage du contact dans la liste du client (croissant).'); + $this->addTimestampableBlamableComments('client_contact'); + } + + // ================================================================= + // Sous-collection : adresses (1:n) + // ================================================================= + + private function createClientAddress(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE client_address ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + client_id INT NOT NULL, + is_prospect BOOLEAN DEFAULT FALSE NOT NULL, + is_delivery BOOLEAN DEFAULT FALSE NOT NULL, + is_billing BOOLEAN DEFAULT FALSE NOT NULL, + country VARCHAR(80) DEFAULT 'France' NOT NULL, + postal_code VARCHAR(20) NOT NULL, + city VARCHAR(120) NOT NULL, + street VARCHAR(255) NOT NULL, + street_complement VARCHAR(255) DEFAULT NULL, + billing_email VARCHAR(180) DEFAULT NULL, + position INT DEFAULT 0 NOT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT DEFAULT NULL, + updated_by INT DEFAULT NULL, + PRIMARY KEY (id), + CONSTRAINT chk_client_address_prospect_exclusive + CHECK (NOT (is_prospect = TRUE AND (is_delivery = TRUE OR is_billing = TRUE))), + CONSTRAINT chk_client_address_billing_email + CHECK ((is_billing = FALSE AND billing_email IS NULL) + OR (is_billing = TRUE AND billing_email IS NOT NULL)), + CONSTRAINT fk_client_address_client + FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE, + CONSTRAINT fk_client_address_created_by + FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, + CONSTRAINT fk_client_address_updated_by + FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL + ) + SQL); + $this->addSql('CREATE INDEX idx_client_address_client ON client_address (client_id)'); + + $this->comment('client_address', '_table', 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).'); + $this->comment('client_address', 'id', 'Identifiant interne auto-incremente.'); + $this->comment('client_address', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.'); + $this->comment('client_address', 'is_prospect', 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.'); + $this->comment('client_address', 'is_delivery', 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.'); + $this->comment('client_address', 'is_billing', 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.'); + $this->comment('client_address', 'country', 'Pays de l adresse — defaut France.'); + $this->comment('client_address', 'postal_code', 'Code postal (4-5 chiffres attendus, RG-1.09).'); + $this->comment('client_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).'); + $this->comment('client_address', 'street', 'Numero et voie de l adresse.'); + $this->comment('client_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.'); + $this->comment('client_address', 'billing_email', 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).'); + $this->comment('client_address', 'position', 'Ordre d affichage de l adresse dans la liste du client (croissant).'); + $this->addTimestampableBlamableComments('client_address'); + } + + // ================================================================= + // Jointures de client_address (M2M) + // ================================================================= + + private function createClientAddressJoinTables(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE client_address_site ( + client_address_id INT NOT NULL, + site_id INT NOT NULL, + PRIMARY KEY (client_address_id, site_id), + CONSTRAINT fk_client_address_site_address + FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE, + CONSTRAINT fk_client_address_site_site + FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT + ) + SQL); + $this->comment('client_address_site', '_table', 'Jointure M2M client_address <-> site (Sites) — sites desservis par l adresse (>= 1 obligatoire, RG-1.10).'); + $this->comment('client_address_site', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.'); + $this->comment('client_address_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.'); + + $this->addSql(<<<'SQL' + CREATE TABLE client_address_contact ( + client_address_id INT NOT NULL, + client_contact_id INT NOT NULL, + PRIMARY KEY (client_address_id, client_contact_id), + CONSTRAINT fk_client_address_contact_address + FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE, + CONSTRAINT fk_client_address_contact_contact + FOREIGN KEY (client_contact_id) REFERENCES client_contact (id) ON DELETE CASCADE + ) + SQL); + $this->comment('client_address_contact', '_table', 'Jointure M2M client_address <-> client_contact — contacts associes a une adresse.'); + $this->comment('client_address_contact', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.'); + $this->comment('client_address_contact', 'client_contact_id', 'FK -> client_contact.id, ON DELETE CASCADE — contact associe a l adresse.'); + + $this->addSql(<<<'SQL' + CREATE TABLE client_address_category ( + client_address_id INT NOT NULL, + category_id INT NOT NULL, + PRIMARY KEY (client_address_id, category_id), + CONSTRAINT fk_client_address_category_address + FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE, + CONSTRAINT fk_client_address_category_category + FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT + ) + SQL); + $this->comment('client_address_category', '_table', 'Jointure M2M client_address <-> category — categories d adresse (types SECTEUR/AUTRE uniquement, RG-1.29).'); + $this->comment('client_address_category', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.'); + $this->comment('client_address_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse (type SECTEUR ou AUTRE, RG-1.29).'); + } + + // ================================================================= + // Sous-collection : RIB (1:n) + // ================================================================= + + private function createClientRib(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE client_rib ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + client_id INT NOT NULL, + label VARCHAR(120) NOT NULL, + bic VARCHAR(20) NOT NULL, + iban VARCHAR(34) NOT NULL, + position INT DEFAULT 0 NOT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT DEFAULT NULL, + updated_by INT DEFAULT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_client_rib_client + FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE, + CONSTRAINT fk_client_rib_created_by + FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, + CONSTRAINT fk_client_rib_updated_by + FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL + ) + SQL); + $this->addSql('CREATE INDEX idx_client_rib_client ON client_rib (client_id)'); + + $this->comment('client_rib', '_table', 'Coordonnees bancaires d un client (1:n) — >= 1 RIB obligatoire si payment_type = LCR (RG-1.13). Tous les champs audites (pas d AuditIgnore).'); + $this->comment('client_rib', 'id', 'Identifiant interne auto-incremente.'); + $this->comment('client_rib', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire du RIB.'); + $this->comment('client_rib', 'label', 'Libelle du RIB (ex: compte principal).'); + $this->comment('client_rib', 'bic', 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).'); + $this->comment('client_rib', 'iban', 'IBAN du compte (≤ 34 caracteres).'); + $this->comment('client_rib', 'position', 'Ordre d affichage du RIB dans la liste du client (croissant).'); + $this->addTimestampableBlamableComments('client_rib'); + } + + // ================================================================= + // Seed extension category_type (M0) + // ================================================================= + + private function seedCategoryTypes(): void + { + // Idempotent : la table category_type peut deja porter des donnees en + // prod. ON CONFLICT (code) s appuie sur l index unique uq_category_type_code. + // NB : la table M0 n a pas de colonne `position` (id/code/label seulement), + // contrairement au pseudo-SQL de la spec § 3.3. + $this->addSql(<<<'SQL' + INSERT INTO category_type (code, label) VALUES + ('DISTRIBUTEUR', 'Distributeur'), + ('COURTIER', 'Courtier'), + ('SECTEUR', 'Secteur'), + ('AUTRE', 'Autre') + ON CONFLICT (code) DO NOTHING + SQL); + } + + // ================================================================= + // Helpers + // ================================================================= + + /** + * Pose les 4 commentaires standardises Timestampable/Blamable sur une table, + * en reutilisant le catalogue partage (source unique, cf. ERP-67). + */ + private function addTimestampableBlamableComments(string $table): void + { + foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) { + $this->comment($table, $column, $description); + } + } + + /** + * Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou + * `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour eviter + * tout echappement d apostrophe. + */ + private function comment(string $table, string $column, string $description): void + { + $quotedTable = '"'.str_replace('"', '""', $table).'"'; + + if ('_table' === $column) { + $this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description)); + + return; + } + + $this->addSql(sprintf( + 'COMMENT ON COLUMN %s.%s IS $_$%s$_$', + $quotedTable, + '"'.str_replace('"', '""', $column).'"', + $description, + )); + } +} 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(); + } +} -- 2.39.5 From 034301ceaf3844085a55fc64fb779a256aee016b Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 1 Jun 2026 12:08:17 +0200 Subject: [PATCH 3/6] fix(commercial) : down() orphan-only + index FK referentiels (review ERP-53) --- migrations/Version20260601000000.php | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/migrations/Version20260601000000.php b/migrations/Version20260601000000.php index af26227..75a0934 100644 --- a/migrations/Version20260601000000.php +++ b/migrations/Version20260601000000.php @@ -84,10 +84,18 @@ final class Version20260601000000 extends AbstractMigration $this->addSql('DROP TABLE payment_delay'); $this->addSql('DROP TABLE tva_mode'); - // Retire uniquement les 4 types seedes par cette migration. Les autres - // types eventuels (CRUD futur) sont preserves. + // 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') + 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); } @@ -220,6 +228,14 @@ final class Version20260601000000 extends AbstractMigration $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. -- 2.39.5 From a9998d4bcd1b73457270fad4c794314b879a2a7b Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 29 May 2026 15:36:14 +0200 Subject: [PATCH 4/6] feat(commercial) : add M1 client entities + accounting referentials + repositories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entites metier (Client, ClientContact, ClientAddress, ClientRib) avec #[Auditable] + Timestampable/Blamable, et 4 referentiels comptables statiques (TvaMode, PaymentDelay, PaymentType, Bank). 8 repositories interfaces + impl Doctrine. Aucun ApiResource (Provider/Processor = ERP-55). - Client : 2 FK auto-referentes distributor/broker (mutuellement exclusives, CHECK en base), M2M categories, FK referentiels comptables, groupes de serialisation par onglet. Pas de #[ORM\UniqueConstraint] : unicite du nom de societe portee par l'index partiel Postgres (decision Q4). - ClientRib : tous les champs audites, aucun #[AuditIgnore] sur iban/bic (decision 29/05, audit admin-only). - M2M Category via le contrat Shared CategoryInterface + resolve_target_entities (regle n°1, pas d'import inter-modules) ; sites via SiteInterface. - CommercialReferentialFixtures : re-seed idempotent des 4 referentiels (sinon vides apres db-reset car desormais tables mappees, purgees par les fixtures). - Referentiels whitelistes dans EntitiesAreTimestampableBlamableTest::EXCLUDED. - doctrine.yaml : mapping ORM du module Commercial + resolve CategoryInterface. - ColumnCommentsCatalog : ajout des colonnes M1 (chemin schema:update/test) ; migration retrofit Version20260528120000 filtree sur les tables existantes pour ne pas casser sur les tables des modules crees plus tard. - makefile test-db-setup : recreation de l'index partiel uq_client_company_name_active. Refs ERP-54. --- 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 -- 2.39.5 From d3d00425f7a9f1270310c43617aa8c52fcacebd2 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 29 May 2026 16:41:40 +0200 Subject: [PATCH 5/6] feat(commercial) : add Client API Platform provider + processor + business rules Branche l'API REST du repertoire clients (M1) sur l'entite Client preparee en ERP-54. Operations GetCollection / Get / Post / Patch (pas de Delete au M1 : l'archivage passe par PATCH isArchived). ClientProvider : - liste paginee (Paginator ORM, aligne sur la convention ERP-72) + echappatoire ?pagination=false - exclut archives + soft-deletes par defaut (RG-1.24), ?includeArchived=true reintegre les archives (RG-1.25) - tri companyName ASC (RG-1.26), filtres ?search (fuzzy companyName/lastName/ email) et ?categoryType= - detail : 404 sur soft-delete, embarque contacts/adresses/ribs ClientProcessor : - normalisation serveur via ClientFieldNormalizer (RG-1.18 a 1.21) - 409 sur doublon de nom de societe (RG-1.16) ; 409 dedie sur conflit de restauration (RG-1.23) - gating par onglet : champ comptable -> accounting.manage, isArchived -> archive, mode strict 403 sur tout le payload (RG-1.28) ; archivage exclusif (RG-1.22) + pose/retrait archivedAt - regles metier RG-1.01 (prenom/nom), RG-1.03 (distributor/broker exclusifs + controle du type de categorie), RG-1.12 (Virement -> banque), RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information pour le role Commerciale) Lecture comptable conditionnelle : ClientReadGroupContextBuilder ajoute le groupe client:read:accounting selon commercial.clients.accounting.view. Resolution des references categorie : CategoryReferenceDenormalizer resout les IRI vers Category quand la propriete est type-hintee par le contrat CategoryInterface (denormalisation impossible sur une interface sinon). Contrats Shared : - CategoryInterface::getCategoryTypeCode() (implemente par Category) pour la verification de type sans import inter-modules - BusinessRoleAwareInterface (implemente par User) + BusinessRoles::COMMERCIALE pour detecter le role metier ; le code de role sera seede par ERP-74 et reutilise par ERP-59/60. RG-1.04 reste dormante tant qu'aucun user ne porte ce role. Coordination stack : - chaines de permission commercial.clients.* referencees ici, declarees en ERP-59 (tests RBAC complets en ERP-60) - config globale de pagination (itemsPerPage client, max 50) portee par ERP-72 - referentiels comptables (PaymentType/Bank/...) exposes en ERP-56 Tests : 31 tests Commercial (integration admin sur les regles metier + unitaires sur le gating, RG-1.04/1.12/1.13 et le context builder). Suite complete verte (339 tests). --- src/Module/Catalog/Domain/Entity/Category.php | 10 + .../Service/ClientFieldNormalizer.php | 80 ++++ ...ClientInformationCompletenessValidator.php | 76 ++++ .../Commercial/Domain/Entity/Client.php | 79 +++- .../CategoryReferenceDenormalizer.php | 62 +++ .../ClientReadGroupContextBuilder.php | 65 ++++ .../State/Processor/ClientProcessor.php | 362 ++++++++++++++++++ .../State/Provider/ClientProvider.php | 170 ++++++++ src/Module/Core/Domain/Entity/User.php | 20 +- .../Contract/BusinessRoleAwareInterface.php | 27 ++ .../Domain/Contract/CategoryInterface.php | 10 + src/Shared/Domain/Security/BusinessRoles.php | 42 ++ .../Api/AbstractCommercialApiTestCase.php | 130 +++++++ tests/Module/Commercial/Api/ClientApiTest.php | 285 ++++++++++++++ .../Unit/ClientFieldNormalizerTest.php | 56 +++ .../Commercial/Unit/ClientProcessorTest.php | 253 ++++++++++++ .../ClientReadGroupContextBuilderTest.php | 85 ++++ 17 files changed, 1808 insertions(+), 4 deletions(-) create mode 100644 src/Module/Commercial/Application/Service/ClientFieldNormalizer.php create mode 100644 src/Module/Commercial/Application/Validator/ClientInformationCompletenessValidator.php create mode 100644 src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/CategoryReferenceDenormalizer.php create mode 100644 src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/ClientReadGroupContextBuilder.php create mode 100644 src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php create mode 100644 src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php create mode 100644 src/Shared/Domain/Contract/BusinessRoleAwareInterface.php create mode 100644 src/Shared/Domain/Security/BusinessRoles.php create mode 100644 tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php create mode 100644 tests/Module/Commercial/Api/ClientApiTest.php create mode 100644 tests/Module/Commercial/Unit/ClientFieldNormalizerTest.php create mode 100644 tests/Module/Commercial/Unit/ClientProcessorTest.php create mode 100644 tests/Module/Commercial/Unit/ClientReadGroupContextBuilderTest.php diff --git a/src/Module/Catalog/Domain/Entity/Category.php b/src/Module/Catalog/Domain/Entity/Category.php index 9ae95fd..5040e02 100644 --- a/src/Module/Catalog/Domain/Entity/Category.php +++ b/src/Module/Catalog/Domain/Entity/Category.php @@ -153,6 +153,16 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt return $this; } + /** + * Implemente CategoryInterface : code du type rattache (ou null). Permet + * aux modules tiers de filtrer/valider par type metier sans dependre de + * Catalog. + */ + public function getCategoryTypeCode(): ?string + { + return $this->categoryType?->getCode(); + } + public function getDeletedAt(): ?DateTimeImmutable { return $this->deletedAt; diff --git a/src/Module/Commercial/Application/Service/ClientFieldNormalizer.php b/src/Module/Commercial/Application/Service/ClientFieldNormalizer.php new file mode 100644 index 0000000..606fd4d --- /dev/null +++ b/src/Module/Commercial/Application/Service/ClientFieldNormalizer.php @@ -0,0 +1,80 @@ + "0612345678" (RG-1.20). + * Le formatage d'affichage "XX XX XX XX XX" est de la responsabilite du front. + * - email : lowercase integral (RG-1.21) + * + * Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide + * apres trim devient null (evite de persister "" dans des colonnes nullable). + */ +final class ClientFieldNormalizer +{ + /** + * Nom de societe en majuscules (RG-1.18). Conserve null tel quel ; une + * chaine non vide est trim + upper. Une chaine vide reste "" (champ + * obligatoire : c'est l'Assert\NotBlank qui rejette, pas le normalizer). + */ + public function normalizeCompanyName(?string $value): ?string + { + if (null === $value) { + return null; + } + + return mb_strtoupper(trim($value), 'UTF-8'); + } + + /** + * Nom/prenom de personne en Title Case (RG-1.19) : "JEAN dupont" -> + * "Jean Dupont". Une chaine vide apres trim devient null. + */ + public function normalizePersonName(?string $value): ?string + { + if (null === $value) { + return null; + } + + $value = trim($value); + + return '' === $value ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8'); + } + + /** + * Email en minuscules (RG-1.21). Une chaine vide apres trim devient null. + */ + public function normalizeEmail(?string $value): ?string + { + if (null === $value) { + return null; + } + + $value = trim($value); + + return '' === $value ? null : mb_strtolower($value, 'UTF-8'); + } + + /** + * Telephone reduit aux chiffres (RG-1.20) : "06.12.34.56.78" -> + * "0612345678". Une valeur sans aucun chiffre devient null. + */ + public function normalizePhone(?string $value): ?string + { + if (null === $value) { + return null; + } + + $digits = preg_replace('/\D+/', '', $value) ?? ''; + + return '' === $digits ? null : $digits; + } +} diff --git a/src/Module/Commercial/Application/Validator/ClientInformationCompletenessValidator.php b/src/Module/Commercial/Application/Validator/ClientInformationCompletenessValidator.php new file mode 100644 index 0000000..154184d --- /dev/null +++ b/src/Module/Commercial/Application/Validator/ClientInformationCompletenessValidator.php @@ -0,0 +1,76 @@ + valeur courante de l'onglet Information. + $fields = [ + 'description' => $client->getDescription(), + 'competitors' => $client->getCompetitors(), + 'foundedAt' => $client->getFoundedAt(), + 'employeesCount' => $client->getEmployeesCount(), + 'revenueAmount' => $client->getRevenueAmount(), + 'directorName' => $client->getDirectorName(), + 'profitAmount' => $client->getProfitAmount(), + ]; + + $violations = new ConstraintViolationList(); + + foreach ($fields as $property => $value) { + if ($this->isMissing($value)) { + $violations->add(new ConstraintViolation( + sprintf('Ce champ est obligatoire pour le role Commerciale (champ "%s").', $property), + null, + [], + $client, + $property, + $value, + )); + } + } + + if (count($violations) > 0) { + throw new ValidationException($violations); + } + } + + /** + * Une valeur est manquante si null ou, pour une chaine, vide apres trim. + * Les zeros numeriques (employeesCount = 0, profitAmount = "0.00") sont des + * valeurs valides : on ne les considere pas manquants. + */ + private function isMissing(mixed $value): bool + { + if (null === $value) { + return true; + } + + return is_string($value) && '' === trim($value); + } +} diff --git a/src/Module/Commercial/Domain/Entity/Client.php b/src/Module/Commercial/Domain/Entity/Client.php index 8eee053..6240975 100644 --- a/src/Module/Commercial/Domain/Entity/Client.php +++ b/src/Module/Commercial/Domain/Entity/Client.php @@ -4,6 +4,13 @@ declare(strict_types=1); namespace App\Module\Commercial\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientProcessor; +use App\Module\Commercial\Infrastructure\ApiPlatform\State\Provider\ClientProvider; use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; @@ -15,6 +22,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Serializer\Attribute\SerializedName; use Symfony\Component\Validator\Constraints as Assert; /** @@ -36,9 +44,62 @@ use Symfony\Component\Validator\Constraints as Assert; * - categories : M2M vers Category (module Catalog) via le contrat * CategoryInterface + resolve_target_entities (regle n°1, pas d'import direct). * - * Aucun ApiResource au M1.1 (ERP-54) : les operations API (Provider + Processor, - * normalisation, archivage, accounting conditionnel) sont branchees en ERP-55. + * Operations API (Provider + Processor) branchees en ERP-55 : + * - GetCollection / Get : security commercial.clients.view. La liste expose le + * groupe client:read ; le detail embarque en plus contacts/adresses/ribs + * (groupe client:item:read). Les champs comptables (client:read:accounting) + * sont ajoutes DYNAMIQUEMENT par ClientReadGroupContextBuilder si l'user a + * la permission accounting.view (§ 2.7 / § 4.1 / § 4.2). + * - Post / Patch : security commercial.clients.manage ; le ClientProcessor + * applique normalisation, gating accounting/archive et regles metier. + * - Pas de Delete au M1 (HP-M2-1) : l'archivage passe par PATCH isArchived. */ +#[ApiResource( + operations: [ + new GetCollection( + security: "is_granted('commercial.clients.view')", + normalizationContext: ['groups' => ['client:read', 'default:read']], + provider: ClientProvider::class, + ), + new Get( + security: "is_granted('commercial.clients.view')", + // Detail : client + sous-collections embarquees. Le groupe + // client:read:accounting est ajoute par le context builder selon la + // permission, donc absent ici volontairement. + normalizationContext: ['groups' => [ + 'client:read', + 'client:item:read', + 'client_contact:read', + 'client_address:read', + 'client_rib:read', + 'default:read', + ]], + provider: ClientProvider::class, + ), + new Post( + security: "is_granted('commercial.clients.manage')", + normalizationContext: ['groups' => ['client:read', 'default:read']], + denormalizationContext: ['groups' => ['client:write:main']], + processor: ClientProcessor::class, + ), + new Patch( + security: "is_granted('commercial.clients.manage')", + // Le ClientProcessor inspecte les champs reellement envoyes pour + // autoriser/refuser onglet par onglet (RG-1.22 / RG-1.28) : les + // champs accounting exigent accounting.manage, isArchived exige + // archive. + normalizationContext: ['groups' => ['client:read', 'default:read']], + denormalizationContext: ['groups' => [ + 'client:write:main', + 'client:write:information', + 'client:write:accounting', + 'client:write:archive', + ]], + provider: ClientProvider::class, + processor: ClientProcessor::class, + ), + ], +)] #[ORM\Entity(repositoryClass: DoctrineClientRepository::class)] #[ORM\Table(name: 'client')] // Index nommes pour matcher la migration (Version20260601000000). L'index @@ -202,8 +263,13 @@ class Client implements TimestampableInterface, BlamableInterface private Collection $ribs; // === Archive / Soft delete === + // Groupe d'ECRITURE uniquement sur la propriete (denormalisation PATCH + // archive). Le groupe de LECTURE est declare sur le getter isArchived() + // avec SerializedName('isArchived') : sans cela, Symfony strip le prefixe + // "is" et exposerait la cle JSON "archived" (meme pattern que User::isAdmin + // et Role::isSystem). #[ORM\Column(name: 'is_archived', options: ['default' => false])] - #[Groups(['client:read', 'client:write:archive'])] + #[Groups(['client:write:archive'])] private bool $isArchived = false; #[ORM\Column(type: 'datetime_immutable', nullable: true)] @@ -526,6 +592,7 @@ class Client implements TimestampableInterface, BlamableInterface } /** @return Collection */ + #[Groups(['client:item:read'])] public function getContacts(): Collection { return $this->contacts; @@ -551,6 +618,7 @@ class Client implements TimestampableInterface, BlamableInterface } /** @return Collection */ + #[Groups(['client:item:read'])] public function getAddresses(): Collection { return $this->addresses; @@ -576,6 +644,7 @@ class Client implements TimestampableInterface, BlamableInterface } /** @return Collection */ + #[Groups(['client:item:read'])] public function getRibs(): Collection { return $this->ribs; @@ -600,6 +669,10 @@ class Client implements TimestampableInterface, BlamableInterface return $this; } + // Groupe de lecture + nom serialise explicite : sans SerializedName, Symfony + // exposerait la cle "archived" (strip du prefixe "is" sur les getters). + #[Groups(['client:read'])] + #[SerializedName('isArchived')] public function isArchived(): bool { return $this->isArchived; diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/CategoryReferenceDenormalizer.php b/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/CategoryReferenceDenormalizer.php new file mode 100644 index 0000000..a1f1e72 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/CategoryReferenceDenormalizer.php @@ -0,0 +1,62 @@ +`, donc + * l'INTERFACE. Or le serializer ne sait pas denormaliser un IRI vers une + * interface (« Could not denormalize object of type CategoryInterface[] ») : il + * lui faut une classe-ressource concrete. On resout donc l'IRI via l'IriConverter + * (qui retourne la Category mappee a la route) sans importer Category — la regle + * ABSOLUE n°1 reste respectee (dependance au seul contrat Shared + API Platform). + * + * En lecture (normalisation), aucun probleme : l'objet reel EST une Category, + * resource a part entiere, serialisee en IRI par le normalizer standard. + */ +final class CategoryReferenceDenormalizer implements DenormalizerInterface +{ + public function __construct( + private readonly IriConverterInterface $iriConverter, + ) {} + + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): ?CategoryInterface + { + if (!is_string($data) || '' === $data) { + return null; + } + + // getResourceFromIri leve une exception sur IRI invalide -> 400, ce qui + // est le comportement attendu pour une reference cassee. + $resource = $this->iriConverter->getResourceFromIri($data); + + return $resource instanceof CategoryInterface ? $resource : null; + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + // Support base sur le seul type cible : l'ArrayDenormalizer (collection + // `CategoryInterface[]`) interroge le support en passant le TABLEAU + // complet comme $data avant de deleguer element par element. Tester + // is_string($data) ici casserait donc la chaine pour les collections. + return CategoryInterface::class === $type; + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return [CategoryInterface::class => true]; + } +} diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/ClientReadGroupContextBuilder.php b/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/ClientReadGroupContextBuilder.php new file mode 100644 index 0000000..9d5220b --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/ClientReadGroupContextBuilder.php @@ -0,0 +1,65 @@ +decorated->createFromRequest($request, $normalization, $extractedAttributes); + + // Uniquement en lecture, sur la ressource Client, avec la permission. + if (!$normalization) { + return $context; + } + + if (Client::class !== ($context['resource_class'] ?? null)) { + return $context; + } + + if (!$this->security->isGranted('commercial.clients.accounting.view')) { + return $context; + } + + $groups = $context['groups'] ?? []; + if (!in_array('client:read:accounting', $groups, true)) { + $groups[] = 'client:read:accounting'; + } + $context['groups'] = $groups; + + return $context; + } +} diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php new file mode 100644 index 0000000..e083744 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php @@ -0,0 +1,362 @@ + exige accounting.manage (RG-1.28, 403) ; + * - champ isArchived dans le payload -> exige archive (RG-1.22, 403) et + * interdit toute autre modification dans la meme requete (RG-1.22, 422). + * 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer. + * 3. Regles metier : RG-1.01 (prenom/nom), RG-1.03 (distributor/broker + * exclusifs + type de categorie), RG-1.12 (Virement -> banque), + * RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information pour le role + * Commerciale). + * 4. Pose / retrait de archivedAt (RG-1.22 true=now, RG-1.23 false=null). + * 5. Persistance via le persist_processor Doctrine, avec traduction des + * collisions d'unicite en 409 (RG-1.16 doublon de nom ; RG-1.23 conflit de + * restauration). + * + * Note : la validation Symfony (Assert\NotBlank, Assert\Email, Assert\Count sur + * categories...) est jouee par API Platform AVANT ce processor ; on n'y traite + * donc que les regles non exprimables en simples contraintes d'attribut. + * + * @implements ProcessorInterface + */ +final class ClientProcessor implements ProcessorInterface +{ + /** Champs de l'onglet principal (groupe client:write:main). */ + private const array MAIN_FIELDS = [ + 'companyName', 'firstName', 'lastName', 'phonePrimary', 'phoneSecondary', + 'email', 'distributor', 'broker', 'triageService', 'categories', + ]; + + /** Champs de l'onglet Information (groupe client:write:information). */ + private const array INFORMATION_FIELDS = [ + 'description', 'competitors', 'foundedAt', 'employeesCount', + 'revenueAmount', 'directorName', 'profitAmount', + ]; + + /** Champs de l'onglet Comptabilite (groupe client:write:accounting). */ + private const array ACCOUNTING_FIELDS = [ + 'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', + 'paymentType', 'bank', + ]; + + /** Champ d'archivage (groupe client:write:archive). */ + private const string ARCHIVE_FIELD = 'isArchived'; + + private const string PERM_ACCOUNTING_MANAGE = 'commercial.clients.accounting.manage'; + private const string PERM_ARCHIVE = 'commercial.clients.archive'; + + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + private readonly ClientFieldNormalizer $normalizer, + private readonly ClientInformationCompletenessValidator $informationValidator, + private readonly Security $security, + private readonly RequestStack $requestStack, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof Client) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + $payloadKeys = $this->payloadKeys(); + + $isArchiveRequest = $this->guardArchive($data, $payloadKeys); + $this->guardAccounting($payloadKeys); + + $this->normalize($data); + + $this->validateMainContact($data); + $this->validateDistributorBroker($data); + $this->validateAccountingConsistency($data); + $this->validateInformationCompleteness($data, $payloadKeys); + + try { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } catch (UniqueConstraintViolationException $e) { + // Le seul index unique partiel est uq_client_company_name_active + // (LOWER(company_name) parmi non-archives/non-deletes — decision Q4). + if ($isArchiveRequest && false === $data->isArchived()) { + // RG-1.23 : restauration en conflit avec un homonyme actif. + throw new ConflictHttpException( + 'Restauration impossible : un autre client a pris le nom entre-temps.', + $e, + ); + } + + // RG-1.16 : doublon de nom de societe. + throw new ConflictHttpException( + sprintf('Un client nommé "%s" existe déjà.', (string) $data->getCompanyName()), + $e, + ); + } + } + + /** + * RG-1.22 / RG-1.23 : si le payload porte isArchived, exige la permission + * archive (403), interdit toute autre modification (422) et pose/retire + * archivedAt. Retourne true si la requete est une requete d'archivage. + * + * @param list $payloadKeys + */ + private function guardArchive(Client $data, array $payloadKeys): bool + { + if (!in_array(self::ARCHIVE_FIELD, $payloadKeys, true)) { + return false; + } + + if (!$this->security->isGranted(self::PERM_ARCHIVE)) { + throw new AccessDeniedHttpException(sprintf( + 'Le champ "%s" requiert la permission "%s".', + self::ARCHIVE_FIELD, + self::PERM_ARCHIVE, + )); + } + + // RG-1.22 : une requete d'archivage ne modifie aucun autre champ. + if ([] !== array_diff($payloadKeys, [self::ARCHIVE_FIELD])) { + throw new UnprocessableEntityHttpException( + 'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".', + ); + } + + // RG-1.22 (true -> now) / RG-1.23 (false -> null). + $data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null); + + return true; + } + + /** + * RG-1.28 : un champ comptable dans le payload exige accounting.manage, + * sinon 403 sur l'ensemble du payload (mode strict, pas de filtrage + * silencieux). Le message precise le premier champ fautif. + * + * @param list $payloadKeys + */ + private function guardAccounting(array $payloadKeys): void + { + $touched = array_values(array_intersect($payloadKeys, self::ACCOUNTING_FIELDS)); + + if ([] === $touched) { + return; + } + + if (!$this->security->isGranted(self::PERM_ACCOUNTING_MANAGE)) { + throw new AccessDeniedHttpException(sprintf( + 'Le champ "%s" requiert la permission "%s".', + $touched[0], + self::PERM_ACCOUNTING_MANAGE, + )); + } + } + + /** + * Normalisation serveur (RG-1.18 a 1.21). Les setters non-nullables + * (companyName, email, phonePrimary) ne sont touches que si une valeur est + * presente, pour ne jamais ecraser l'existant lors d'un PATCH partiel. + */ + private function normalize(Client $data): void + { + if (null !== $data->getCompanyName()) { + $data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName())); + } + if (null !== $data->getEmail()) { + $data->setEmail((string) $this->normalizer->normalizeEmail($data->getEmail())); + } + if (null !== $data->getPhonePrimary()) { + $data->setPhonePrimary((string) $this->normalizer->normalizePhone($data->getPhonePrimary())); + } + + $data->setFirstName($this->normalizer->normalizePersonName($data->getFirstName())); + $data->setLastName($this->normalizer->normalizePersonName($data->getLastName())); + $data->setPhoneSecondary($this->normalizer->normalizePhone($data->getPhoneSecondary())); + } + + /** + * RG-1.01 : au moins le prenom OU le nom du contact principal. + */ + private function validateMainContact(Client $data): void + { + if (null === $data->getFirstName() && null === $data->getLastName()) { + $this->throwViolation( + 'firstName', + 'Le prénom ou le nom du contact principal est obligatoire.', + $data, + ); + } + } + + /** + * RG-1.03 : distributor et broker mutuellement exclusifs ; un distributor + * doit referencer un client de categorie DISTRIBUTEUR (idem broker -> + * COURTIER). + */ + private function validateDistributorBroker(Client $data): void + { + $distributor = $data->getDistributor(); + $broker = $data->getBroker(); + + if (null !== $distributor && null !== $broker) { + $this->throwViolation( + 'distributor', + 'Un client ne peut pas être rattaché à la fois à un distributeur et à un courtier.', + $data, + ); + } + + if (null !== $distributor && !$this->hasCategoryType($distributor, 'DISTRIBUTEUR')) { + $this->throwViolation( + 'distributor', + 'Le distributeur référencé doit être un client de catégorie DISTRIBUTEUR.', + $data, + ); + } + + if (null !== $broker && !$this->hasCategoryType($broker, 'COURTIER')) { + $this->throwViolation( + 'broker', + 'Le courtier référencé doit être un client de catégorie COURTIER.', + $data, + ); + } + } + + /** + * RG-1.12 : Virement -> banque obligatoire. RG-1.13 : LCR -> au moins un RIB. + */ + private function validateAccountingConsistency(Client $data): void + { + $paymentCode = $data->getPaymentType()?->getCode(); + + if ('VIREMENT' === $paymentCode && null === $data->getBank()) { + $this->throwViolation( + 'bank', + 'La banque est obligatoire pour le type de règlement Virement.', + $data, + ); + } + + if ('LCR' === $paymentCode && $data->getRibs()->isEmpty()) { + $this->throwViolation( + 'paymentType', + 'Au moins un RIB est obligatoire pour le type de règlement LCR.', + $data, + ); + } + } + + /** + * RG-1.04 : si l'utilisateur porte le role metier Commerciale ET que le + * payload touche l'onglet Information, tous les champs Information sont + * obligatoires. Dormant tant qu'aucun user ne porte le role `commerciale`. + * + * @param list $payloadKeys + */ + private function validateInformationCompleteness(Client $data, array $payloadKeys): void + { + $touchesInformation = [] !== array_intersect($payloadKeys, self::INFORMATION_FIELDS); + + if ($touchesInformation && $this->currentUserIsCommerciale()) { + $this->informationValidator->validate($data); + } + } + + /** + * Vrai si au moins une categorie du client porte le type donne. S'appuie + * sur CategoryInterface::getCategoryTypeCode() (pas d'import de Category). + */ + private function hasCategoryType(Client $client, string $typeCode): bool + { + foreach ($client->getCategories() as $category) { + if ($category instanceof CategoryInterface && $category->getCategoryTypeCode() === $typeCode) { + return true; + } + } + + return false; + } + + private function currentUserIsCommerciale(): bool + { + $user = $this->security->getUser(); + + return $user instanceof BusinessRoleAwareInterface + && $user->hasBusinessRole(BusinessRoles::COMMERCIALE); + } + + /** + * Cles de premier niveau effectivement envoyees par le client (payload JSON + * brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies ; + * c'est ce qui permet le gating par onglet (RG-1.22 / RG-1.28) et le + * declenchement conditionnel de RG-1.04. + * + * @return list + */ + private function payloadKeys(): array + { + $request = $this->requestStack->getCurrentRequest(); + if (null === $request) { + return []; + } + + $content = $request->getContent(); + if ('' === $content) { + return []; + } + + try { + $decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return []; + } + + return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : []; + } + + /** + * Leve une ValidationException (HTTP 422) portant une violation unique sur + * la propriete visee — meme rendu Hydra que les contraintes Symfony. + * + * @return never + */ + private function throwViolation(string $property, string $message, Client $root): void + { + $violations = new ConstraintViolationList(); + $violations->add(new ConstraintViolation($message, null, [], $root, $property, null)); + + throw new ValidationException($violations); + } +} diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php new file mode 100644 index 0000000..bbd6e52 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php @@ -0,0 +1,170 @@ + (clients ayant >= 1 categorie de ce type) ; + * - pagination obligatoire (convention Starseed ERP-72) : Paginator ORM ; + * echappatoire ?pagination=false pour alimenter un cote front). + if (!$this->pagination->isEnabled($operation, $context)) { + /** @var list $result */ + return $qb->getQuery()->getResult(); + } + + $limit = $this->pagination->getLimit($operation, $context); + $page = max(1, $this->pagination->getPage($context)); + $offset = ($page - 1) * $limit; + + $qb->setFirstResult($offset)->setMaxResults($limit); + + // fetchJoinCollection: true pour un COUNT correct des que des JOINs + // to-many seront ajoutes (sous-collections embarquees en detail). + return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true)); + } + + /** + * @param array $uriVariables + */ + private function provideItem(array $uriVariables): ?Client + { + $id = $uriVariables['id'] ?? null; + if (!is_int($id) && !(is_string($id) && ctype_digit($id))) { + return null; + } + + $client = $this->repository->findById((int) $id); + if (null === $client) { + return null; + } + + // Soft-delete : jamais expose au M1 (HP-M2-1) — 404 via retour null. + // Les archives restent visibles en detail (consultation + restauration). + if (null !== $client->getDeletedAt()) { + return null; + } + + return $client; + } + + /** + * Recherche fuzzy insensible a la casse sur companyName + lastName + email. + * Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester + * litteraux. + */ + private function applySearch(QueryBuilder $qb, mixed $search): void + { + if (!is_string($search) || '' === trim($search)) { + return; + } + + $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search)); + $pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%'; + + $qb->andWhere( + 'LOWER(c.companyName) LIKE :search ' + .'OR LOWER(c.lastName) LIKE :search ' + .'OR LOWER(c.email) LIKE :search', + )->setParameter('search', $pattern); + } + + /** + * Restreint aux clients possedant au moins une categorie du type donne. + * Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas + * perturber le DISTINCT / ORDER BY de la requete paginee principale. + */ + private function applyCategoryType(QueryBuilder $qb, mixed $categoryType): void + { + if (!is_string($categoryType) || '' === trim($categoryType)) { + return; + } + + $sub = $this->repository->createQueryBuilder('c2') + ->select('c2.id') + ->join('c2.categories', 'cat2') + ->join('cat2.categoryType', 'ct2') + ->where('ct2.code = :categoryType') + ; + + $qb->andWhere($qb->expr()->in('c.id', $sub->getDQL())) + ->setParameter('categoryType', trim($categoryType)) + ; + } + + /** + * Lit un flag booleen issu des query params. Accepte true / "true" / "1". + */ + private function readBool(mixed $raw): bool + { + if (is_bool($raw)) { + return $raw; + } + + return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true); + } +} diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index 00e6d50..8016543 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -22,6 +22,7 @@ use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository; // C'est le pattern officiel Doctrine pour les bounded contexts DDD. use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Attribute\AuditIgnore; +use App\Shared\Domain\Contract\BusinessRoleAwareInterface; use App\Shared\Domain\Contract\SiteInterface; use App\Shared\Domain\Exception\SiteNotAuthorizedException; use DateTimeImmutable; @@ -75,7 +76,7 @@ use Symfony\Component\Serializer\Attribute\SerializedName; #[ORM\Entity(repositoryClass: DoctrineUserRepository::class)] #[ORM\Table(name: '`user`')] #[Auditable] -class User implements UserInterface, PasswordAuthenticatedUserInterface +class User implements UserInterface, PasswordAuthenticatedUserInterface, BusinessRoleAwareInterface { #[ORM\Id] #[ORM\GeneratedValue] @@ -337,6 +338,23 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $keys; } + /** + * Implemente BusinessRoleAwareInterface : vrai si l'un des roles RBAC + * rattaches porte le code donne. Permet aux modules tiers de detecter un + * role metier (ex: `commerciale` pour RG-1.04 du M1 Clients) sans importer + * cette classe. Comparaison stricte sur Role::code. + */ + public function hasBusinessRole(string $roleCode): bool + { + foreach ($this->rbacRoles as $role) { + if ($role->getCode() === $roleCode) { + return true; + } + } + + return false; + } + public function getPassword(): ?string { return $this->password; diff --git a/src/Shared/Domain/Contract/BusinessRoleAwareInterface.php b/src/Shared/Domain/Contract/BusinessRoleAwareInterface.php new file mode 100644 index 0000000..92646a4 --- /dev/null +++ b/src/Shared/Domain/Contract/BusinessRoleAwareInterface.php @@ -0,0 +1,27 @@ + purge manuelle obligatoire. + * + * @internal + */ +abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase +{ + protected const string TEST_CATEGORY_PREFIX = 'test_cli_cat_'; + + protected function tearDown(): void + { + $this->cleanupCommercialTestData(); + parent::tearDown(); + } + + protected function createAdminClient(): Client + { + return $this->authenticatedClient('admin', 'admin'); + } + + /** + * Recupere (ou cree) un CategoryType par son code metier. Idempotent : la + * contrainte d'unicite sur category_type.code interdit les doublons. + */ + protected function createCategoryType(string $code): CategoryType + { + $em = $this->getEm(); + $existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => $code]); + if (null !== $existing) { + return $existing; + } + + $type = new CategoryType(); + $type->setCode($code); + $type->setLabel(ucfirst(strtolower($code))); + $em->persist($type); + $em->flush(); + + return $type; + } + + /** + * Cree une Category de test rattachee a un type metier donne (code). + */ + protected function createCategory(string $typeCode = 'SECTEUR'): Category + { + $em = $this->getEm(); + $suffix = substr(bin2hex(random_bytes(4)), 0, 8); + $category = new Category(); + $category->setName(self::TEST_CATEGORY_PREFIX.$suffix); + $category->setCategoryType($this->createCategoryType($typeCode)); + $em->persist($category); + $em->flush(); + + return $category; + } + + /** + * Seede directement un Client en base (sans passer par l'API), pour les + * tests de liste / archivage. Le client porte une categorie SECTEUR. + */ + protected function seedClient(string $companyName, bool $isArchived = false, string $categoryTypeCode = 'SECTEUR'): ClientEntity + { + $em = $this->getEm(); + $client = new ClientEntity(); + // Stocke en MAJUSCULES pour refleter l'etat normalise (RG-1.18) qu'aurait + // produit le ClientProcessor via l'API. + $client->setCompanyName(mb_strtoupper($companyName, 'UTF-8')); + $client->setLastName('Seed'); + $client->setPhonePrimary('0102030405'); + $client->setEmail(strtolower(str_replace(' ', '', $companyName)).'@seed.test'); + $client->addCategory($this->createCategory($categoryTypeCode)); + $client->setIsArchived($isArchived); + if ($isArchived) { + $client->setArchivedAt(new DateTimeImmutable()); + } + $em->persist($client); + $em->flush(); + + return $client; + } + + private function cleanupCommercialTestData(): void + { + $em = $this->getEm(); + + // Clients d'abord (la jointure client_category est purgee par + // ON DELETE CASCADE ; les auto-references distributor/broker sont + // ON DELETE SET NULL). + $em->createQuery('DELETE FROM '.ClientEntity::class)->execute(); + + // Categories de test ensuite (FK client_category deja purgee). + $em->createQuery( + 'DELETE FROM '.Category::class.' c WHERE c.name LIKE :prefix', + )->setParameter('prefix', self::TEST_CATEGORY_PREFIX.'%')->execute(); + + // Users / roles jetables. + $em->createQuery( + 'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix', + )->setParameter('prefix', 'test_%')->execute(); + + $em->createQuery( + 'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix', + )->setParameter('prefix', 'test_%')->execute(); + } +} diff --git a/tests/Module/Commercial/Api/ClientApiTest.php b/tests/Module/Commercial/Api/ClientApiTest.php new file mode 100644 index 0000000..605a07e --- /dev/null +++ b/tests/Module/Commercial/Api/ClientApiTest.php @@ -0,0 +1,285 @@ +createAdminClient(); + $cat = $this->createCategory('SECTEUR'); + + $response = $client->request('POST', '/api/clients', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'companyName' => 'acme sas', + 'firstName' => 'JEAN', + 'lastName' => 'dupont', + 'phonePrimary' => '06.12.34.56.78', + 'email' => 'Jean.DUPONT@ACME.FR', + 'categories' => ['/api/categories/'.$cat->getId()], + ], + ]); + + self::assertResponseStatusCodeSame(201); + $data = $response->toArray(); + // RG-1.18 / 1.19 / 1.20 / 1.21 + self::assertSame('ACME SAS', $data['companyName']); + self::assertSame('Jean', $data['firstName']); + self::assertSame('Dupont', $data['lastName']); + self::assertSame('0612345678', $data['phonePrimary']); + self::assertSame('jean.dupont@acme.fr', $data['email']); + self::assertFalse($data['isArchived']); + } + + public function testPostDuplicateCompanyNameReturns409(): void + { + $client = $this->createAdminClient(); + $cat = $this->createCategory('SECTEUR'); + $iri = '/api/categories/'.$cat->getId(); + + $payload = [ + 'companyName' => 'Doublon SARL', + 'firstName' => 'A', + 'phonePrimary' => '0102030405', + 'email' => 'dup@test.fr', + 'categories' => [$iri], + ]; + + $client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]); + self::assertResponseStatusCodeSame(201); + + // Meme nom (insensible a la casse via l'index LOWER) -> 409 (RG-1.16). + $payload['email'] = 'dup2@test.fr'; + $client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]); + self::assertResponseStatusCodeSame(409); + } + + public function testPostWithoutFirstOrLastNameReturns422(): void + { + $client = $this->createAdminClient(); + $cat = $this->createCategory('SECTEUR'); + + $client->request('POST', '/api/clients', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'companyName' => 'No Contact Name', + 'phonePrimary' => '0102030405', + 'email' => 'nc@test.fr', + 'categories' => ['/api/categories/'.$cat->getId()], + ], + ]); + + // RG-1.01 + self::assertResponseStatusCodeSame(422); + } + + public function testPostWithoutCategoryReturns422(): void + { + $client = $this->createAdminClient(); + + $client->request('POST', '/api/clients', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'companyName' => 'No Category', + 'firstName' => 'A', + 'phonePrimary' => '0102030405', + 'email' => 'nocat@test.fr', + 'categories' => [], + ], + ]); + + // Assert\Count(min: 1) + self::assertResponseStatusCodeSame(422); + } + + public function testPostWithDistributorAndBrokerReturns422(): void + { + $client = $this->createAdminClient(); + $cat = $this->createCategory('SECTEUR'); + $distributor = $this->seedClient('Distrib Mutex', false, 'DISTRIBUTEUR'); + + $client->request('POST', '/api/clients', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'companyName' => 'Mutex Client', + 'firstName' => 'A', + 'phonePrimary' => '0102030405', + 'email' => 'mutex@test.fr', + 'categories' => ['/api/categories/'.$cat->getId()], + 'distributor' => '/api/clients/'.$distributor->getId(), + 'broker' => '/api/clients/'.$distributor->getId(), + ], + ]); + + // RG-1.03 (exclusivite) + self::assertResponseStatusCodeSame(422); + } + + public function testPostDistributorReferencingNonDistributorReturns422(): void + { + $client = $this->createAdminClient(); + $cat = $this->createCategory('SECTEUR'); + $notDistro = $this->seedClient('Pas Un Distrib', false, 'SECTEUR'); + + $client->request('POST', '/api/clients', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'companyName' => 'Bad Distrib Ref', + 'firstName' => 'A', + 'phonePrimary' => '0102030405', + 'email' => 'baddistrib@test.fr', + 'categories' => ['/api/categories/'.$cat->getId()], + 'distributor' => '/api/clients/'.$notDistro->getId(), + ], + ]); + + // RG-1.03 (le distributor doit etre categorise DISTRIBUTEUR) + self::assertResponseStatusCodeSame(422); + } + + public function testPostValidDistributorReturns201(): void + { + $client = $this->createAdminClient(); + $cat = $this->createCategory('SECTEUR'); + $distributor = $this->seedClient('Vrai Distrib', false, 'DISTRIBUTEUR'); + + $client->request('POST', '/api/clients', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'companyName' => 'Client Avec Distrib', + 'firstName' => 'A', + 'phonePrimary' => '0102030405', + 'email' => 'okdistrib@test.fr', + 'categories' => ['/api/categories/'.$cat->getId()], + 'distributor' => '/api/clients/'.$distributor->getId(), + ], + ]); + + self::assertResponseStatusCodeSame(201); + } + + public function testListSortedByCompanyNameAscAndExcludesArchived(): void + { + $client = $this->createAdminClient(); + $this->seedClient('Zebra Co'); + $this->seedClient('Alpha Co'); + $this->seedClient('Archivé Co', true); + + $names = $client->request('GET', '/api/clients?pagination=false', [ + 'headers' => ['Accept' => self::LD], + ])->toArray()['member']; + $companyNames = array_map(static fn (array $c): string => $c['companyName'], $names); + + // RG-1.24 : l'archive est exclue par defaut. + self::assertNotContains('ARCHIVÉ CO', $companyNames); + // RG-1.26 : tri companyName ASC (Alpha avant Zebra). + $alpha = array_search('ALPHA CO', $companyNames, true); + $zebra = array_search('ZEBRA CO', $companyNames, true); + self::assertNotFalse($alpha); + self::assertNotFalse($zebra); + self::assertLessThan($zebra, $alpha); + } + + public function testListIncludeArchivedReturnsArchived(): void + { + $client = $this->createAdminClient(); + $this->seedClient('Hidden Archived', true); + + $members = $client->request('GET', '/api/clients?includeArchived=true&pagination=false', [ + 'headers' => ['Accept' => self::LD], + ])->toArray()['member']; + $names = array_map(static fn (array $c): string => $c['companyName'], $members); + + // RG-1.25 + self::assertContains('HIDDEN ARCHIVED', $names); + } + + public function testCollectionIsPaginated(): void + { + $client = $this->createAdminClient(); + $this->seedClient('Paginated One'); + + // Collection Hydra avec total (la cle `view` n'apparait qu'a partir de + // 2 pages cote API Platform 4, donc non assertable sur page unique). + $page1 = $client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]])->toArray(); + self::assertArrayHasKey('totalItems', $page1); + self::assertNotEmpty($page1['member']); + + // Preuve que la pagination serveur est bien engagee : la page 2 d'un jeu + // tenant sur une page est vide (un provider non pagine ignorerait `page` + // et renverrait quand meme les items). + $page2 = $client->request('GET', '/api/clients?page=2', ['headers' => ['Accept' => self::LD]])->toArray(); + self::assertSame([], $page2['member']); + } + + public function testPatchArchiveSetsArchivedAtThenRestore(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('To Archive'); + $iri = '/api/clients/'.$seed->getId(); + + // Archive (RG-1.22) : admin a la permission archive via bypass isAdmin. + $archived = $client->request('PATCH', $iri, [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['isArchived' => true], + ])->toArray(); + self::assertResponseStatusCodeSame(200); + self::assertTrue($archived['isArchived']); + self::assertNotNull($archived['archivedAt']); + + // Restauration (RG-1.23) : archivedAt repasse a null. + $restored = $client->request('PATCH', $iri, [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['isArchived' => false], + ])->toArray(); + self::assertResponseStatusCodeSame(200); + self::assertFalse($restored['isArchived']); + self::assertNull($restored['archivedAt']); + } + + public function testPatchArchiveWithOtherFieldReturns422(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Archive Plus Field'); + + $client->request('PATCH', '/api/clients/'.$seed->getId(), [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['isArchived' => true, 'companyName' => 'Renamed'], + ]); + + // RG-1.22 : une requete d'archivage ne modifie aucun autre champ. + self::assertResponseStatusCodeSame(422); + } + + public function testGetDetailEmbedsSubCollections(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Detail Embed'); + + $data = $client->request('GET', '/api/clients/'.$seed->getId(), [ + 'headers' => ['Accept' => self::LD], + ])->toArray(); + + // § 4.2 : le detail embarque contacts / adresses / ribs. + self::assertArrayHasKey('contacts', $data); + self::assertArrayHasKey('addresses', $data); + self::assertArrayHasKey('ribs', $data); + } +} diff --git a/tests/Module/Commercial/Unit/ClientFieldNormalizerTest.php b/tests/Module/Commercial/Unit/ClientFieldNormalizerTest.php new file mode 100644 index 0000000..f31823a --- /dev/null +++ b/tests/Module/Commercial/Unit/ClientFieldNormalizerTest.php @@ -0,0 +1,56 @@ +normalizer = new ClientFieldNormalizer(); + } + + public function testCompanyNameIsUppercased(): void + { + // RG-1.18 + self::assertSame('ACME SAS', $this->normalizer->normalizeCompanyName(' acme sas ')); + self::assertNull($this->normalizer->normalizeCompanyName(null)); + } + + public function testPersonNameIsTitleCased(): void + { + // RG-1.19 + self::assertSame('Jean', $this->normalizer->normalizePersonName('JEAN')); + self::assertSame('Dupont', $this->normalizer->normalizePersonName('dupont')); + self::assertNull($this->normalizer->normalizePersonName(' ')); + self::assertNull($this->normalizer->normalizePersonName(null)); + } + + public function testEmailIsLowercased(): void + { + // RG-1.21 + self::assertSame('jean.dupont@acme.fr', $this->normalizer->normalizeEmail(' Jean.DUPONT@ACME.FR ')); + self::assertNull($this->normalizer->normalizeEmail(null)); + self::assertNull($this->normalizer->normalizeEmail(' ')); + } + + public function testPhoneKeepsOnlyDigits(): void + { + // RG-1.20 + self::assertSame('0612345678', $this->normalizer->normalizePhone('06.12.34.56.78')); + self::assertSame('0612345678', $this->normalizer->normalizePhone('06 12 34 56 78')); + self::assertNull($this->normalizer->normalizePhone('----')); + self::assertNull($this->normalizer->normalizePhone(null)); + } +} diff --git a/tests/Module/Commercial/Unit/ClientProcessorTest.php b/tests/Module/Commercial/Unit/ClientProcessorTest.php new file mode 100644 index 0000000..7552d1d --- /dev/null +++ b/tests/Module/Commercial/Unit/ClientProcessorTest.php @@ -0,0 +1,253 @@ + 403. + $processor = $this->makeProcessor(granted: [], payload: ['siren' => '123456789']); + + $this->expectException(AccessDeniedHttpException::class); + $processor->process($this->minimalClient(), $this->operation()); + } + + public function testStrictMixWithAccountingFieldIsForbidden(): void + { + // RG-1.28 : payload mixant main + accounting sans la permission -> 403 + // sur l'ensemble (pas de filtrage silencieux). + $processor = $this->makeProcessor( + granted: [], + payload: ['companyName' => 'X', 'siren' => '123456789'], + ); + + $this->expectException(AccessDeniedHttpException::class); + $processor->process($this->minimalClient(), $this->operation()); + } + + public function testArchiveWithoutPermissionIsForbidden(): void + { + // RG-1.22 : isArchived sans la permission archive -> 403. + $processor = $this->makeProcessor(granted: [], payload: ['isArchived' => true]); + + $this->expectException(AccessDeniedHttpException::class); + $processor->process($this->minimalClient(), $this->operation()); + } + + public function testArchiveWithOtherFieldIsUnprocessable(): void + { + // RG-1.22 : une requete d'archivage ne modifie aucun autre champ. + $processor = $this->makeProcessor( + granted: ['commercial.clients.archive'], + payload: ['isArchived' => true, 'companyName' => 'X'], + ); + + $this->expectException(UnprocessableEntityHttpException::class); + $processor->process($this->minimalClient(), $this->operation()); + } + + public function testVirementWithoutBankIsUnprocessable(): void + { + // RG-1.12 + $client = $this->minimalClient(); + $client->setPaymentType($this->paymentType('VIREMENT')); + + $processor = $this->makeProcessor( + granted: ['commercial.clients.accounting.manage'], + payload: ['paymentType' => '/api/payment_types/1'], + ); + + $this->expectException(ValidationException::class); + $processor->process($client, $this->operation()); + } + + public function testVirementWithBankPasses(): void + { + // RG-1.12 satisfait : Virement + banque. + $client = $this->minimalClient(); + $client->setPaymentType($this->paymentType('VIREMENT')); + $client->setBank(new Bank()); + + $processor = $this->makeProcessor( + granted: ['commercial.clients.accounting.manage'], + payload: ['paymentType' => '/api/payment_types/1', 'bank' => '/api/banks/1'], + ); + + $result = $processor->process($client, $this->operation()); + self::assertInstanceOf(Client::class, $result); + } + + public function testLcrWithoutRibIsUnprocessable(): void + { + // RG-1.13 + $client = $this->minimalClient(); + $client->setPaymentType($this->paymentType('LCR')); + + $processor = $this->makeProcessor( + granted: ['commercial.clients.accounting.manage'], + payload: ['paymentType' => '/api/payment_types/2'], + ); + + $this->expectException(ValidationException::class); + $processor->process($client, $this->operation()); + } + + public function testLcrWithRibPasses(): void + { + // RG-1.13 satisfait : LCR + au moins un RIB. + $client = $this->minimalClient(); + $client->setPaymentType($this->paymentType('LCR')); + $client->addRib(new ClientRib()); + + $processor = $this->makeProcessor( + granted: ['commercial.clients.accounting.manage'], + payload: ['paymentType' => '/api/payment_types/2'], + ); + + self::assertInstanceOf(Client::class, $processor->process($client, $this->operation())); + } + + public function testCommercialeIncompleteInformationIsUnprocessable(): void + { + // RG-1.04 : role Commerciale + onglet Information incomplet -> 422. + $client = $this->minimalClient(); + $client->setDescription('Une description'); // les autres champs Information restent null + + $processor = $this->makeProcessor( + granted: [], + payload: ['description' => 'Une description'], + user: $this->commercialeUser(), + ); + + $this->expectException(ValidationException::class); + $processor->process($client, $this->operation()); + } + + public function testNonCommercialeSkipsInformationCompleteness(): void + { + // Meme payload incomplet, mais user non-Commerciale -> aucun blocage. + $client = $this->minimalClient(); + $client->setDescription('Une description'); + + $processor = $this->makeProcessor( + granted: [], + payload: ['description' => 'Une description'], + user: null, + ); + + self::assertInstanceOf(Client::class, $processor->process($client, $this->operation())); + } + + /** + * @param list $granted Permissions accordees a l'utilisateur courant + * @param array $payload Corps JSON simule de la requete + */ + private function makeProcessor(array $granted, array $payload, ?UserInterface $user = null): ClientProcessor + { + $persist = new class implements ProcessorInterface { + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + return $data; + } + }; + + $security = $this->createStub(Security::class); + $security->method('isGranted')->willReturnCallback( + static fn (mixed $attribute): bool => is_string($attribute) && in_array($attribute, $granted, true), + ); + $security->method('getUser')->willReturn($user); + + $requestStack = new RequestStack(); + $requestStack->push(new Request([], [], [], [], [], [], json_encode($payload, JSON_THROW_ON_ERROR))); + + return new ClientProcessor( + $persist, + new ClientFieldNormalizer(), + new ClientInformationCompletenessValidator(), + $security, + $requestStack, + ); + } + + /** + * Client minimal valide vis-a-vis de RG-1.01 (un nom de contact) — suffisant + * pour atteindre les validations testees. + */ + private function minimalClient(): Client + { + $client = new Client(); + $client->setCompanyName('Test Co'); + $client->setLastName('Dupont'); + $client->setPhonePrimary('0102030405'); + $client->setEmail('t@test.fr'); + + return $client; + } + + private function paymentType(string $code): PaymentType + { + $type = new PaymentType(); + $type->setCode($code); + $type->setLabel($code); + + return $type; + } + + private function operation(): Operation + { + return $this->createStub(Operation::class); + } + + private function commercialeUser(): UserInterface + { + return new class implements UserInterface, BusinessRoleAwareInterface { + public function hasBusinessRole(string $roleCode): bool + { + return BusinessRoles::COMMERCIALE === $roleCode; + } + + public function getRoles(): array + { + return ['ROLE_USER']; + } + + public function eraseCredentials(): void {} + + public function getUserIdentifier(): string + { + return 'commerciale-test'; + } + }; + } +} diff --git a/tests/Module/Commercial/Unit/ClientReadGroupContextBuilderTest.php b/tests/Module/Commercial/Unit/ClientReadGroupContextBuilderTest.php new file mode 100644 index 0000000..60f6eea --- /dev/null +++ b/tests/Module/Commercial/Unit/ClientReadGroupContextBuilderTest.php @@ -0,0 +1,85 @@ +builder( + baseContext: ['resource_class' => Client::class, 'groups' => ['client:read', 'default:read']], + granted: true, + ); + + $context = $builder->createFromRequest(new Request(), true); + + self::assertContains('client:read:accounting', $context['groups']); + } + + public function testDoesNotAddAccountingGroupWhenNotGranted(): void + { + $builder = $this->builder( + baseContext: ['resource_class' => Client::class, 'groups' => ['client:read', 'default:read']], + granted: false, + ); + + $context = $builder->createFromRequest(new Request(), true); + + self::assertNotContains('client:read:accounting', $context['groups']); + } + + public function testDoesNotAddAccountingGroupOnWrite(): void + { + $builder = $this->builder( + baseContext: ['resource_class' => Client::class, 'groups' => ['client:write:main']], + granted: true, + ); + + // normalization = false -> ecriture : pas de groupe de lecture ajoute. + $context = $builder->createFromRequest(new Request(), false); + + self::assertNotContains('client:read:accounting', $context['groups']); + } + + public function testIgnoresOtherResources(): void + { + $builder = $this->builder( + baseContext: ['resource_class' => 'App\Other\Resource', 'groups' => ['other:read']], + granted: true, + ); + + $context = $builder->createFromRequest(new Request(), true); + + self::assertSame(['other:read'], $context['groups']); + } + + /** + * @param array $baseContext + */ + private function builder(array $baseContext, bool $granted): ClientReadGroupContextBuilder + { + $decorated = $this->createStub(SerializerContextBuilderInterface::class); + $decorated->method('createFromRequest')->willReturn($baseContext); + + $security = $this->createStub(Security::class); + $security->method('isGranted')->willReturn($granted); + + return new ClientReadGroupContextBuilder($decorated, $security); + } +} -- 2.39.5 From 0f8fc48df078f2613fcf545fcc6f95b07abbe08d Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 1 Jun 2026 12:15:33 +0200 Subject: [PATCH 6/6] fix(commercial) : robust gating + strict category denormalizer + provider via EM (review ERP-55) --- .../CategoryReferenceDenormalizer.php | 14 +- .../State/Processor/ClientProcessor.php | 143 +++++++++++++++--- .../State/Provider/ClientProvider.php | 11 +- .../CategoryReferenceDenormalizerTest.php | 62 ++++++++ .../Commercial/Unit/ClientProcessorTest.php | 123 +++++++++++++-- 5 files changed, 315 insertions(+), 38 deletions(-) create mode 100644 tests/Module/Commercial/Unit/CategoryReferenceDenormalizerTest.php diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/CategoryReferenceDenormalizer.php b/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/CategoryReferenceDenormalizer.php index a1f1e72..e588c85 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/CategoryReferenceDenormalizer.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/CategoryReferenceDenormalizer.php @@ -6,6 +6,7 @@ namespace App\Module\Commercial\Infrastructure\ApiPlatform\Serializer; use ApiPlatform\Metadata\IriConverterInterface; use App\Shared\Domain\Contract\CategoryInterface; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; /** @@ -40,7 +41,18 @@ final class CategoryReferenceDenormalizer implements DenormalizerInterface // est le comportement attendu pour une reference cassee. $resource = $this->iriConverter->getResourceFromIri($data); - return $resource instanceof CategoryInterface ? $resource : null; + // IRI syntaxiquement valide mais pointant sur une autre ressource (ex: + // '/api/clients/5' la ou une categorie est attendue) : on refuse + // explicitement plutot que de retourner null silencieusement, ce qui + // perdrait la reference sans erreur. UnexpectedValueException -> 400. + if (!$resource instanceof CategoryInterface) { + throw new UnexpectedValueException(sprintf( + 'L\'IRI "%s" ne référence pas une catégorie.', + $data, + )); + } + + return $resource; } public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php index e083744..5f665f1 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php @@ -15,6 +15,7 @@ use App\Shared\Domain\Contract\CategoryInterface; use App\Shared\Domain\Security\BusinessRoles; use DateTimeImmutable; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use Doctrine\ORM\EntityManagerInterface; use JsonException; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -84,6 +85,7 @@ final class ClientProcessor implements ProcessorInterface private readonly ClientInformationCompletenessValidator $informationValidator, private readonly Security $security, private readonly RequestStack $requestStack, + private readonly EntityManagerInterface $em, ) {} public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed @@ -92,17 +94,17 @@ final class ClientProcessor implements ProcessorInterface return $this->persistProcessor->process($data, $operation, $uriVariables, $context); } - $payloadKeys = $this->payloadKeys(); + $writableKeys = $this->writablePayloadKeys(); - $isArchiveRequest = $this->guardArchive($data, $payloadKeys); - $this->guardAccounting($payloadKeys); + $isArchiveRequest = $this->guardArchive($data, $writableKeys); + $this->guardAccounting($data); $this->normalize($data); $this->validateMainContact($data); $this->validateDistributorBroker($data); $this->validateAccountingConsistency($data); - $this->validateInformationCompleteness($data, $payloadKeys); + $this->validateInformationCompleteness($data, $writableKeys); try { return $this->persistProcessor->process($data, $operation, $uriVariables, $context); @@ -126,15 +128,28 @@ final class ClientProcessor implements ProcessorInterface } /** - * RG-1.22 / RG-1.23 : si le payload porte isArchived, exige la permission - * archive (403), interdit toute autre modification (422) et pose/retire - * archivedAt. Retourne true si la requete est une requete d'archivage. + * RG-1.22 / RG-1.23 : si le payload bascule reellement isArchived, exige la + * permission archive (403), interdit toute autre modification (422) et + * pose/retire archivedAt. Retourne true si la requete est une requete + * d'archivage. * - * @param list $payloadKeys + * Le gating est restreint a la mise a jour d'un client existant ET au seul + * cas ou isArchived change vraiment : un POST (entite non encore geree par + * l'ORM) ou un PATCH « representation complete » renvoyant isArchived + * inchange ne doit declencher ni 403 ni 422 parasite. + * + * @param list $writableKeys cles ecrivables du payload (hors @* et champs inconnus) */ - private function guardArchive(Client $data, array $payloadKeys): bool + private function guardArchive(Client $data, array $writableKeys): bool { - if (!in_array(self::ARCHIVE_FIELD, $payloadKeys, true)) { + // POST / entite non geree : l'archivage est une action de mise a jour. + if (!$this->em->contains($data)) { + return false; + } + + // isArchived inchange par rapport a l'etat persiste : pas une requete + // d'archivage (cas du PATCH representation complete). + if (!$this->fieldChanged($data, 'isArchived', $data->isArchived())) { return false; } @@ -146,8 +161,8 @@ final class ClientProcessor implements ProcessorInterface )); } - // RG-1.22 : une requete d'archivage ne modifie aucun autre champ. - if ([] !== array_diff($payloadKeys, [self::ARCHIVE_FIELD])) { + // RG-1.22 : une requete d'archivage ne modifie aucun autre champ ecrivable. + if ([] !== array_diff($writableKeys, [self::ARCHIVE_FIELD])) { throw new UnprocessableEntityHttpException( 'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".', ); @@ -160,29 +175,88 @@ final class ClientProcessor implements ProcessorInterface } /** - * RG-1.28 : un champ comptable dans le payload exige accounting.manage, - * sinon 403 sur l'ensemble du payload (mode strict, pas de filtrage - * silencieux). Le message precise le premier champ fautif. - * - * @param list $payloadKeys + * RG-1.28 : la modification effective d'un champ comptable exige + * accounting.manage, sinon 403 sur l'ensemble du payload (mode strict, pas + * de filtrage silencieux). On ne gate que si un champ change reellement par + * rapport a l'etat persiste : un POST/PATCH renvoyant des champs comptables + * inchanges (ou null en creation) ne declenche pas de 403 parasite. Le + * message precise le premier champ fautif. */ - private function guardAccounting(array $payloadKeys): void + private function guardAccounting(Client $data): void { - $touched = array_values(array_intersect($payloadKeys, self::ACCOUNTING_FIELDS)); + $changed = $this->changedAccountingFields($data); - if ([] === $touched) { + if ([] === $changed) { return; } if (!$this->security->isGranted(self::PERM_ACCOUNTING_MANAGE)) { throw new AccessDeniedHttpException(sprintf( 'Le champ "%s" requiert la permission "%s".', - $touched[0], + $changed[0], self::PERM_ACCOUNTING_MANAGE, )); } } + /** + * Champs comptables dont la valeur courante differe de l'etat persiste. Les + * relations (tvaMode, paymentDelay, paymentType, bank) sont comparees par + * identite d'objet : l'identity map Doctrine renvoie la meme instance tant + * que la reference est inchangee. + * + * @return list + */ + private function changedAccountingFields(Client $data): array + { + $changed = []; + + foreach (self::ACCOUNTING_FIELDS as $field) { + $newValue = match ($field) { + 'siren' => $data->getSiren(), + 'accountNumber' => $data->getAccountNumber(), + 'tvaMode' => $data->getTvaMode(), + 'nTva' => $data->getNTva(), + 'paymentDelay' => $data->getPaymentDelay(), + 'paymentType' => $data->getPaymentType(), + 'bank' => $data->getBank(), + }; + + if ($this->fieldChanged($data, $field, $newValue)) { + $changed[] = $field; + } + } + + return $changed; + } + + /** + * Vrai si la valeur courante d'un champ differe de l'etat persiste. Pour une + * entite non geree (creation/POST), l'etat persiste est vide : toute valeur + * non-null est alors un changement. + */ + private function fieldChanged(Client $data, string $field, mixed $newValue): bool + { + $original = $this->originalData($data); + + return $newValue !== ($original[$field] ?? null); + } + + /** + * Snapshot des valeurs persistees de l'entite (telles que chargees, avant + * application du payload). Vide pour une entite non geree (POST). + * + * @return array + */ + private function originalData(Client $data): array + { + if (!$this->em->contains($data)) { + return []; + } + + return $this->em->getUnitOfWork()->getOriginalEntityData($data); + } + /** * Normalisation serveur (RG-1.18 a 1.21). Les setters non-nullables * (companyName, email, phonePrimary) ne sont touches que si une valeur est @@ -317,11 +391,32 @@ final class ClientProcessor implements ProcessorInterface && $user->hasBusinessRole(BusinessRoles::COMMERCIALE); } + /** + * Cles ecrivables effectivement presentes dans le payload : on retire les + * cles JSON-LD (@id, @context, @var...) et tout champ non rattache a un + * groupe d'ecriture connu. C'est la base du 422 d'archivage (RG-1.22) et du + * declenchement conditionnel de RG-1.04 — sans elles, un PATCH + * « representation complete » porteur de @id ferait croire a une + * modification multi-onglets. + * + * @return list + */ + private function writablePayloadKeys(): array + { + $writable = array_merge( + self::MAIN_FIELDS, + self::INFORMATION_FIELDS, + self::ACCOUNTING_FIELDS, + [self::ARCHIVE_FIELD], + ); + + return array_values(array_intersect($this->payloadKeys(), $writable)); + } + /** * Cles de premier niveau effectivement envoyees par le client (payload JSON - * brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies ; - * c'est ce qui permet le gating par onglet (RG-1.22 / RG-1.28) et le - * declenchement conditionnel de RG-1.04. + * brut), filtrage compris. Pour un PATCH merge-patch+json, ce sont les seuls + * champs modifies. * * @return list */ diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php index bbd6e52..f401375 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php @@ -11,6 +11,7 @@ use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\State\ProviderInterface; use App\Module\Commercial\Domain\Entity\Client; use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface; +use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -45,6 +46,7 @@ final class ClientProvider implements ProviderInterface #[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')] private readonly ClientRepositoryInterface $repository, private readonly Pagination $pagination, + private readonly EntityManagerInterface $em, ) {} public function provide(Operation $operation, array $uriVariables = [], array $context = []): Client|iterable|Paginator|null @@ -73,7 +75,7 @@ final class ClientProvider implements ProviderInterface // Echappatoire ?pagination=false : collection complete sans Paginator // (cf. convention ERP-72 — utile pour un