diff --git a/composer.json b/composer.json index eb9a778..b4bf311 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "nelmio/cors-bundle": "^2.6", "nyholm/psr7": "^1.8", "phpdocumentor/reflection-docblock": "^5.6|^6.0", + "phpoffice/phpspreadsheet": "^5.7", "phpstan/phpdoc-parser": "^2.3", "symfony/asset": "8.0.*", "symfony/console": "8.0.*", @@ -23,6 +24,7 @@ "symfony/expression-language": "8.0.*", "symfony/flex": "^2", "symfony/framework-bundle": "8.0.*", + "symfony/intl": "8.0.*", "symfony/mime": "8.0.*", "symfony/monolog-bundle": "^4.0", "symfony/property-access": "8.0.*", diff --git a/composer.lock b/composer.lock index d6dc421..4bf713f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d65a546151abb6b977fbf7f1c86d14fe", + "content-hash": "aada2e60fd7563f1498b5505b37e3f4b", "packages": [ { "name": "api-platform/doctrine-common", @@ -1160,6 +1160,85 @@ }, "time": "2026-03-17T15:23:21+00:00" }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, { "name": "composer/semver", "version": "3.4.4", @@ -2630,6 +2709,191 @@ ], "time": "2025-12-20T17:47:00+00:00" }, + { + "name": "maennchen/zipstream-php", + "version": "3.2.2", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.3" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.86", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^12.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2026-04-11T18:38:28+00:00" + }, + { + "name": "markbaker/complex", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Complex\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" + } + ], + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", + "keywords": [ + "complex", + "mathematics" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + }, + "time": "2022-12-06T16:21:08+00:00" + }, + { + "name": "markbaker/matrix", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matrix\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@demon-angel.eu" + } + ], + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", + "keywords": [ + "mathematics", + "matrix", + "vector" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -3052,6 +3316,115 @@ }, "time": "2026-01-06T21:53:42+00:00" }, + { + "name": "phpoffice/phpspreadsheet", + "version": "5.7.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8", + "reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8", + "shasum": "" + }, + "require": { + "composer/pcre": "^1||^2||^3", + "ext-ctype": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-filter": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "ext-zip": "*", + "ext-zlib": "*", + "maennchen/zipstream-php": "^2.1 || ^3.0", + "markbaker/complex": "^3.0", + "markbaker/matrix": "^3.0", + "php": "^8.1", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "dompdf/dompdf": "^2.0 || ^3.0", + "ext-intl": "*", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.5", + "mpdf/mpdf": "^8.1.1", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/phpstan": "^1.1 || ^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0", + "phpstan/phpstan-phpunit": "^1.0 || ^2.0", + "phpunit/phpunit": "^10.5", + "squizlabs/php_codesniffer": "^3.7", + "tecnickcom/tcpdf": "^6.5" + }, + "suggest": { + "dompdf/dompdf": "Option for rendering PDF with PDF Writer", + "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()", + "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", + "mpdf/mpdf": "Option for rendering PDF with PDF Writer", + "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maarten Balliauw", + "homepage": "https://blog.maartenballiauw.be" + }, + { + "name": "Mark Baker", + "homepage": "https://markbakeruk.net" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net" + }, + { + "name": "Erik Tilt" + }, + { + "name": "Adrien Crivelli" + }, + { + "name": "Owen Leibman" + } + ], + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "keywords": [ + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" + ], + "support": { + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.7.0" + }, + "time": "2026-04-20T02:42:17+00:00" + }, { "name": "phpstan/phpdoc-parser", "version": "2.3.2", @@ -3513,6 +3886,57 @@ }, "time": "2024-09-11T13:17:53+00:00" }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, { "name": "symfony/asset", "version": "v8.0.8", @@ -5172,6 +5596,95 @@ ], "time": "2026-03-31T21:14:05+00:00" }, + { + "name": "symfony/intl", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/intl.git", + "reference": "604a1dbbd67471e885e93274379cadd80dc33535" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/intl/zipball/604a1dbbd67471e885e93274379cadd80dc33535", + "reference": "604a1dbbd67471e885e93274379cadd80dc33535", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "conflict": { + "symfony/string": "<7.4" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Intl\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/Resources/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Eriksen Costa", + "email": "eriksen.costa@infranology.com.br" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides access to the localization data of the ICU library", + "homepage": "https://symfony.com", + "keywords": [ + "i18n", + "icu", + "internationalization", + "intl", + "l10n", + "localization" + ], + "support": { + "source": "https://github.com/symfony/intl/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, { "name": "symfony/mime", "version": "v8.0.8", @@ -8263,85 +8776,6 @@ ], "time": "2022-12-23T10:58:28+00:00" }, - { - "name": "composer/pcre", - "version": "3.3.2", - "source": { - "type": "git", - "url": "https://github.com/composer/pcre.git", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<1.11.10" - }, - "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" - }, - "type": "library", - "extra": { - "phpstan": { - "includes": [ - "extension.neon" - ] - }, - "branch-alias": { - "dev-main": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\Pcre\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - } - ], - "description": "PCRE wrapping library that offers type-safe preg_* replacements.", - "keywords": [ - "PCRE", - "preg", - "regex", - "regular expression" - ], - "support": { - "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2024-11-12T16:29:46+00:00" - }, { "name": "composer/xdebug-handler", "version": "3.0.5", 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/config/sidebar.php b/config/sidebar.php index 2d019df..c51494d 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -103,6 +103,13 @@ return [ 'label' => 'sidebar.commercial.section', 'icon' => 'mdi:account-arrow-left-outline', 'items' => [ + [ + 'label' => 'sidebar.commercial.clients', + 'to' => '/clients', + 'icon' => 'mdi:account-group-outline', + 'module' => 'commercial', + 'permission' => 'commercial.clients.view', + ], [ 'label' => 'sidebar.commercial.suppliers', 'to' => '/suppliers', diff --git a/docs/specs/M1-clients/spec-back.md b/docs/specs/M1-clients/spec-back.md index c8f5a0d..cd7359a 100644 --- a/docs/specs/M1-clients/spec-back.md +++ b/docs/specs/M1-clients/spec-back.md @@ -235,7 +235,9 @@ Le **formatage `XX XX XX XX XX`** est fait à l'affichage côté front (filter V ### 3.2 Migration Doctrine — SQL Postgres -Namespace : `App\Module\Commercial\Infrastructure\Doctrine\Migrations` (modulaire, post-init). Fichier : `Version20260601000000.php` (à dater par le dev). +Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrations/Version20260601000000.php` (à dater par le dev). + +> **Décision 29/05/2026 (vérifiée empiriquement en dev)** : cette migration crée un **schéma avec FK cross-module** (`user`, `category`, `site`) → elle a la même dépendance d'ordre que les migrations d'init. Le namespace modulaire `App\Module\Commercial\…` casse `make db-reset` : Doctrine Migrations 3.x trie par **FQCN alphabétique** (`App\…` < `DoctrineMigrations\…`), donc la migration client tournerait AVANT `user`/`category`/`site` et ses FK échoueraient. Elle relève donc de l'**exception racine** de la règle ABSOLUE n°11 (même choix que la migration cross-module ERP-67). Le namespace modulaire reste réservé aux évolutions post-schéma (ajout de colonnes/index). La correction long-terme (MigrationsComparator custom, tri par timestamp) est un ticket archi dédié, hors scope M1. ```sql -- ===================================================================== @@ -475,7 +477,14 @@ INSERT INTO category_type (code, label, position) VALUES ('AUTRE', 'Autre', 99); ``` -> **Note** : le CRUD admin de `CategoryType` reste HP (cf. M0). Le seed est fait via migration ou fixture déclenchée à chaque `make db-reset`. +> **Note** : le CRUD admin de `CategoryType` reste HP (cf. M0). +> +> **Seed en DEUX endroits (décision 29/05, vérifiée empiriquement)** : le `make db-reset` lance les fixtures, dont le purger Doctrine **vide `category_type`** (entité M0 mappée) avant `load()` → un seed posé uniquement en migration disparaît en dev/test. Donc : +> 1. **Migration** (`ON CONFLICT (code) DO NOTHING`) → sert en **prod** (pas de fixtures). +> 2. **Fixture Commercial idempotente** (ex. `CommercialReferentialFixtures`) re-seedant les 4 types → survit au `db-reset`, satisfait le critère « 4 types présents après db-reset ». +> +> ⚠ **À venir en ERP-54** : `tva_mode` / `payment_delay` / `payment_type` / `bank` ne sont pas encore des entités mappées au M1.0 → le purger ne les touche pas, leur seed migration survit. **Dès qu'ERP-54 crée leurs entités, ils seront purgés au db-reset** → il faudra les ajouter à la même fixture référentielle. +> 🔗 **Coordination ERP-68** : ERP-53 pose la fixture référentielle minimale (4 category_types). ERP-68 l'**étend** (clients de démo, ~12-15) sans la dupliquer. ### 3.4 Entité `Client` — squelette @@ -971,7 +980,7 @@ Cf. § 2.6. Pattern Shared standard. - [ ] **Compta POST création** : Compta → 403 (pas de `manage` global) - [ ] **PATCH mix groupes** : Bureau envoie payload avec `companyName` (write:main) + `siren` (write:accounting) → **403 sur tout le payload** (strict, RG-1.28) - [ ] **Audit** : POST + PATCH + archive → audit_log avec entity_type='Client', `changes` correct ; **iban/bic présents dans le diff** (pas d'AuditIgnore, cf. § 6.1) -- [ ] **Migration** : `make db-reset` → schéma OK, seed des 4 référentiels + CategoryType (DISTRIBUTEUR/COURTIER/SECTEUR/AUTRE) présent ; index partiel unique `uq_client_company_name_active` présent (un seul — cf. Q4) +- [ ] **Migration** : `make db-reset` → schéma OK ; migration en racine `migrations/` (namespace `DoctrineMigrations`, ordre garanti) ; 4 référentiels comptables seedés ; **4 CategoryType présents APRÈS db-reset** (via fixture idempotente, car le purger vide category_type) ; index partiel unique `uq_client_company_name_active` présent (un seul — cf. Q4) ### 8.2 Cas à couvrir (front — Vitest) diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 77849a7..af8d223 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -23,6 +23,7 @@ }, "commercial": { "section": "Commercial", + "clients": "Répertoire clients", "suppliers": "Répertoire fournisseurs" }, "core": { diff --git a/frontend/tests/e2e/_fixtures/personas.ts b/frontend/tests/e2e/_fixtures/personas.ts index 43d4bba..c610608 100644 --- a/frontend/tests/e2e/_fixtures/personas.ts +++ b/frontend/tests/e2e/_fixtures/personas.ts @@ -65,6 +65,16 @@ export const personas: Record = { 'sites.bypass_scope', 'catalog.categories.view', 'catalog.categories.manage', + // Commercial — Repertoire clients (M1). Mappe ici sur le persona + // "tout" en attendant les vrais roles metier (bureau/compta/ + // commerciale/usine) seedes par ERP-74. Pas de nouveau persona + // (regle ABSOLUE n°7). commercial.clients.view n'ajoute pas de lien + // dans la section Administration, donc expectedAdminLinks reste inchange. + 'commercial.clients.view', + 'commercial.clients.manage', + 'commercial.clients.accounting.view', + 'commercial.clients.accounting.manage', + 'commercial.clients.archive', ], expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'], }, 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/migrations/Version20260601000000.php b/migrations/Version20260601000000.php new file mode 100644 index 0000000..75a0934 --- /dev/null +++ b/migrations/Version20260601000000.php @@ -0,0 +1,554 @@ +createAccountingReferentials(); + $this->createClientTable(); + $this->createClientCategory(); + $this->createClientContact(); + $this->createClientAddress(); + $this->createClientAddressJoinTables(); + $this->createClientRib(); + $this->seedCategoryTypes(); + } + + public function down(Schema $schema): void + { + // Ordre inverse des dependances FK : on supprime d'abord les jointures + // et sous-collections, puis client, puis les referentiels. + $this->addSql('DROP TABLE client_address_category'); + $this->addSql('DROP TABLE client_address_contact'); + $this->addSql('DROP TABLE client_address_site'); + $this->addSql('DROP TABLE client_rib'); + $this->addSql('DROP TABLE client_address'); + $this->addSql('DROP TABLE client_contact'); + $this->addSql('DROP TABLE client_category'); + $this->addSql('DROP TABLE client'); + $this->addSql('DROP TABLE bank'); + $this->addSql('DROP TABLE payment_type'); + $this->addSql('DROP TABLE payment_delay'); + $this->addSql('DROP TABLE tva_mode'); + + // Retire uniquement les 4 types seedes par cette migration ET restes + // orphelins (aucune `category` ne les reference). Sans la clause + // NOT EXISTS, le DELETE casse sur la FK RESTRICT category.category_type_id + // des qu'une categorie pointe sur l'un d'eux. Symetrique du + // `ON CONFLICT (code) DO NOTHING` du up() : on ne defait que ce qu'on a + // reellement cree et qui n'est pas reutilise. + $this->addSql(<<<'SQL' + DELETE FROM category_type + WHERE code IN ('DISTRIBUTEUR', 'COURTIER', 'SECTEUR', 'AUTRE') + AND NOT EXISTS ( + SELECT 1 FROM category c WHERE c.category_type_id = category_type.id + ) + SQL); + } + + // ================================================================= + // Referentiels comptables (4 tables statiques, memes colonnes) + // ================================================================= + + private function createAccountingReferentials(): void + { + $referentials = [ + 'tva_mode' => 'Referentiel des modes de TVA appliques a un client (France, Export, Intracom).', + 'payment_delay' => 'Referentiel des delais de reglement (15 jours, 30 jours, a reception).', + 'payment_type' => 'Referentiel des types de reglement (virement, LCR, cheque, non soumise).', + 'bank' => 'Referentiel des banques selectionnables pour le reglement par virement.', + ]; + + foreach ($referentials as $table => $tableComment) { + $this->addSql(sprintf(<<<'SQL' + CREATE TABLE %s ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + code VARCHAR(30) NOT NULL, + label VARCHAR(120) NOT NULL, + position INT DEFAULT 0 NOT NULL, + PRIMARY KEY (id) + ) + SQL, $table)); + $this->addSql(sprintf('CREATE UNIQUE INDEX uq_%s_code ON %s (code)', $table, $table)); + + $this->comment($table, '_table', $tableComment); + $this->comment($table, 'id', 'Identifiant interne auto-incremente.'); + $this->comment($table, 'code', 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.'); + $this->comment($table, 'label', 'Libelle affichable (FR, ≤ 120 caracteres).'); + $this->comment($table, 'position', 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).'); + } + + // Seed initial (cf. spec § 3.2). Tables fraichement creees donc vides : + // INSERT direct sans ON CONFLICT. + $this->addSql(<<<'SQL' + INSERT INTO tva_mode (code, label, position) VALUES + ('FRANCE_VENTES', 'France (ventes)', 10), + ('EXPORT_VENTES', 'Export (ventes)', 20), + ('INTRACOM_VENTES', 'Intracom (ventes)', 30) + SQL); + $this->addSql(<<<'SQL' + INSERT INTO payment_delay (code, label, position) VALUES + ('J15', '15 jours', 10), + ('J30', '30 jours', 20), + ('A_RECEPTION', 'À réception', 30) + SQL); + $this->addSql(<<<'SQL' + INSERT INTO payment_type (code, label, position) VALUES + ('VIREMENT', 'Virement', 10), + ('LCR', 'LCR', 20), + ('NON_SOUMISE', 'Non soumise', 30), + ('CHEQUE', 'Chèque', 40) + SQL); + $this->addSql(<<<'SQL' + INSERT INTO bank (code, label, position) VALUES + ('SG', 'Société Générale', 10), + ('CIC', 'CIC', 20), + ('CA', 'Crédit Agricole', 30) + SQL); + } + + // ================================================================= + // Table principale `client` + // ================================================================= + + private function createClientTable(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE client ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + company_name VARCHAR(180) NOT NULL, + first_name VARCHAR(120) DEFAULT NULL, + last_name VARCHAR(120) DEFAULT NULL, + phone_primary VARCHAR(20) NOT NULL, + phone_secondary VARCHAR(20) DEFAULT NULL, + email VARCHAR(180) NOT NULL, + distributor_id INT DEFAULT NULL, + broker_id INT DEFAULT NULL, + triage_service BOOLEAN DEFAULT FALSE NOT NULL, + description TEXT DEFAULT NULL, + competitors VARCHAR(255) DEFAULT NULL, + founded_at DATE DEFAULT NULL, + employees_count INT DEFAULT NULL, + revenue_amount NUMERIC(15, 2) DEFAULT NULL, + director_name VARCHAR(120) DEFAULT NULL, + profit_amount NUMERIC(15, 2) DEFAULT NULL, + siren VARCHAR(20) DEFAULT NULL, + account_number VARCHAR(40) DEFAULT NULL, + tva_mode_id INT DEFAULT NULL, + n_tva VARCHAR(40) DEFAULT NULL, + payment_delay_id INT DEFAULT NULL, + payment_type_id INT DEFAULT NULL, + bank_id INT DEFAULT NULL, + is_archived BOOLEAN DEFAULT FALSE NOT NULL, + archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT DEFAULT NULL, + updated_by INT DEFAULT NULL, + PRIMARY KEY (id), + CONSTRAINT chk_client_distrib_or_broker + CHECK (NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL)), + CONSTRAINT fk_client_distributor + FOREIGN KEY (distributor_id) REFERENCES client (id) ON DELETE SET NULL, + CONSTRAINT fk_client_broker + FOREIGN KEY (broker_id) REFERENCES client (id) ON DELETE SET NULL, + CONSTRAINT fk_client_tva_mode + FOREIGN KEY (tva_mode_id) REFERENCES tva_mode (id) ON DELETE RESTRICT, + CONSTRAINT fk_client_payment_delay + FOREIGN KEY (payment_delay_id) REFERENCES payment_delay (id) ON DELETE RESTRICT, + CONSTRAINT fk_client_payment_type + FOREIGN KEY (payment_type_id) REFERENCES payment_type (id) ON DELETE RESTRICT, + CONSTRAINT fk_client_bank + FOREIGN KEY (bank_id) REFERENCES bank (id) ON DELETE RESTRICT, + CONSTRAINT fk_client_created_by + FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, + CONSTRAINT fk_client_updated_by + FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL + ) + SQL); + + $this->addSql('CREATE INDEX idx_client_is_archived ON client (is_archived)'); + $this->addSql('CREATE INDEX idx_client_deleted_at ON client (deleted_at)'); + $this->addSql('CREATE INDEX idx_client_distributor_id ON client (distributor_id)'); + $this->addSql('CREATE INDEX idx_client_broker_id ON client (broker_id)'); + $this->addSql('CREATE INDEX idx_client_created_by ON client (created_by)'); + $this->addSql('CREATE INDEX idx_client_updated_by ON client (updated_by)'); + + // Index sur les FK des referentiels comptables — coherence avec les autres + // FK deja indexees ci-dessus (Postgres n'indexe pas automatiquement les + // colonnes portant une FOREIGN KEY). + $this->addSql('CREATE INDEX idx_client_tva_mode_id ON client (tva_mode_id)'); + $this->addSql('CREATE INDEX idx_client_payment_delay_id ON client (payment_delay_id)'); + $this->addSql('CREATE INDEX idx_client_payment_type_id ON client (payment_type_id)'); + $this->addSql('CREATE INDEX idx_client_bank_id ON client (bank_id)'); + + // Unicite metier partielle (Q4) : nom de societe insensible a la casse, + // parmi les non-archives ET non soft-deletes uniquement. Pas d'index + // unique sur siren ni email. + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX uq_client_company_name_active + ON client (LOWER(company_name)) + WHERE is_archived = FALSE AND deleted_at IS NULL + SQL); + + $this->comment('client', '_table', 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).'); + $this->comment('client', 'id', 'Identifiant interne auto-incremente.'); + $this->comment('client', 'company_name', 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).'); + $this->comment('client', 'first_name', 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).'); + $this->comment('client', 'last_name', 'Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).'); + $this->comment('client', 'phone_primary', 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.'); + $this->comment('client', 'phone_secondary', 'Telephone secondaire optionnel — chiffres uniquement (RG-1.20).'); + $this->comment('client', 'email', 'Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).'); + $this->comment('client', 'distributor_id', 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.'); + $this->comment('client', 'broker_id', 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.'); + $this->comment('client', 'triage_service', 'Drapeau service triage active pour le client. Faux par defaut.'); + $this->comment('client', 'description', 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.'); + $this->comment('client', 'competitors', 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).'); + $this->comment('client', 'founded_at', 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).'); + $this->comment('client', 'employees_count', 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).'); + $this->comment('client', 'revenue_amount', 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).'); + $this->comment('client', 'director_name', 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).'); + $this->comment('client', 'profit_amount', 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).'); + $this->comment('client', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).'); + $this->comment('client', 'account_number', 'Onglet Comptabilite : numero de compte comptable du client.'); + $this->comment('client', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.'); + $this->comment('client', 'n_tva', 'Onglet Comptabilite : numero de TVA intracommunautaire.'); + $this->comment('client', 'payment_delay_id', 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id, ON DELETE RESTRICT.'); + $this->comment('client', 'payment_type_id', 'Onglet Comptabilite : type de reglement — FK -> payment_type.id, ON DELETE RESTRICT. Code LCR impose >= 1 RIB (RG-1.13), VIREMENT impose une banque (RG-1.12).'); + $this->comment('client', 'bank_id', 'Onglet Comptabilite : banque — FK -> bank.id, ON DELETE RESTRICT. Obligatoire si payment_type = VIREMENT (RG-1.12).'); + $this->comment('client', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.clients.archive (RG-1.22/23).'); + $this->comment('client', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration (RG-1.22/23).'); + $this->comment('client', 'deleted_at', 'Horodatage du soft-delete technique (HP M2) — non expose par l API au M1. Null = ligne active.'); + $this->addTimestampableBlamableComments('client'); + } + + // ================================================================= + // M2M client <-> category + // ================================================================= + + private function createClientCategory(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE client_category ( + client_id INT NOT NULL, + category_id INT NOT NULL, + PRIMARY KEY (client_id, category_id), + CONSTRAINT fk_client_category_client + FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE, + CONSTRAINT fk_client_category_category + FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT + ) + SQL); + $this->addSql('CREATE INDEX idx_client_category_category ON client_category (category_id)'); + + $this->comment('client_category', '_table', 'Jointure M2M client <-> category (Catalog) — categories metier du client (au moins une obligatoire).'); + $this->comment('client_category', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client porteur de la categorie.'); + $this->comment('client_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie rattachee au client.'); + } + + // ================================================================= + // Sous-collection : contacts (1:n) + // ================================================================= + + private function createClientContact(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE client_contact ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + client_id INT NOT NULL, + first_name VARCHAR(120) DEFAULT NULL, + last_name VARCHAR(120) DEFAULT NULL, + job_title VARCHAR(120) DEFAULT NULL, + phone_primary VARCHAR(20) DEFAULT NULL, + phone_secondary VARCHAR(20) DEFAULT NULL, + email VARCHAR(180) DEFAULT NULL, + position INT DEFAULT 0 NOT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT DEFAULT NULL, + updated_by INT DEFAULT NULL, + PRIMARY KEY (id), + CONSTRAINT chk_client_contact_name + CHECK (first_name IS NOT NULL OR last_name IS NOT NULL), + CONSTRAINT fk_client_contact_client + FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE, + CONSTRAINT fk_client_contact_created_by + FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, + CONSTRAINT fk_client_contact_updated_by + FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL + ) + SQL); + $this->addSql('CREATE INDEX idx_client_contact_client ON client_contact (client_id)'); + + $this->comment('client_contact', '_table', 'Contacts d un client (1:n) — au moins firstName OU lastName par contact (RG-1.05).'); + $this->comment('client_contact', 'id', 'Identifiant interne auto-incremente.'); + $this->comment('client_contact', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire du contact.'); + $this->comment('client_contact', 'first_name', 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).'); + $this->comment('client_contact', 'last_name', 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).'); + $this->comment('client_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).'); + $this->comment('client_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (RG-1.20).'); + $this->comment('client_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (RG-1.20).'); + $this->comment('client_contact', 'email', 'Email du contact (lowercase serveur, RG-1.21).'); + $this->comment('client_contact', 'position', 'Ordre d affichage du contact dans la liste du client (croissant).'); + $this->addTimestampableBlamableComments('client_contact'); + } + + // ================================================================= + // Sous-collection : adresses (1:n) + // ================================================================= + + private function createClientAddress(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE client_address ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + client_id INT NOT NULL, + is_prospect BOOLEAN DEFAULT FALSE NOT NULL, + is_delivery BOOLEAN DEFAULT FALSE NOT NULL, + is_billing BOOLEAN DEFAULT FALSE NOT NULL, + country VARCHAR(80) DEFAULT 'France' NOT NULL, + postal_code VARCHAR(20) NOT NULL, + city VARCHAR(120) NOT NULL, + street VARCHAR(255) NOT NULL, + street_complement VARCHAR(255) DEFAULT NULL, + billing_email VARCHAR(180) DEFAULT NULL, + position INT DEFAULT 0 NOT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT DEFAULT NULL, + updated_by INT DEFAULT NULL, + PRIMARY KEY (id), + CONSTRAINT chk_client_address_prospect_exclusive + CHECK (NOT (is_prospect = TRUE AND (is_delivery = TRUE OR is_billing = TRUE))), + CONSTRAINT chk_client_address_billing_email + CHECK ((is_billing = FALSE AND billing_email IS NULL) + OR (is_billing = TRUE AND billing_email IS NOT NULL)), + CONSTRAINT fk_client_address_client + FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE, + CONSTRAINT fk_client_address_created_by + FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, + CONSTRAINT fk_client_address_updated_by + FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL + ) + SQL); + $this->addSql('CREATE INDEX idx_client_address_client ON client_address (client_id)'); + + $this->comment('client_address', '_table', 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).'); + $this->comment('client_address', 'id', 'Identifiant interne auto-incremente.'); + $this->comment('client_address', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.'); + $this->comment('client_address', 'is_prospect', 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.'); + $this->comment('client_address', 'is_delivery', 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.'); + $this->comment('client_address', 'is_billing', 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.'); + $this->comment('client_address', 'country', 'Pays de l adresse — defaut France.'); + $this->comment('client_address', 'postal_code', 'Code postal (4-5 chiffres attendus, RG-1.09).'); + $this->comment('client_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).'); + $this->comment('client_address', 'street', 'Numero et voie de l adresse.'); + $this->comment('client_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.'); + $this->comment('client_address', 'billing_email', 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).'); + $this->comment('client_address', 'position', 'Ordre d affichage de l adresse dans la liste du client (croissant).'); + $this->addTimestampableBlamableComments('client_address'); + } + + // ================================================================= + // Jointures de client_address (M2M) + // ================================================================= + + private function createClientAddressJoinTables(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE client_address_site ( + client_address_id INT NOT NULL, + site_id INT NOT NULL, + PRIMARY KEY (client_address_id, site_id), + CONSTRAINT fk_client_address_site_address + FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE, + CONSTRAINT fk_client_address_site_site + FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT + ) + SQL); + $this->comment('client_address_site', '_table', 'Jointure M2M client_address <-> site (Sites) — sites desservis par l adresse (>= 1 obligatoire, RG-1.10).'); + $this->comment('client_address_site', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.'); + $this->comment('client_address_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.'); + + $this->addSql(<<<'SQL' + CREATE TABLE client_address_contact ( + client_address_id INT NOT NULL, + client_contact_id INT NOT NULL, + PRIMARY KEY (client_address_id, client_contact_id), + CONSTRAINT fk_client_address_contact_address + FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE, + CONSTRAINT fk_client_address_contact_contact + FOREIGN KEY (client_contact_id) REFERENCES client_contact (id) ON DELETE CASCADE + ) + SQL); + $this->comment('client_address_contact', '_table', 'Jointure M2M client_address <-> client_contact — contacts associes a une adresse.'); + $this->comment('client_address_contact', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.'); + $this->comment('client_address_contact', 'client_contact_id', 'FK -> client_contact.id, ON DELETE CASCADE — contact associe a l adresse.'); + + $this->addSql(<<<'SQL' + CREATE TABLE client_address_category ( + client_address_id INT NOT NULL, + category_id INT NOT NULL, + PRIMARY KEY (client_address_id, category_id), + CONSTRAINT fk_client_address_category_address + FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE, + CONSTRAINT fk_client_address_category_category + FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT + ) + SQL); + $this->comment('client_address_category', '_table', 'Jointure M2M client_address <-> category — categories d adresse (types SECTEUR/AUTRE uniquement, RG-1.29).'); + $this->comment('client_address_category', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.'); + $this->comment('client_address_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse (type SECTEUR ou AUTRE, RG-1.29).'); + } + + // ================================================================= + // Sous-collection : RIB (1:n) + // ================================================================= + + private function createClientRib(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE client_rib ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + client_id INT NOT NULL, + label VARCHAR(120) NOT NULL, + bic VARCHAR(20) NOT NULL, + iban VARCHAR(34) NOT NULL, + position INT DEFAULT 0 NOT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT DEFAULT NULL, + updated_by INT DEFAULT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_client_rib_client + FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE, + CONSTRAINT fk_client_rib_created_by + FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, + CONSTRAINT fk_client_rib_updated_by + FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL + ) + SQL); + $this->addSql('CREATE INDEX idx_client_rib_client ON client_rib (client_id)'); + + $this->comment('client_rib', '_table', 'Coordonnees bancaires d un client (1:n) — >= 1 RIB obligatoire si payment_type = LCR (RG-1.13). Tous les champs audites (pas d AuditIgnore).'); + $this->comment('client_rib', 'id', 'Identifiant interne auto-incremente.'); + $this->comment('client_rib', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire du RIB.'); + $this->comment('client_rib', 'label', 'Libelle du RIB (ex: compte principal).'); + $this->comment('client_rib', 'bic', 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).'); + $this->comment('client_rib', 'iban', 'IBAN du compte (≤ 34 caracteres).'); + $this->comment('client_rib', 'position', 'Ordre d affichage du RIB dans la liste du client (croissant).'); + $this->addTimestampableBlamableComments('client_rib'); + } + + // ================================================================= + // Seed extension category_type (M0) + // ================================================================= + + private function seedCategoryTypes(): void + { + // Idempotent : la table category_type peut deja porter des donnees en + // prod. ON CONFLICT (code) s appuie sur l index unique uq_category_type_code. + // NB : la table M0 n a pas de colonne `position` (id/code/label seulement), + // contrairement au pseudo-SQL de la spec § 3.3. + $this->addSql(<<<'SQL' + INSERT INTO category_type (code, label) VALUES + ('DISTRIBUTEUR', 'Distributeur'), + ('COURTIER', 'Courtier'), + ('SECTEUR', 'Secteur'), + ('AUTRE', 'Autre') + ON CONFLICT (code) DO NOTHING + SQL); + } + + // ================================================================= + // Helpers + // ================================================================= + + /** + * Pose les 4 commentaires standardises Timestampable/Blamable sur une table, + * en reutilisant le catalogue partage (source unique, cf. ERP-67). + */ + private function addTimestampableBlamableComments(string $table): void + { + foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) { + $this->comment($table, $column, $description); + } + } + + /** + * Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou + * `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour eviter + * tout echappement d apostrophe. + */ + private function comment(string $table, string $column, string $description): void + { + $quotedTable = '"'.str_replace('"', '""', $table).'"'; + + if ('_table' === $column) { + $this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description)); + + return; + } + + $this->addSql(sprintf( + 'COMMENT ON COLUMN %s.%s IS $_$%s$_$', + $quotedTable, + '"'.str_replace('"', '""', $column).'"', + $description, + )); + } +} diff --git a/src/Module/Catalog/Domain/Entity/Category.php b/src/Module/Catalog/Domain/Entity/Category.php index 6bbb52d..5040e02 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 @@ -152,6 +153,16 @@ class Category implements TimestampableInterface, BlamableInterface 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/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(); + } +} 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/CommercialModule.php b/src/Module/Commercial/CommercialModule.php index 0be29ca..f4fac9c 100644 --- a/src/Module/Commercial/CommercialModule.php +++ b/src/Module/Commercial/CommercialModule.php @@ -9,4 +9,36 @@ final class CommercialModule public const string ID = 'commercial'; public const string LABEL = 'Commercial'; public const bool REQUIRED = false; + + /** + * Liste declarative des permissions RBAC exposees par le module Commercial. + * + * Consommee par la commande `app:sync-permissions` (SyncPermissionsCommand) + * qui se charge d'upserter ces entrees dans la table `permission`, de + * reactiver les codes precedemment marques orphelins et de marquer comme + * orphelins ceux qui ont disparu du code source. + * + * La cle `module` est auto-injectee par le sync command a partir de + * `self::ID`, il est donc inutile de la repeter dans chaque entree. + * + * Convention de nommage des codes : `module.resource[.sub].action` en + * snake_case, le prefixe module devant correspondre exactement a + * `self::ID` (verifie par la commande de synchronisation). + * + * Granularite alignee sur Core/Catalog (view + manage), plus deux + * permissions dediees a l'onglet Comptabilite et a l'archivage + * (cf. spec-back M1 § 2.7). + * + * @return array + */ + public static function permissions(): array + { + return [ + ['code' => 'commercial.clients.view', 'label' => 'Voir les clients'], + ['code' => 'commercial.clients.manage', 'label' => 'Créer / modifier les clients (hors onglet Comptabilité)'], + ['code' => 'commercial.clients.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un client'], + ['code' => 'commercial.clients.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un client'], + ['code' => 'commercial.clients.archive', 'label' => 'Archiver / restaurer un client'], + ]; + } } diff --git a/src/Module/Commercial/Domain/Entity/Bank.php b/src/Module/Commercial/Domain/Entity/Bank.php new file mode 100644 index 0000000..e690504 --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/Bank.php @@ -0,0 +1,105 @@ + 405. Pas de + * Timestampable/Blamable (referentiel statique whiteliste dans + * EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe + * `client:read:accounting` permet l'embarquement dans la reponse Client. + */ +#[ApiResource( + operations: [ + new GetCollection( + security: "is_granted('commercial.clients.view')", + normalizationContext: ['groups' => ['bank:read']], + // Tri par defaut spec M1 § 4.7 : position ASC puis label ASC. + order: ['position' => 'ASC', 'label' => 'ASC'], + // ERP-72 : pagination serveur + toggle ?pagination=false (cf. TvaMode). + paginationClientEnabled: true, + ), + new Get( + security: "is_granted('commercial.clients.view')", + normalizationContext: ['groups' => ['bank:read']], + ), + ], + security: "is_granted('commercial.clients.view')", +)] +#[ORM\Entity(repositoryClass: DoctrineBankRepository::class)] +#[ORM\Table(name: 'bank')] +#[ORM\UniqueConstraint(name: 'uq_bank_code', columns: ['code'])] +class Bank +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['bank:read', 'client:read:accounting'])] + private ?int $id = null; + + #[ORM\Column(length: 30)] + #[Groups(['bank:read', 'client:read:accounting'])] + private ?string $code = null; + + #[ORM\Column(length: 120)] + #[Groups(['bank:read', 'client:read:accounting'])] + private ?string $label = null; + + #[ORM\Column(options: ['default' => 0])] + #[Groups(['bank:read'])] + private int $position = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): static + { + $this->code = $code; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): static + { + $this->label = $label; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } +} diff --git a/src/Module/Commercial/Domain/Entity/Client.php b/src/Module/Commercial/Domain/Entity/Client.php new file mode 100644 index 0000000..6240975 --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/Client.php @@ -0,0 +1,711 @@ + ['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 +// unique partiel uq_client_company_name_active reste possede par la migration : +// Doctrine ORM ne sait pas exprimer un index fonctionnel (LOWER) + partiel +// (WHERE) via attribut. Pas de #[ORM\UniqueConstraint] (decision Q4). +#[ORM\Index(name: 'idx_client_is_archived', columns: ['is_archived'])] +#[ORM\Index(name: 'idx_client_deleted_at', columns: ['deleted_at'])] +#[ORM\Index(name: 'idx_client_distributor_id', columns: ['distributor_id'])] +#[ORM\Index(name: 'idx_client_broker_id', columns: ['broker_id'])] +#[ORM\Index(name: 'idx_client_created_by', columns: ['created_by'])] +#[ORM\Index(name: 'idx_client_updated_by', columns: ['updated_by'])] +#[Auditable] +class Client implements TimestampableInterface, BlamableInterface +{ + use TimestampableBlamableTrait; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['client:read'])] + private ?int $id = null; + + // === Formulaire principal === + #[ORM\Column(length: 180)] + #[Assert\NotBlank(message: 'Le nom de l\'entreprise est obligatoire.', normalizer: 'trim')] + #[Assert\Length(min: 2, max: 180, normalizer: 'trim')] + #[Groups(['client:read', 'client:write:main'])] + private ?string $companyName = null; + + // RG-1.01 : firstName OU lastName obligatoire (validation au futur Processor). + #[ORM\Column(length: 120, nullable: true)] + #[Assert\Length(max: 120, normalizer: 'trim')] + #[Groups(['client:read', 'client:write:main'])] + private ?string $firstName = null; + + #[ORM\Column(length: 120, nullable: true)] + #[Assert\Length(max: 120, normalizer: 'trim')] + #[Groups(['client:read', 'client:write:main'])] + private ?string $lastName = null; + + #[ORM\Column(length: 20)] + #[Assert\NotBlank] + #[Groups(['client:read', 'client:write:main'])] + private ?string $phonePrimary = null; + + #[ORM\Column(length: 20, nullable: true)] + #[Groups(['client:read', 'client:write:main'])] + private ?string $phoneSecondary = null; + + #[ORM\Column(length: 180)] + #[Assert\NotBlank] + #[Assert\Email] + #[Groups(['client:read', 'client:write:main'])] + private ?string $email = null; + + // RG-1.03 : distributor / broker auto-references mutuellement exclusives + // (CHECK chk_client_distrib_or_broker en base). + #[ORM\ManyToOne(targetEntity: self::class)] + #[ORM\JoinColumn(name: 'distributor_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + #[Groups(['client:read', 'client:write:main'])] + private ?Client $distributor = null; + + #[ORM\ManyToOne(targetEntity: self::class)] + #[ORM\JoinColumn(name: 'broker_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + #[Groups(['client:read', 'client:write:main'])] + private ?Client $broker = null; + + #[ORM\Column(name: 'triage_service', options: ['default' => false])] + #[Groups(['client:read', 'client:write:main'])] + private bool $triageService = false; + + // RG : au moins une categorie (Count min 1). M2M vers Category via le contrat + // CategoryInterface (resolve_target_entities -> Category). + /** @var Collection */ + #[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 === + // 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: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 */ + #[Groups(['client:item:read'])] + 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 */ + #[Groups(['client:item:read'])] + 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 */ + #[Groups(['client:item:read'])] + 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; + } + + // 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; + } + + 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..ca2eac8 --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/ClientAddress.php @@ -0,0 +1,379 @@ + ['client_address:read']], + ), + new Post( + uriTemplate: '/clients/{clientId}/addresses', + uriVariables: [ + 'clientId' => new Link(fromClass: Client::class, toProperty: 'client'), + ], + security: "is_granted('commercial.clients.manage')", + normalizationContext: ['groups' => ['client_address:read']], + denormalizationContext: ['groups' => ['client_address:write']], + processor: ClientAddressProcessor::class, + ), + new Patch( + security: "is_granted('commercial.clients.manage')", + normalizationContext: ['groups' => ['client_address:read']], + denormalizationContext: ['groups' => ['client_address:write']], + processor: ClientAddressProcessor::class, + ), + new Delete( + security: "is_granted('commercial.clients.manage')", + processor: ClientAddressProcessor::class, + ), + ], +)] +#[ORM\Entity(repositoryClass: DoctrineClientAddressRepository::class)] +#[ORM\Table(name: 'client_address')] +#[ORM\Index(name: 'idx_client_address_client', columns: ['client_id'])] +#[Auditable] +class ClientAddress implements TimestampableInterface, BlamableInterface +{ + use TimestampableBlamableTrait; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['client_address:read'])] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'addresses')] + #[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] + private ?Client $client = null; + + #[ORM\Column(name: 'is_prospect', options: ['default' => false])] + #[Groups(['client_address:read', 'client_address:write'])] + private bool $isProspect = false; + + #[ORM\Column(name: 'is_delivery', options: ['default' => false])] + #[Groups(['client_address:read', 'client_address:write'])] + private bool $isDelivery = false; + + #[ORM\Column(name: 'is_billing', options: ['default' => false])] + #[Groups(['client_address:read', 'client_address:write'])] + private bool $isBilling = false; + + #[ORM\Column(length: 80, options: ['default' => 'France'])] + #[Groups(['client_address:read', 'client_address:write'])] + private string $country = 'France'; + + // RG-1.09 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur). + #[ORM\Column(length: 20)] + #[Assert\NotBlank] + #[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')] + #[Groups(['client_address:read', 'client_address:write'])] + private ?string $postalCode = null; + + #[ORM\Column(length: 120)] + #[Assert\NotBlank] + #[Groups(['client_address:read', 'client_address:write'])] + private ?string $city = null; + + #[ORM\Column(length: 255)] + #[Assert\NotBlank] + #[Groups(['client_address:read', 'client_address:write'])] + private ?string $street = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['client_address:read', 'client_address:write'])] + private ?string $streetComplement = null; + + // RG-1.11 : obligatoire ssi isBilling (CHECK BDD + futur Processor). + #[ORM\Column(length: 180, nullable: true)] + #[Assert\Email] + #[Groups(['client_address:read', 'client_address:write'])] + private ?string $billingEmail = null; + + #[ORM\Column(options: ['default' => 0])] + #[Groups(['client_address:read', 'client_address:write'])] + private int $position = 0; + + // RG-1.10 : au moins un site rattache a chaque adresse. + /** @var Collection */ + #[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..06565e2 --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/ClientContact.php @@ -0,0 +1,222 @@ + ['client_contact:read']], + ), + new Post( + uriTemplate: '/clients/{clientId}/contacts', + uriVariables: [ + 'clientId' => new Link(fromClass: Client::class, toProperty: 'client'), + ], + security: "is_granted('commercial.clients.manage')", + normalizationContext: ['groups' => ['client_contact:read']], + denormalizationContext: ['groups' => ['client_contact:write']], + processor: ClientContactProcessor::class, + ), + new Patch( + security: "is_granted('commercial.clients.manage')", + normalizationContext: ['groups' => ['client_contact:read']], + denormalizationContext: ['groups' => ['client_contact:write']], + processor: ClientContactProcessor::class, + ), + new Delete( + security: "is_granted('commercial.clients.manage')", + processor: ClientContactProcessor::class, + ), + ], +)] +#[ORM\Entity(repositoryClass: DoctrineClientContactRepository::class)] +#[ORM\Table(name: 'client_contact')] +#[ORM\Index(name: 'idx_client_contact_client', columns: ['client_id'])] +#[Auditable] +class ClientContact implements TimestampableInterface, BlamableInterface +{ + use TimestampableBlamableTrait; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['client_contact:read'])] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'contacts')] + #[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] + private ?Client $client = null; + + // RG-1.05 : firstName OU lastName obligatoire (CHECK BDD + Processor). Les + // deux restent nullable au niveau ORM. + #[ORM\Column(length: 120, nullable: true)] + #[Assert\Length(max: 120, normalizer: 'trim')] + #[Groups(['client_contact:read', 'client_contact:write'])] + private ?string $firstName = null; + + #[ORM\Column(length: 120, nullable: true)] + #[Assert\Length(max: 120, normalizer: 'trim')] + #[Groups(['client_contact:read', 'client_contact:write'])] + private ?string $lastName = null; + + #[ORM\Column(length: 120, nullable: true)] + #[Assert\Length(max: 120, normalizer: 'trim')] + #[Groups(['client_contact:read', 'client_contact:write'])] + private ?string $jobTitle = null; + + #[ORM\Column(length: 20, nullable: true)] + #[Groups(['client_contact:read', 'client_contact:write'])] + private ?string $phonePrimary = null; + + #[ORM\Column(length: 20, nullable: true)] + #[Groups(['client_contact:read', 'client_contact:write'])] + private ?string $phoneSecondary = null; + + #[ORM\Column(length: 180, nullable: true)] + #[Assert\Email] + #[Groups(['client_contact:read', 'client_contact:write'])] + private ?string $email = null; + + #[ORM\Column(options: ['default' => 0])] + #[Groups(['client_contact:read', 'client_contact:write'])] + private int $position = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getClient(): ?Client + { + return $this->client; + } + + public function setClient(?Client $client): static + { + $this->client = $client; + + return $this; + } + + public function getFirstName(): ?string + { + return $this->firstName; + } + + public function setFirstName(?string $firstName): static + { + $this->firstName = $firstName; + + return $this; + } + + public function getLastName(): ?string + { + return $this->lastName; + } + + public function setLastName(?string $lastName): static + { + $this->lastName = $lastName; + + return $this; + } + + public function getJobTitle(): ?string + { + return $this->jobTitle; + } + + public function setJobTitle(?string $jobTitle): static + { + $this->jobTitle = $jobTitle; + + return $this; + } + + public function getPhonePrimary(): ?string + { + return $this->phonePrimary; + } + + public function setPhonePrimary(?string $phonePrimary): static + { + $this->phonePrimary = $phonePrimary; + + return $this; + } + + public function getPhoneSecondary(): ?string + { + return $this->phoneSecondary; + } + + public function setPhoneSecondary(?string $phoneSecondary): static + { + $this->phoneSecondary = $phoneSecondary; + + return $this; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(?string $email): static + { + $this->email = $email; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } +} diff --git a/src/Module/Commercial/Domain/Entity/ClientRib.php b/src/Module/Commercial/Domain/Entity/ClientRib.php new file mode 100644 index 0000000..63a9447 --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/ClientRib.php @@ -0,0 +1,178 @@ + ['client_rib:read']], + ), + new Post( + uriTemplate: '/clients/{clientId}/ribs', + uriVariables: [ + 'clientId' => new Link(fromClass: Client::class, toProperty: 'client'), + ], + security: "is_granted('commercial.clients.accounting.manage')", + normalizationContext: ['groups' => ['client_rib:read']], + denormalizationContext: ['groups' => ['client_rib:write']], + processor: ClientRibProcessor::class, + ), + new Patch( + security: "is_granted('commercial.clients.accounting.manage')", + normalizationContext: ['groups' => ['client_rib:read']], + denormalizationContext: ['groups' => ['client_rib:write']], + processor: ClientRibProcessor::class, + ), + new Delete( + security: "is_granted('commercial.clients.accounting.manage')", + processor: ClientRibProcessor::class, + ), + ], +)] +#[ORM\Entity(repositoryClass: DoctrineClientRibRepository::class)] +#[ORM\Table(name: 'client_rib')] +#[ORM\Index(name: 'idx_client_rib_client', columns: ['client_id'])] +#[Auditable] +class ClientRib implements TimestampableInterface, BlamableInterface +{ + use TimestampableBlamableTrait; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['client_rib:read'])] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'ribs')] + #[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] + private ?Client $client = null; + + #[ORM\Column(length: 120)] + #[Assert\NotBlank] + #[Assert\Length(max: 120, normalizer: 'trim')] + #[Groups(['client_rib:read', 'client_rib:write'])] + private ?string $label = null; + + #[ORM\Column(length: 20)] + #[Assert\NotBlank] + #[Assert\Bic] + #[Groups(['client_rib:read', 'client_rib:write'])] + private ?string $bic = null; + + #[ORM\Column(length: 34)] + #[Assert\NotBlank] + #[Assert\Iban] + #[Groups(['client_rib:read', 'client_rib:write'])] + private ?string $iban = null; + + #[ORM\Column(options: ['default' => 0])] + #[Groups(['client_rib:read', 'client_rib:write'])] + private int $position = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getClient(): ?Client + { + return $this->client; + } + + public function setClient(?Client $client): static + { + $this->client = $client; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): static + { + $this->label = $label; + + return $this; + } + + public function getBic(): ?string + { + return $this->bic; + } + + public function setBic(string $bic): static + { + $this->bic = $bic; + + return $this; + } + + public function getIban(): ?string + { + return $this->iban; + } + + public function setIban(string $iban): static + { + $this->iban = $iban; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } +} diff --git a/src/Module/Commercial/Domain/Entity/PaymentDelay.php b/src/Module/Commercial/Domain/Entity/PaymentDelay.php new file mode 100644 index 0000000..f45f225 --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/PaymentDelay.php @@ -0,0 +1,105 @@ + 405. Pas de + * Timestampable/Blamable (referentiel statique whiteliste dans + * EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe + * `client:read:accounting` permet l'embarquement dans la reponse Client. + */ +#[ApiResource( + operations: [ + new GetCollection( + security: "is_granted('commercial.clients.view')", + normalizationContext: ['groups' => ['payment_delay:read']], + // Tri par defaut spec M1 § 4.7 : position ASC puis label ASC. + order: ['position' => 'ASC', 'label' => 'ASC'], + // ERP-72 : pagination serveur + toggle ?pagination=false (cf. TvaMode). + paginationClientEnabled: true, + ), + new Get( + security: "is_granted('commercial.clients.view')", + normalizationContext: ['groups' => ['payment_delay:read']], + ), + ], + security: "is_granted('commercial.clients.view')", +)] +#[ORM\Entity(repositoryClass: DoctrinePaymentDelayRepository::class)] +#[ORM\Table(name: 'payment_delay')] +#[ORM\UniqueConstraint(name: 'uq_payment_delay_code', columns: ['code'])] +class PaymentDelay +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['payment_delay:read', 'client:read:accounting'])] + private ?int $id = null; + + #[ORM\Column(length: 30)] + #[Groups(['payment_delay:read', 'client:read:accounting'])] + private ?string $code = null; + + #[ORM\Column(length: 120)] + #[Groups(['payment_delay:read', 'client:read:accounting'])] + private ?string $label = null; + + #[ORM\Column(options: ['default' => 0])] + #[Groups(['payment_delay:read'])] + private int $position = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): static + { + $this->code = $code; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): static + { + $this->label = $label; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } +} diff --git a/src/Module/Commercial/Domain/Entity/PaymentType.php b/src/Module/Commercial/Domain/Entity/PaymentType.php new file mode 100644 index 0000000..5402b68 --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/PaymentType.php @@ -0,0 +1,108 @@ + 405. Pas de + * Timestampable/Blamable (referentiel statique whiteliste dans + * EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe + * `client:read:accounting` permet l'embarquement dans la reponse Client. + */ +#[ApiResource( + operations: [ + new GetCollection( + security: "is_granted('commercial.clients.view')", + normalizationContext: ['groups' => ['payment_type:read']], + // Tri par defaut spec M1 § 4.7 : position ASC puis label ASC. + order: ['position' => 'ASC', 'label' => 'ASC'], + // ERP-72 : pagination serveur + toggle ?pagination=false (cf. TvaMode). + paginationClientEnabled: true, + ), + new Get( + security: "is_granted('commercial.clients.view')", + normalizationContext: ['groups' => ['payment_type:read']], + ), + ], + security: "is_granted('commercial.clients.view')", +)] +#[ORM\Entity(repositoryClass: DoctrinePaymentTypeRepository::class)] +#[ORM\Table(name: 'payment_type')] +#[ORM\UniqueConstraint(name: 'uq_payment_type_code', columns: ['code'])] +class PaymentType +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['payment_type:read', 'client:read:accounting'])] + private ?int $id = null; + + #[ORM\Column(length: 30)] + #[Groups(['payment_type:read', 'client:read:accounting'])] + private ?string $code = null; + + #[ORM\Column(length: 120)] + #[Groups(['payment_type:read', 'client:read:accounting'])] + private ?string $label = null; + + #[ORM\Column(options: ['default' => 0])] + #[Groups(['payment_type:read'])] + private int $position = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): static + { + $this->code = $code; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): static + { + $this->label = $label; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } +} diff --git a/src/Module/Commercial/Domain/Entity/TvaMode.php b/src/Module/Commercial/Domain/Entity/TvaMode.php new file mode 100644 index 0000000..989fb02 --- /dev/null +++ b/src/Module/Commercial/Domain/Entity/TvaMode.php @@ -0,0 +1,111 @@ + POST/PATCH/DELETE renvoient 405. + * + * Referentiel statique : pas de Timestampable/Blamable (whiteliste dans + * EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le + * groupe `client:read:accounting` permet d'embarquer le mode dans la reponse + * d'un Client (onglet Comptabilite) au lieu d'un IRI. + */ +#[ApiResource( + operations: [ + new GetCollection( + security: "is_granted('commercial.clients.view')", + normalizationContext: ['groups' => ['tva_mode:read']], + // Tri par defaut spec M1 § 4.7 : position ASC puis label ASC + // (ordre des selecteurs comptables) — provider Doctrine par defaut. + order: ['position' => 'ASC', 'label' => 'ASC'], + // ERP-72 : pagination serveur sur toute collection autonome. Le + // toggle client est desactive globalement, on l'active ici pour + // permettre ?pagination=false (alimenter un entier). + paginationClientEnabled: true, + ), + new Get( + security: "is_granted('commercial.clients.view')", + normalizationContext: ['groups' => ['tva_mode:read']], + ), + ], + security: "is_granted('commercial.clients.view')", +)] +#[ORM\Entity(repositoryClass: DoctrineTvaModeRepository::class)] +#[ORM\Table(name: 'tva_mode')] +#[ORM\UniqueConstraint(name: 'uq_tva_mode_code', columns: ['code'])] +class TvaMode +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['tva_mode:read', 'client:read:accounting'])] + private ?int $id = null; + + #[ORM\Column(length: 30)] + #[Groups(['tva_mode:read', 'client:read:accounting'])] + private ?string $code = null; + + #[ORM\Column(length: 120)] + #[Groups(['tva_mode:read', 'client:read:accounting'])] + private ?string $label = null; + + #[ORM\Column(options: ['default' => 0])] + #[Groups(['tva_mode:read'])] + private int $position = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): static + { + $this->code = $code; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): static + { + $this->label = $label; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } +} 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/ApiPlatform/Serializer/CategoryReferenceDenormalizer.php b/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/CategoryReferenceDenormalizer.php new file mode 100644 index 0000000..e588c85 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/CategoryReferenceDenormalizer.php @@ -0,0 +1,74 @@ +`, 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); + + // 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 + { + // 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/Serializer/SiteReferenceDenormalizer.php b/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/SiteReferenceDenormalizer.php new file mode 100644 index 0000000..7c59022 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/SiteReferenceDenormalizer.php @@ -0,0 +1,71 @@ +`, + * donc l'INTERFACE. Le serializer ne sait pas denormaliser un IRI vers une + * interface (« Could not denormalize object of type SiteInterface[] ») ; on + * resout l'IRI via l'IriConverter (qui retourne le Site mappe a la route) sans + * importer la classe Site du module Sites — la regle ABSOLUE n°1 (pas d'import + * cross-module) reste respectee : dependance au seul contrat Shared + API Platform. + * + * En lecture (normalisation), aucun probleme : l'objet reel EST un Site, + * ressource a part entiere, serialise en IRI par le normalizer standard. + */ +final class SiteReferenceDenormalizer implements DenormalizerInterface +{ + public function __construct( + private readonly IriConverterInterface $iriConverter, + ) {} + + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): ?SiteInterface + { + 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); + + // IRI syntaxiquement valide mais pointant sur une autre ressource : on + // refuse explicitement plutot que de retourner null silencieusement. + if (!$resource instanceof SiteInterface) { + throw new UnexpectedValueException(sprintf( + 'L\'IRI "%s" ne référence pas un site.', + $data, + )); + } + + return $resource; + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + // Support base sur le seul type cible : l'ArrayDenormalizer (collection + // `SiteInterface[]`) interroge le support en passant le TABLEAU complet + // comme $data avant de deleguer element par element. Tester + // is_string($data) ici casserait la chaine pour les collections. + return SiteInterface::class === $type; + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return [SiteInterface::class => true]; + } +} diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php new file mode 100644 index 0000000..1720432 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php @@ -0,0 +1,92 @@ += 1 site) + * par des contraintes Assert sur l'entite, RG-1.06/07/08/11 par des CHECK BDD. + * - DELETE : aucune regle metier specifique (suppression physique directe). + * + * La security de l'operation (commercial.clients.manage) est deja appliquee par + * API Platform, de meme que la validation Symfony des contraintes d'attribut. + * + * @implements ProcessorInterface + */ +final class ClientAddressProcessor implements ProcessorInterface +{ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private readonly ProcessorInterface $removeProcessor, + private readonly ClientFieldNormalizer $normalizer, + private readonly EntityManagerInterface $em, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof ClientAddress) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof DeleteOperationInterface) { + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } + + $this->linkParent($data, $uriVariables); + $this->normalize($data); + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + /** + * Rattache l'adresse au client parent de la sous-ressource POST + * (/clients/{clientId}/addresses) : la relation n'est pas peuplee + * automatiquement par le Link sur une ecriture. Sur PATCH, no-op. + */ + private function linkParent(ClientAddress $address, array $uriVariables): void + { + if (null !== $address->getClient()) { + return; + } + + $clientId = $uriVariables['clientId'] ?? null; + if (null === $clientId) { + return; + } + + $client = $clientId instanceof Client + ? $clientId + : $this->em->getRepository(Client::class)->find($clientId); + + if ($client instanceof Client) { + $address->setClient($client); + } + } + + /** + * Normalisation serveur (RG-1.21) : email de facturation en minuscules. La + * methode est null-safe — une adresse non facturable (billingEmail null) + * reste null. + */ + private function normalize(ClientAddress $address): void + { + $address->setBillingEmail($this->normalizer->normalizeEmail($address->getBillingEmail())); + } +} diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientContactProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientContactProcessor.php new file mode 100644 index 0000000..3ddffc0 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientContactProcessor.php @@ -0,0 +1,151 @@ + + */ +final class ClientContactProcessor implements ProcessorInterface +{ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private readonly ProcessorInterface $removeProcessor, + private readonly ClientFieldNormalizer $normalizer, + private readonly EntityManagerInterface $em, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof ClientContact) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof DeleteOperationInterface) { + $this->guardLastContactDeletion($data); + + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } + + $this->linkParent($data, $uriVariables); + $this->normalize($data); + $this->validateName($data); + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + /** + * Rattache le contact au client parent de la sous-ressource POST + * (/clients/{clientId}/contacts). La relation n'est pas peuplee + * automatiquement par le Link sur une operation d'ecriture : on resout donc + * le parent depuis l'uri variable. Sur PATCH (entite existante), le client + * est deja present -> no-op. + */ + private function linkParent(ClientContact $contact, array $uriVariables): void + { + if (null !== $contact->getClient()) { + return; + } + + $clientId = $uriVariables['clientId'] ?? null; + if (null === $clientId) { + return; + } + + $client = $clientId instanceof Client + ? $clientId + : $this->em->getRepository(Client::class)->find($clientId); + + if ($client instanceof Client) { + $contact->setClient($client); + } + } + + /** + * Normalisation serveur (RG-1.19 / 1.20 / 1.21). Toutes les methodes du + * normalizer sont null-safe : une chaine vide apres trim devient null. + */ + private function normalize(ClientContact $contact): void + { + $contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName())); + $contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName())); + $contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary())); + $contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary())); + $contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail())); + } + + /** + * RG-1.05 : au moins le prenom OU le nom est obligatoire (double garde avec + * le CHECK BDD chk_client_contact_name — leve un 422 propre plutot qu'une + * erreur SQL). Joue apres normalisation, donc les chaines vides sont deja + * ramenees a null. + */ + private function validateName(ClientContact $contact): void + { + if (null === $contact->getFirstName() && null === $contact->getLastName()) { + $violations = new ConstraintViolationList(); + $violations->add(new ConstraintViolation( + 'Le prénom ou le nom du contact est obligatoire.', + null, + [], + $contact, + 'firstName', + null, + )); + + throw new ValidationException($violations); + } + } + + /** + * RG-1.14 : refuse la suppression du dernier contact d'un client (409). La + * collection inclut le contact en cours de suppression : un effectif <= 1 + * signifie qu'il ne resterait aucun contact. Sans client rattache (cas + * theorique), on laisse passer. + */ + private function guardLastContactDeletion(ClientContact $contact): void + { + $client = $contact->getClient(); + if (null === $client) { + return; + } + + if ($client->getContacts()->count() <= 1) { + throw new ConflictHttpException( + 'Impossible de supprimer le dernier contact du client : au moins un contact est requis.', + ); + } + } +} 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..5f665f1 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php @@ -0,0 +1,457 @@ + 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, + private readonly EntityManagerInterface $em, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof Client) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + $writableKeys = $this->writablePayloadKeys(); + + $isArchiveRequest = $this->guardArchive($data, $writableKeys); + $this->guardAccounting($data); + + $this->normalize($data); + + $this->validateMainContact($data); + $this->validateDistributorBroker($data); + $this->validateAccountingConsistency($data); + $this->validateInformationCompleteness($data, $writableKeys); + + 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 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. + * + * 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 $writableKeys): bool + { + // 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; + } + + 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 ecrivable. + if ([] !== array_diff($writableKeys, [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 : 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(Client $data): void + { + $changed = $this->changedAccountingFields($data); + + if ([] === $changed) { + return; + } + + if (!$this->security->isGranted(self::PERM_ACCOUNTING_MANAGE)) { + throw new AccessDeniedHttpException(sprintf( + 'Le champ "%s" requiert la permission "%s".', + $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 + * 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 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), filtrage compris. Pour un PATCH merge-patch+json, ce sont les seuls + * champs modifies. + * + * @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/Processor/ClientRibProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientRibProcessor.php new file mode 100644 index 0000000..baf55ec --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientRibProcessor.php @@ -0,0 +1,104 @@ + + */ +final class ClientRibProcessor implements ProcessorInterface +{ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private readonly ProcessorInterface $removeProcessor, + private readonly EntityManagerInterface $em, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof ClientRib) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof DeleteOperationInterface) { + $this->guardLastRibDeletionUnderLcr($data); + + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } + + $this->linkParent($data, $uriVariables); + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + /** + * Rattache le RIB au client parent de la sous-ressource POST + * (/clients/{clientId}/ribs) : la relation n'est pas peuplee automatiquement + * par le Link sur une ecriture. Sur PATCH, no-op. + */ + private function linkParent(ClientRib $rib, array $uriVariables): void + { + if (null !== $rib->getClient()) { + return; + } + + $clientId = $uriVariables['clientId'] ?? null; + if (null === $clientId) { + return; + } + + $client = $clientId instanceof Client + ? $clientId + : $this->em->getRepository(Client::class)->find($clientId); + + if ($client instanceof Client) { + $rib->setClient($client); + } + } + + /** + * RG-1.13 : un client dont le type de reglement est LCR doit conserver au + * moins un RIB. La collection inclut le RIB en cours de suppression : un + * effectif <= 1 signifie qu'il ne resterait aucun RIB -> 409. Pour tout autre + * type de reglement, les RIBs sont optionnels (suppression libre). + */ + private function guardLastRibDeletionUnderLcr(ClientRib $rib): void + { + $client = $rib->getClient(); + if (null === $client) { + return; + } + + if ('LCR' === $client->getPaymentType()?->getCode() && $client->getRibs()->count() <= 1) { + throw new ConflictHttpException( + 'Impossible de supprimer le dernier RIB : le type de règlement LCR exige au moins un RIB.', + ); + } + } +} 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..8d7c640 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php @@ -0,0 +1,130 @@ + (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; + } + + /** + * 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/Commercial/Infrastructure/Controller/ClientExportController.php b/src/Module/Commercial/Infrastructure/Controller/ClientExportController.php new file mode 100644 index 0000000..c8e494d --- /dev/null +++ b/src/Module/Commercial/Infrastructure/Controller/ClientExportController.php @@ -0,0 +1,201 @@ +readBool($request->query->get('includeArchived')); + $search = $request->query->getString('search') ?: null; + $categoryType = $request->query->getString('categoryType') ?: null; + + /** @var list $clients */ + $clients = $this->repository + ->createListQueryBuilder($includeArchived, $search, $categoryType) + ->getQuery() + ->getResult() + ; + + $withSiren = $this->security->isGranted('commercial.clients.accounting.view'); + + $binary = $this->exporter->export( + 'Répertoire clients', + $this->buildHeaders($withSiren), + $this->buildRows($clients, $withSiren), + ); + + return $this->buildResponse($binary); + } + + /** + * Colonnes dans l'ordre impose par la spec § 4.6. SIREN inseree avant la + * date de creation, uniquement si l'utilisateur a accounting.view. + * + * @return list + */ + private function buildHeaders(bool $withSiren): array + { + $headers = [ + 'Nom entreprise', + 'Nom contact principal', + 'Prénom', + 'Téléphone principal', + 'Téléphone secondaire', + 'Email', + 'Catégories', + 'Sites', + ]; + + if ($withSiren) { + $headers[] = 'SIREN'; + } + + $headers[] = 'Date de création'; + + return $headers; + } + + /** + * @param list $clients + * + * @return iterable> + */ + private function buildRows(array $clients, bool $withSiren): iterable + { + foreach ($clients as $client) { + $row = [ + $client->getCompanyName(), + $client->getLastName(), + $client->getFirstName(), + $client->getPhonePrimary(), + $client->getPhoneSecondary(), + $client->getEmail(), + $this->formatCategories($client), + $this->formatSites($client), + ]; + + if ($withSiren) { + $row[] = $client->getSiren(); + } + + $row[] = $client->getCreatedAt()?->format('d/m/Y'); + + yield $row; + } + } + + /** + * Libelles des categories du client, dedupliques, tries, joints par virgule. + */ + private function formatCategories(Client $client): string + { + $names = []; + foreach ($client->getCategories() as $category) { + // @var CategoryInterface $category + $name = $category->getName(); + if (null !== $name && '' !== $name) { + $names[$name] = true; + } + } + + return $this->joinSorted($names); + } + + /** + * Le Client ne porte pas de sites en propre : ils sont rattaches aux + * adresses (RG-1.10). La colonne « Sites » agrege donc l'union distincte des + * sites de toutes les adresses du client (decision validee 01/06). + */ + private function formatSites(Client $client): string + { + $names = []; + foreach ($client->getAddresses() as $address) { + foreach ($address->getSites() as $site) { + // @var SiteInterface $site + $name = $site->getName(); + if (null !== $name && '' !== $name) { + $names[$name] = true; + } + } + } + + return $this->joinSorted($names); + } + + /** + * @param array $names ensemble de libelles (cles) + */ + private function joinSorted(array $names): string + { + $list = array_keys($names); + sort($list); + + return implode(', ', $list); + } + + private function buildResponse(string $binary): Response + { + $filename = sprintf('repertoire-clients-%s.xlsx', new DateTimeImmutable()->format('Ymd')); + + $response = new Response($binary); + $response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + $response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename)); + + return $response; + } + + /** + * Lit un flag booleen issu des query params. Accepte true / "true" / "1". + * Aligne sur ClientProvider pour un comportement identique a la liste. + */ + private function readBool(mixed $raw): bool + { + return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true); + } +} 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..e810898 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php @@ -0,0 +1,98 @@ + + */ +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, + ?string $search = null, + ?string $categoryType = null, + ): QueryBuilder { + $qb = $this->createQueryBuilder('c') + ->andWhere('c.deletedAt IS NULL') + ->orderBy('c.companyName', 'ASC') + ; + + if (!$includeArchived) { + $qb->andWhere('c.isArchived = false'); + } + + $this->applySearch($qb, $search); + $this->applyCategoryType($qb, $categoryType); + + return $qb; + } + + /** + * Recherche fuzzy insensible a la casse sur companyName + lastName + email. + * Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester + * litteraux. + */ + private function applySearch(QueryBuilder $qb, ?string $search): void + { + if (null === $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 principale. + */ + private function applyCategoryType(QueryBuilder $qb, ?string $categoryType): void + { + if (null === $categoryType || '' === trim($categoryType)) { + return; + } + + $sub = $this->getEntityManager()->createQueryBuilder() + ->select('c2.id') + ->from(Client::class, 'c2') + ->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)) + ; + } +} 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/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/Module/Core/Infrastructure/Console/SeedE2ECommand.php b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php index 115f5d6..9a59237 100644 --- a/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php +++ b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php @@ -186,6 +186,15 @@ final class SeedE2ECommand extends Command 'sites.bypass_scope', 'catalog.categories.view', 'catalog.categories.manage', + // Commercial — Repertoire clients (M1). Mappe ici sur le + // persona "tout" en attendant les vrais roles metier + // (bureau/compta/commerciale/usine) seedes par ERP-74. + // Miroir de frontend/tests/e2e/_fixtures/personas.ts. + 'commercial.clients.view', + 'commercial.clients.manage', + 'commercial.clients.accounting.view', + 'commercial.clients.accounting.manage', + 'commercial.clients.archive', ], ], [ 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 @@ + Infra). + */ +interface SpreadsheetExporterInterface +{ + /** + * Genere un classeur XLSX a une feuille et retourne son contenu binaire. + * + * @param string $sheetTitle titre de l'onglet (assaini / tronque par l'implementation si besoin) + * @param list $headers libelles de la ligne d'en-tete (ligne 1) + * @param iterable> $rows lignes de donnees ; chaque ligne est une liste de cellules alignee sur $headers + * + * @return string contenu binaire du fichier XLSX + */ + public function export(string $sheetTitle, array $headers, iterable $rows): string; +} diff --git a/src/Shared/Domain/Security/BusinessRoles.php b/src/Shared/Domain/Security/BusinessRoles.php new file mode 100644 index 0000000..83aac90 --- /dev/null +++ b/src/Shared/Domain/Security/BusinessRoles.php @@ -0,0 +1,42 @@ + '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/src/Shared/Infrastructure/Export/PhpSpreadsheetExporter.php b/src/Shared/Infrastructure/Export/PhpSpreadsheetExporter.php new file mode 100644 index 0000000..b479cb1 --- /dev/null +++ b/src/Shared/Infrastructure/Export/PhpSpreadsheetExporter.php @@ -0,0 +1,85 @@ +getActiveSheet(); + $sheet->setTitle($this->sanitizeSheetTitle($sheetTitle)); + + // Ligne 1 : en-tete. + $sheet->fromArray($headers, null, 'A1'); + + // Lignes 2..n : donnees. Iteration manuelle pour supporter un iterable + // paresseux (generator) sans tout materialiser en memoire. + $rowNumber = 2; + foreach ($rows as $row) { + $sheet->fromArray($row, null, 'A'.$rowNumber); + ++$rowNumber; + } + + return $this->toBinary($spreadsheet); + } + + private function toBinary(Spreadsheet $spreadsheet): string + { + $writer = new Xlsx($spreadsheet); + + // Le writer ecrit vers un chemin de fichier : on passe par un fichier + // temporaire puis on lit son contenu binaire. + $tmpFile = tempnam(sys_get_temp_dir(), 'xlsx_export_'); + if (false === $tmpFile) { + throw new RuntimeException('Impossible de creer un fichier temporaire pour l\'export XLSX.'); + } + + try { + $writer->save($tmpFile); + $binary = file_get_contents($tmpFile); + if (false === $binary) { + throw new RuntimeException('Lecture du fichier XLSX temporaire impossible.'); + } + + return $binary; + } finally { + // Libere les references internes de PhpSpreadsheet puis supprime le + // fichier temporaire, meme en cas d'exception. + $spreadsheet->disconnectWorksheets(); + @unlink($tmpFile); + } + } + + /** + * Retire les caracteres interdits et tronque a 31 caracteres ; renvoie un + * titre par defaut si la chaine resultante est vide. + */ + private function sanitizeSheetTitle(string $title): string + { + $clean = str_replace(str_split(self::INVALID_TITLE_CHARS), '', $title); + $clean = mb_substr($clean, 0, self::MAX_SHEET_TITLE_LENGTH); + + return '' === $clean ? 'Export' : $clean; + } +} 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 diff --git a/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php b/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php new file mode 100644 index 0000000..a2b522d --- /dev/null +++ b/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php @@ -0,0 +1,130 @@ + 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/Api/ClientExportControllerTest.php b/tests/Module/Commercial/Api/ClientExportControllerTest.php new file mode 100644 index 0000000..5707bcb --- /dev/null +++ b/tests/Module/Commercial/Api/ClientExportControllerTest.php @@ -0,0 +1,185 @@ +createAdminClient(); + $this->seedClient('Export Alpha'); + + $response = $client->request('GET', self::EXPORT_URL); + + self::assertResponseIsSuccessful(); + $headers = $response->getHeaders(false); + self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? ''); + + $disposition = $headers['content-disposition'][0] ?? ''; + self::assertStringContainsString('attachment; filename="repertoire-clients-', $disposition); + self::assertMatchesRegularExpression( + '/filename="repertoire-clients-\d{8}\.xlsx"/', + $disposition, + ); + + // Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes. + $grid = $this->gridFromResponse($response->getContent()); + $headers = $grid[0]; + self::assertSame('Nom entreprise', $headers[0]); + self::assertContains('Catégories', $headers); + self::assertContains('Sites', $headers); + self::assertContains('Date de création', $headers); + } + + public function testExportExcludesArchivedByDefault(): void + { + $client = $this->createAdminClient(); + $this->seedClient('Active One'); + $this->seedClient('Archived One', true); + + $names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent()); + + self::assertContains('ACTIVE ONE', $names); + self::assertNotContains('ARCHIVED ONE', $names); + } + + public function testExportRespectsSearchFilter(): void + { + $client = $this->createAdminClient(); + $this->seedClient('Searchable Alpha'); + $this->seedClient('Other Beta'); + + $names = $this->companyNames( + $client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(), + ); + + self::assertContains('SEARCHABLE ALPHA', $names); + self::assertNotContains('OTHER BETA', $names); + } + + public function testExportRespectsCategoryTypeFilter(): void + { + $client = $this->createAdminClient(); + $this->seedClient('Distrib Co', false, 'DISTRIBUTEUR'); + $this->seedClient('Secteur Co', false, 'SECTEUR'); + + $names = $this->companyNames( + $client->request('GET', self::EXPORT_URL.'?categoryType=DISTRIBUTEUR')->getContent(), + ); + + self::assertContains('DISTRIB CO', $names); + self::assertNotContains('SECTEUR CO', $names); + } + + public function testSirenColumnPresentWithAccountingView(): void + { + // L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN. + $client = $this->createAdminClient(); + $seed = $this->seedClient('Siren Co'); + $em = $this->getEm(); + $seed->setSiren('123456789'); + $em->flush(); + + $grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()); + + self::assertContains('SIREN', $grid[0]); + self::assertStringContainsString('123456789', $this->flatten($grid)); + } + + public function testSirenColumnAbsentWithoutAccountingView(): void + { + // Seed via admin, puis relecture par un user qui n'a QUE clients.view. + $admin = $this->createAdminClient(); + $seed = $this->seedClient('No Siren Co'); + $em = $this->getEm(); + $seed->setSiren('987654321'); + $em->flush(); + + $creds = $this->createUserWithPermission('commercial.clients.view'); + $viewer = $this->authenticatedClient($creds['username'], $creds['password']); + + $grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent()); + + self::assertNotContains('SIREN', $grid[0]); + self::assertStringNotContainsString('987654321', $this->flatten($grid)); + } + + public function testForbiddenWithoutClientsViewPermission(): void + { + $creds = $this->createUserWithPermission('core.users.view'); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $client->request('GET', self::EXPORT_URL); + + self::assertResponseStatusCodeSame(403); + } + + public function testUnauthorizedWhenAnonymous(): void + { + $client = self::createClient(); + $client->request('GET', self::EXPORT_URL); + + self::assertResponseStatusCodeSame(401); + } + + /** + * Relit le binaire XLSX d'une reponse et renvoie la grille de cellules. + * + * @return array> + */ + private function gridFromResponse(string $binary): array + { + $tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_'); + self::assertIsString($tmp); + file_put_contents($tmp, $binary); + + try { + return IOFactory::load($tmp)->getActiveSheet()->toArray(); + } finally { + @unlink($tmp); + } + } + + /** + * Extrait la colonne « Nom entreprise » (1re colonne) des lignes de donnees. + * + * @return list + */ + private function companyNames(string $binary): array + { + $grid = $this->gridFromResponse($binary); + $rows = array_slice($grid, 1); // saute l'en-tete + + return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows)); + } + + /** + * Aplatit toute la grille en une chaine, pour les assertions de presence. + * + * @param array> $grid + */ + private function flatten(array $grid): string + { + return implode('|', array_map( + static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)), + $grid, + )); + } +} diff --git a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php new file mode 100644 index 0000000..dd7538c --- /dev/null +++ b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php @@ -0,0 +1,320 @@ + 409) et RG-1.14 (DELETE + * dernier contact -> 409), plus le gating comptable (POST/PATCH/DELETE de + * client_ribs sans accounting.manage -> 403). + * + * @internal + */ +final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase +{ + private const string LD = 'application/ld+json'; + private const string MERGE = 'application/merge-patch+json'; + private const string VALID_IBAN = 'FR1420041010050500013M02606'; + private const string VALID_BIC = 'BNPAFRPPXXX'; + + // === Contacts === + + public function testPostContactNormalizesFields(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Contact Host'); + + $data = $client->request('POST', '/api/clients/'.$seed->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'firstName' => 'JEAN', + 'lastName' => 'dupont', + 'phonePrimary' => '06.12.34.56.78', + 'email' => 'Jean.DUPONT@ACME.FR', + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + // RG-1.19 / 1.20 / 1.21 + self::assertSame('Jean', $data['firstName']); + self::assertSame('Dupont', $data['lastName']); + self::assertSame('0612345678', $data['phonePrimary']); + self::assertSame('jean.dupont@acme.fr', $data['email']); + } + + public function testPostContactWithoutNameReturns422(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Contact No Name'); + + $client->request('POST', '/api/clients/'.$seed->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['jobTitle' => 'Directeur'], + ]); + + // RG-1.05 + self::assertResponseStatusCodeSame(422); + } + + public function testPatchContactNormalizes(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Contact Patch'); + $contact = $this->seedContact($seed, 'Paul'); + + $data = $client->request('PATCH', '/api/client_contacts/'.$contact->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['lastName' => 'martin'], + ])->toArray(); + + self::assertResponseStatusCodeSame(200); + self::assertSame('Martin', $data['lastName']); + } + + public function testDeleteContactWhenSeveralReturns204(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Contact Multi'); + $this->seedContact($seed, 'Premier'); + $second = $this->seedContact($seed, 'Second'); + + $client->request('DELETE', '/api/client_contacts/'.$second->getId()); + + self::assertResponseStatusCodeSame(204); + } + + public function testDeleteLastContactReturns409(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Contact Solo'); + $only = $this->seedContact($seed, 'Unique'); + + $client->request('DELETE', '/api/client_contacts/'.$only->getId()); + + // RG-1.14 + self::assertResponseStatusCodeSame(409); + } + + // === Adresses === + + public function testPostAddressNormalizesBillingEmail(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Address Host'); + $siteIri = $this->firstSiteIri(); + + $data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'isBilling' => true, + 'billingEmail' => 'Facturation@ACME.FR', + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$siteIri], + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + // RG-1.21 + self::assertSame('facturation@acme.fr', $data['billingEmail']); + } + + public function testPostAddressWithoutSiteReturns422(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Address No Site'); + + $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [], + ], + ]); + + // RG-1.10 (Assert\Count min 1) + self::assertResponseStatusCodeSame(422); + } + + public function testPostAddressWithInvalidPostalCodeReturns422(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Address Bad CP'); + $siteIri = $this->firstSiteIri(); + + $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'postalCode' => '123', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$siteIri], + ], + ]); + + // RG-1.09 (Assert\Regex ^[0-9]{4,5}$) + self::assertResponseStatusCodeSame(422); + } + + // === RIBs === + + public function testPostRibByAdminReturns201(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Rib Host'); + + $data = $client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'label' => 'Compte principal', + 'bic' => self::VALID_BIC, + 'iban' => self::VALID_IBAN, + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertSame('Compte principal', $data['label']); + } + + public function testPostRibWithInvalidIbanReturns422(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Rib Bad Iban'); + + $client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'label' => 'Compte invalide', + 'bic' => self::VALID_BIC, + 'iban' => 'INVALID-IBAN', + ], + ]); + + // Assert\Iban + self::assertResponseStatusCodeSame(422); + } + + public function testDeleteRibNonLcrReturns204(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Rib Non LCR'); + $rib = $this->seedRib($seed); + + $client->request('DELETE', '/api/client_ribs/'.$rib->getId()); + + self::assertResponseStatusCodeSame(204); + } + + public function testDeleteLastRibUnderLcrReturns409(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Rib LCR Solo'); + $this->setPaymentType($seed, 'LCR'); + $rib = $this->seedRib($seed); + + $client->request('DELETE', '/api/client_ribs/'.$rib->getId()); + + // RG-1.13 + self::assertResponseStatusCodeSame(409); + } + + public function testRibWriteWithoutAccountingManageReturns403(): void + { + // Un utilisateur portant seulement commercial.clients.manage (sans + // accounting.manage) ne peut ni creer, ni modifier, ni supprimer un RIB. + $seed = $this->seedClient('Rib Forbidden'); + $rib = $this->seedRib($seed); + $credentials = $this->createUserWithPermission('commercial.clients.manage'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + + $client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['label' => 'X', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN], + ]); + self::assertResponseStatusCodeSame(403); + + $client->request('PATCH', '/api/client_ribs/'.$rib->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['label' => 'Y'], + ]); + self::assertResponseStatusCodeSame(403); + + $client->request('DELETE', '/api/client_ribs/'.$rib->getId()); + self::assertResponseStatusCodeSame(403); + } + + // === Helpers === + + /** + * Seede un ClientContact rattache a un client (sans passer par l'API). + */ + private function seedContact(ClientEntity $client, string $firstName): ClientContact + { + $em = $this->getEm(); + $contact = new ClientContact(); + $contact->setFirstName($firstName); + $contact->setClient($client); + $em->persist($contact); + $em->flush(); + + return $contact; + } + + /** + * Seede un ClientRib valide rattache a un client (sans passer par l'API). + */ + private function seedRib(ClientEntity $client): ClientRib + { + $em = $this->getEm(); + $rib = new ClientRib(); + $rib->setLabel('Seed RIB'); + $rib->setBic(self::VALID_BIC); + $rib->setIban(self::VALID_IBAN); + $rib->setClient($client); + $em->persist($rib); + $em->flush(); + + return $rib; + } + + /** + * Affecte un type de reglement (par code) au client seede. + */ + private function setPaymentType(ClientEntity $client, string $code): void + { + $em = $this->getEm(); + $type = $em->getRepository(PaymentType::class)->findOneBy(['code' => $code]); + self::assertNotNull($type, sprintf('PaymentType "%s" introuvable (fixtures).', $code)); + + $managed = $em->getRepository(ClientEntity::class)->find($client->getId()); + $managed->setPaymentType($type); + $em->flush(); + } + + /** + * Retourne l'IRI du premier site seede (fixtures Sites). Skip en amont si le + * module Sites est desactive. + */ + private function firstSiteIri(): string + { + $site = $this->getEm()->getRepository(Site::class)->findOneBy([]); + self::assertNotNull($site, 'Aucun site seede : impossible de tester les adresses.'); + + return '/api/sites/'.$site->getId(); + } +} diff --git a/tests/Module/Commercial/Api/ReferentialApiTest.php b/tests/Module/Commercial/Api/ReferentialApiTest.php new file mode 100644 index 0000000..48f28c9 --- /dev/null +++ b/tests/Module/Commercial/Api/ReferentialApiTest.php @@ -0,0 +1,253 @@ + 405 (aucune operation d'ecriture declaree) ; + * - user authentifie sans commercial.clients.view -> 403 ; + * - anonyme -> 401 ; + * - pagination serveur active (ERP-72) + echappatoire ?pagination=false. + * + * @internal + */ +final class ReferentialApiTest extends AbstractCommercialApiTestCase +{ + private const string LD = 'application/ld+json'; + + /** + * Endpoint => codes attendus dans le seed (sous-ensemble verifie present). + * + * @var array> + */ + private const SEED = [ + '/api/tva_modes' => ['FRANCE_VENTES', 'EXPORT_VENTES', 'INTRACOM_VENTES'], + '/api/payment_delays' => ['J15', 'J30', 'A_RECEPTION'], + '/api/payment_types' => ['VIREMENT', 'LCR', 'NON_SOUMISE', 'CHEQUE'], + '/api/banks' => ['SG', 'CIC', 'CA'], + ]; + + /** + * Purge les eventuelles lignes de test inserees dans tva_mode (tri label). + * Les codes du seed ne commencent jamais par TEST_, donc cette purge ne + * touche pas les referentiels metier. + */ + protected function tearDown(): void + { + $this->getEm() + ->createQuery('DELETE FROM '.TvaMode::class.' t WHERE t.code LIKE :prefix') + ->setParameter('prefix', 'TEST\_%') + ->execute() + ; + + parent::tearDown(); + } + + /** + * Critere : chaque endpoint repond 200 et expose le seed (id, code, label, + * position) sous le groupe de lecture du referentiel. + * + * @param list $expectedCodes + */ + #[DataProvider('endpointProvider')] + public function testCollectionReturns200WithSeed(string $endpoint, array $expectedCodes): void + { + $client = $this->createAdminClient(); + $response = $client->request('GET', $endpoint.'?pagination=false', ['headers' => ['Accept' => self::LD]]); + + self::assertResponseStatusCodeSame(200); + + $members = $response->toArray()['member']; + $codes = array_map(static fn (array $m): string => $m['code'], $members); + + foreach ($expectedCodes as $expected) { + self::assertContains($expected, $codes, $endpoint.' doit exposer le code seede '.$expected); + } + + // Le DTO de lecture expose bien id / code / label / position. + $first = $members[0]; + self::assertArrayHasKey('id', $first); + self::assertArrayHasKey('label', $first); + self::assertArrayHasKey('position', $first); + } + + /** + * Critere : GET item repond 200 (recupere via un id reel de la collection). + */ + public function testGetItemReturns200(): void + { + $client = $this->createAdminClient(); + + $first = $client->request('GET', '/api/tva_modes?pagination=false', ['headers' => ['Accept' => self::LD]]) + ->toArray()['member'][0] + ; + + $client->request('GET', '/api/tva_modes/'.$first['id'], ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + } + + /** + * Critere : tri par defaut position ASC. Le seed tva_mode est ordonne + * FRANCE_VENTES (10) < EXPORT_VENTES (20) < INTRACOM_VENTES (30). + */ + public function testDefaultSortByPositionAsc(): void + { + $client = $this->createAdminClient(); + + $codes = array_map( + static fn (array $m): string => $m['code'], + $client->request('GET', '/api/tva_modes?pagination=false', ['headers' => ['Accept' => self::LD]])->toArray()['member'], + ); + + $expectedOrder = ['FRANCE_VENTES', 'EXPORT_VENTES', 'INTRACOM_VENTES']; + $filtered = array_values(array_intersect($codes, $expectedOrder)); + + self::assertSame( + $expectedOrder, + $filtered, + 'Les modes de TVA doivent etre tries position ASC (§ 4.7).', + ); + } + + /** + * Critere : a position egale, tri label ASC (departage). On insere deux + * lignes de test partageant la meme position, labels volontairement dans le + * desordre alphabetique ; le tearDown les purge ensuite. + */ + public function testTieBreakSortByLabelAsc(): void + { + $em = $this->getEm(); + foreach ([['TEST_TIE_Z', 'ZZZ Tie'], ['TEST_TIE_A', 'AAA Tie']] as [$code, $label]) { + $mode = new TvaMode(); + $mode->setCode($code); + $mode->setLabel($label); + $mode->setPosition(9000); + $em->persist($mode); + } + $em->flush(); + + $client = $this->createAdminClient(); + $codes = array_map( + static fn (array $m): string => $m['code'], + $client->request('GET', '/api/tva_modes?pagination=false', ['headers' => ['Accept' => self::LD]])->toArray()['member'], + ); + + $tie = array_values(array_intersect($codes, ['TEST_TIE_A', 'TEST_TIE_Z'])); + self::assertSame( + ['TEST_TIE_A', 'TEST_TIE_Z'], + $tie, + 'A position egale, le tri secondaire doit etre label ASC (§ 4.7).', + ); + } + + /** + * Critere ERP-72 : la collection est paginee par defaut. Preuve : une page + * au-dela des donnees est vide (un provider non pagine ignorerait `page`). + * Avec ?pagination=false, le parametre `page` est ignore -> tout revient. + */ + public function testPaginationActiveAndClientToggle(): void + { + $client = $this->createAdminClient(); + + // Page 2 d'un referentiel tenant sur une page : vide -> pagination active. + $page2 = $client->request('GET', '/api/tva_modes?page=2', ['headers' => ['Accept' => self::LD]])->toArray(); + self::assertArrayHasKey('totalItems', $page2); + self::assertSame([], $page2['member'], 'La page 2 doit etre vide : pagination serveur active.'); + + // ?pagination=false : `page` ignore, le seed complet est renvoye. + $all = $client->request('GET', '/api/tva_modes?pagination=false&page=2', ['headers' => ['Accept' => self::LD]])->toArray(); + self::assertNotEmpty($all['member'], '?pagination=false doit desactiver la pagination (page ignoree).'); + } + + /** + * Critere : aucune operation d'ecriture n'est declaree -> POST sur la + * collection renvoie 405 Method Not Allowed sur les 4 referentiels. + * + * @param list $expectedCodes + */ + #[DataProvider('endpointProvider')] + public function testPostReturns405(string $endpoint, array $expectedCodes): void + { + $client = $this->createAdminClient(); + $client->request('POST', $endpoint, [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['code' => 'X', 'label' => 'X', 'position' => 1], + ]); + + self::assertResponseStatusCodeSame(405); + } + + /** + * Critere : PATCH et DELETE sur un item renvoient 405 (lecture seule). + */ + public function testPatchAndDeleteReturn405(): void + { + $client = $this->createAdminClient(); + $first = $client->request('GET', '/api/tva_modes?pagination=false', ['headers' => ['Accept' => self::LD]]) + ->toArray()['member'][0] + ; + $iri = '/api/tva_modes/'.$first['id']; + + $client->request('PATCH', $iri, [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['label' => 'Renamed'], + ]); + self::assertResponseStatusCodeSame(405); + + $client->request('DELETE', $iri); + self::assertResponseStatusCodeSame(405); + } + + /** + * Critere : un utilisateur authentifie sans la permission + * commercial.clients.view obtient 403 sur les 4 endpoints. + * + * @param list $expectedCodes + */ + #[DataProvider('endpointProvider')] + public function testForbiddenWithoutPermission(string $endpoint, array $expectedCodes): void + { + // User jetable portant une permission SANS rapport (existe en base mais + // ne donne pas commercial.clients.view). + $creds = $this->createUserWithPermission('core.users.view'); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $client->request('GET', $endpoint, ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403); + } + + /** + * Critere : un appel anonyme (non authentifie) obtient 401 sur les 4 + * endpoints. + * + * @param list $expectedCodes + */ + #[DataProvider('endpointProvider')] + public function testUnauthorizedWhenAnonymous(string $endpoint, array $expectedCodes): void + { + $client = self::createClient(); + $client->request('GET', $endpoint, ['headers' => ['Accept' => self::LD]]); + + self::assertResponseStatusCodeSame(401); + } + + /** + * @return iterable}> + */ + public static function endpointProvider(): iterable + { + foreach (self::SEED as $endpoint => $codes) { + yield $endpoint => [$endpoint, $codes]; + } + } +} diff --git a/tests/Module/Commercial/Unit/CategoryReferenceDenormalizerTest.php b/tests/Module/Commercial/Unit/CategoryReferenceDenormalizerTest.php new file mode 100644 index 0000000..37a50e1 --- /dev/null +++ b/tests/Module/Commercial/Unit/CategoryReferenceDenormalizerTest.php @@ -0,0 +1,62 @@ +createStub(CategoryInterface::class); + $iriConverter = $this->createMock(IriConverterInterface::class); + $iriConverter->method('getResourceFromIri')->willReturn($category); + + $denormalizer = new CategoryReferenceDenormalizer($iriConverter); + + self::assertSame( + $category, + $denormalizer->denormalize('/api/categories/1', CategoryInterface::class), + ); + } + + public function testRejectsIriOfWrongType(): void + { + // Bug review ERP-55 : un IRI syntaxiquement valide mais pointant sur une + // autre ressource (ex: /api/clients/5) doit lever une exception au lieu + // d'etre silencieusement ignore. + $iriConverter = $this->createMock(IriConverterInterface::class); + $iriConverter->method('getResourceFromIri')->willReturn(new stdClass()); + + $denormalizer = new CategoryReferenceDenormalizer($iriConverter); + + $this->expectException(UnexpectedValueException::class); + $denormalizer->denormalize('/api/clients/5', CategoryInterface::class); + } + + public function testReturnsNullForEmptyData(): void + { + // Valeur vide deleguee par l'ArrayDenormalizer : aucun appel a + // l'IriConverter, retour null. + $iriConverter = $this->createMock(IriConverterInterface::class); + $iriConverter->expects(self::never())->method('getResourceFromIri'); + + $denormalizer = new CategoryReferenceDenormalizer($iriConverter); + + self::assertNull($denormalizer->denormalize('', CategoryInterface::class)); + } +} 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..c9e848b --- /dev/null +++ b/tests/Module/Commercial/Unit/ClientProcessorTest.php @@ -0,0 +1,354 @@ + 403. En creation (POST), positionner siren est un + // changement vs l'etat persiste vide. + $client = $this->minimalClient(); + $client->setSiren('123456789'); + + $processor = $this->makeProcessor(granted: [], payload: ['siren' => '123456789']); + + $this->expectException(AccessDeniedHttpException::class); + $processor->process($client, $this->operation()); + } + + public function testStrictMixWithAccountingFieldIsForbidden(): void + { + // RG-1.28 : payload mixant main + accounting sans la permission -> 403 + // sur l'ensemble (pas de filtrage silencieux). + $client = $this->minimalClient(); + $client->setCompanyName('X'); + $client->setSiren('123456789'); + + $processor = $this->makeProcessor( + granted: [], + payload: ['companyName' => 'X', 'siren' => '123456789'], + ); + + $this->expectException(AccessDeniedHttpException::class); + $processor->process($client, $this->operation()); + } + + public function testArchiveWithoutPermissionIsForbidden(): void + { + // RG-1.22 : basculer isArchived sans la permission archive -> 403. + $client = $this->minimalClient(); + $client->setIsArchived(true); + + $processor = $this->makeProcessor( + granted: [], + payload: ['isArchived' => true], + managed: true, + originalData: ['isArchived' => false], + ); + + $this->expectException(AccessDeniedHttpException::class); + $processor->process($client, $this->operation()); + } + + public function testArchiveWithOtherFieldIsUnprocessable(): void + { + // RG-1.22 : une requete d'archivage ne modifie aucun autre champ. + $client = $this->minimalClient(); + $client->setIsArchived(true); + $client->setCompanyName('X'); + + $processor = $this->makeProcessor( + granted: ['commercial.clients.archive'], + payload: ['isArchived' => true, 'companyName' => 'X'], + managed: true, + originalData: ['isArchived' => false], + ); + + $this->expectException(UnprocessableEntityHttpException::class); + $processor->process($client, $this->operation()); + } + + public function testPostWithIsArchivedFalseIsNotGated(): void + { + // Bug review ERP-55 : un POST renvoyant isArchived:false (valeur par + // defaut) ne doit declencher ni 403 (archive) ni 422, meme sans + // permission. L'entite n'est pas encore geree par l'ORM. + $client = $this->minimalClient(); // isArchived = false par defaut + + $processor = $this->makeProcessor( + granted: [], + payload: ['companyName' => 'Test Co', 'isArchived' => false], + managed: false, + ); + + self::assertInstanceOf(Client::class, $processor->process($client, $this->operation())); + } + + public function testFullRepresentationPatchWithUnchangedArchiveIsNotGated(): void + { + // Bug review ERP-55 : un PATCH « representation complete » renvoyant + // isArchived inchange + des cles JSON-LD (@id, @context) ne doit pas etre + // gate (ni 403 archive ni 422), meme sans permission. + $client = $this->minimalClient(); // isArchived = false (inchange) + + $processor = $this->makeProcessor( + granted: [], + payload: [ + '@id' => '/api/clients/1', + '@context' => '/api/contexts/Client', + 'companyName' => 'Test Co', + 'isArchived' => false, + ], + managed: true, + originalData: ['isArchived' => false], + ); + + self::assertInstanceOf(Client::class, $processor->process($client, $this->operation())); + } + + public function testUnchangedAccountingFieldOnPatchIsNotGated(): void + { + // Bug review ERP-55 : renvoyer un champ comptable a sa valeur persistee + // (PATCH representation complete) ne change rien -> pas d'exigence + // accounting.manage. + $client = $this->minimalClient(); + $client->setSiren('123456789'); // identique a l'etat persiste + + $processor = $this->makeProcessor( + granted: [], + payload: ['companyName' => 'Test Co', 'siren' => '123456789'], + managed: true, + // getOriginalEntityData renvoie tous les champs mappes d'une entite + // geree : isArchived (non-null) y figure toujours. + originalData: ['siren' => '123456789', 'isArchived' => false], + ); + + self::assertInstanceOf(Client::class, $processor->process($client, $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 + * @param bool $managed true = entite geree par l'ORM (PATCH), false = creation (POST) + * @param array $originalData Etat persiste simule (getOriginalEntityData) pour la detection de changement + */ + private function makeProcessor( + array $granted, + array $payload, + ?UserInterface $user = null, + bool $managed = false, + array $originalData = [], + ): 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))); + + // EntityManager stub : contains() distingue creation (POST) et mise a + // jour (PATCH) ; getOriginalEntityData() fournit l'etat persiste compare + // par le gating (RG-1.22 / RG-1.28). + $uow = $this->createMock(UnitOfWork::class); + $uow->method('getOriginalEntityData')->willReturn($originalData); + + $em = $this->createMock(EntityManagerInterface::class); + $em->method('contains')->willReturn($managed); + $em->method('getUnitOfWork')->willReturn($uow); + + return new ClientProcessor( + $persist, + new ClientFieldNormalizer(), + new ClientInformationCompletenessValidator(), + $security, + $requestStack, + $em, + ); + } + + /** + * 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); + } +} diff --git a/tests/Shared/Infrastructure/Export/PhpSpreadsheetExporterTest.php b/tests/Shared/Infrastructure/Export/PhpSpreadsheetExporterTest.php new file mode 100644 index 0000000..74ca5de --- /dev/null +++ b/tests/Shared/Infrastructure/Export/PhpSpreadsheetExporterTest.php @@ -0,0 +1,99 @@ +export( + 'Feuille test', + ['Nom', 'Email'], + [ + ['Alpha', 'alpha@test.fr'], + ['Beta', null], + ], + ); + + self::assertNotSame('', $binary); + // Un fichier XLSX (OOXML) est une archive ZIP : signature "PK\x03\x04". + self::assertStringStartsWith("PK\x03\x04", $binary); + + $grid = $this->grid($binary); + self::assertSame(['Nom', 'Email'], $grid[0]); + self::assertSame('Alpha', $grid[1][0]); + self::assertSame('alpha@test.fr', $grid[1][1]); + self::assertSame('Beta', $grid[2][0]); + // Cellule null a l'ecriture -> vide a la relecture. + self::assertNull($grid[2][1]); + } + + public function testExportAcceptsGeneratorRows(): void + { + $rows = (static function (): Generator { + yield ['L1']; + + yield ['L2']; + })(); + + $grid = $this->grid(new PhpSpreadsheetExporter()->export('Gen', ['H'], $rows)); + + self::assertSame('H', $grid[0][0]); + self::assertSame('L1', $grid[1][0]); + self::assertSame('L2', $grid[2][0]); + } + + public function testLongOrInvalidSheetTitleIsSanitized(): void + { + // Titre > 31 caracteres + caracteres interdits par Excel ([ ] : * etc.). + $binary = new PhpSpreadsheetExporter()->export( + str_repeat('A', 50).'[]:*?/\\', + ['H'], + [['x']], + ); + + $title = $this->load($binary)->getActiveSheet()->getTitle(); + self::assertLessThanOrEqual(31, mb_strlen($title)); + self::assertStringNotContainsString('[', $title); + self::assertStringNotContainsString(':', $title); + } + + /** + * Relit le binaire XLSX et renvoie la grille de cellules (ligne 0 = entete). + * + * @return array> + */ + private function grid(string $binary): array + { + return $this->load($binary)->getActiveSheet()->toArray(); + } + + private function load(string $binary): Spreadsheet + { + $tmp = tempnam(sys_get_temp_dir(), 'xlsx_test_'); + self::assertIsString($tmp); + file_put_contents($tmp, $binary); + + try { + return IOFactory::load($tmp); + } finally { + @unlink($tmp); + } + } +}