From 120058049ce02a55a5789c0fedf1f704be6d28ef Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Mon, 1 Jun 2026 19:46:39 +0000 Subject: [PATCH] test(commercial) : cover RG-1.01..1.29 except role-gated (M1) + polish stack (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dernier wagon de la stack back M1. ERP-60 = polish stack + couverture de tests PHPUnit NON dépendante des rôles métier (cf. spec § 7 / § 8.1). ## Phase 0 — polish stack (déjà mergé dans les branches basses via rebase) - ERP-59 : route sidebar `/clients` (au lieu de `/commercial/clients`), cohérente avec `/suppliers`. - One-liner pagination Client abandonné : `pagination_client_enabled: true` est déjà le défaut global → `?pagination=false` marche déjà sur `/api/clients` (décision P7). ## Phase 1 — tests (combler les trous, zéro duplication) 8 nouvelles suites couvrant les RG non encore testées par ERP-55/56/57/58 : - `ClientFormulaireMainTest` — RG-1.02 (téléphone secondaire, max 2). - `ClientAddressTest` — RG-1.06/07/08 + RG-1.11 (CHECK BDD prospect/billing). - `ClientUniquenessTest` — RG-1.15/1.17 (Q4 : SIREN/email NON uniques). - `ClientArchiveTest` — **RG-1.23 : 409 restauration en conflit (gap P1)**. - `ClientAuditTest` — RG-1.27 (created* figés / updatedBy modificateur) + iban/bic présents dans le diff audité. - `ClientMigrationTest` — index partiel unique `uq_client_company_name_active` (1 seul) ; pas d'index siren/email. - `ClientSecurityTest` — 401 anonyme + 403 sans `commercial.clients.view`. - `ClientPatchStrictTest` — RG-1.28 (403 strict mix de groupes, fonctionnel). Cahier de test complet (mapping de TOUTES les RG → test) : `docs/specs/M1-clients/cahier-test-back-M1.md`. ## Délégué à ERP-74 (#493) Matrice RBAC différenciée (bureau/compta/commerciale/usine) + RG-1.04 fonctionnel — exigent les rôles métier seedés après le merge de la stack. ## Gaps documentés (cahier) - RG-1.29 validation écriture (catégorie type sur adresse → 422) non implémentée back (hors § 8.1, ticket test-only). - Violations CHECK adresse → rejet (≥400) sans mapping fin 422 (amélioration possible). ## Vérifs `make db-reset && make php-cs-fixer-allow-risky && make test` → **421 tests OK, 1386 assertions, 0 risky**. Nouveaux tests : 17, 71 assertions. --------- Co-authored-by: Matthieu Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/38 Co-authored-by: THOLOT DECHENE Matthieu Co-committed-by: THOLOT DECHENE Matthieu --- composer.json | 2 + composer.lock | 594 +++++++++++++++--- config/sidebar.php | 7 + docs/specs/M1-clients/cahier-test-back-M1.md | 79 +++ frontend/i18n/locales/fr.json | 1 + frontend/tests/e2e/_fixtures/personas.ts | 10 + src/Module/Commercial/CommercialModule.php | 32 + src/Module/Commercial/Domain/Entity/Bank.php | 28 +- .../Domain/Entity/ClientAddress.php | 48 +- .../Domain/Entity/ClientContact.php | 50 +- .../Commercial/Domain/Entity/ClientRib.php | 48 +- .../Commercial/Domain/Entity/PaymentDelay.php | 28 +- .../Commercial/Domain/Entity/PaymentType.php | 28 +- .../Commercial/Domain/Entity/TvaMode.php | 29 +- .../Repository/ClientRepositoryInterface.php | 14 +- .../Serializer/SiteReferenceDenormalizer.php | 71 +++ .../Processor/ClientAddressProcessor.php | 92 +++ .../Processor/ClientContactProcessor.php | 151 +++++ .../State/Processor/ClientRibProcessor.php | 104 +++ .../State/Provider/ClientProvider.php | 63 +- .../Controller/ClientExportController.php | 201 ++++++ .../Doctrine/DoctrineClientRepository.php | 55 +- .../Infrastructure/Console/SeedE2ECommand.php | 9 + .../Contract/SpreadsheetExporterInterface.php | 31 + .../Export/PhpSpreadsheetExporter.php | 85 +++ .../Commercial/Api/ClientAddressTest.php | 149 +++++ .../Commercial/Api/ClientArchiveTest.php | 47 ++ .../Module/Commercial/Api/ClientAuditTest.php | 142 +++++ .../Api/ClientExportControllerTest.php | 185 ++++++ .../Api/ClientFormulaireMainTest.php | 84 +++ .../Commercial/Api/ClientMigrationTest.php | 67 ++ .../Commercial/Api/ClientPatchStrictTest.php | 52 ++ .../Commercial/Api/ClientSecurityTest.php | 57 ++ .../Api/ClientSubResourceApiTest.php | 320 ++++++++++ .../Commercial/Api/ClientUniquenessTest.php | 73 +++ .../Commercial/Api/ReferentialApiTest.php | 253 ++++++++ .../Export/PhpSpreadsheetExporterTest.php | 99 +++ 37 files changed, 3230 insertions(+), 158 deletions(-) create mode 100644 docs/specs/M1-clients/cahier-test-back-M1.md create mode 100644 src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/SiteReferenceDenormalizer.php create mode 100644 src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php create mode 100644 src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientContactProcessor.php create mode 100644 src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientRibProcessor.php create mode 100644 src/Module/Commercial/Infrastructure/Controller/ClientExportController.php create mode 100644 src/Shared/Domain/Contract/SpreadsheetExporterInterface.php create mode 100644 src/Shared/Infrastructure/Export/PhpSpreadsheetExporter.php create mode 100644 tests/Module/Commercial/Api/ClientAddressTest.php create mode 100644 tests/Module/Commercial/Api/ClientArchiveTest.php create mode 100644 tests/Module/Commercial/Api/ClientAuditTest.php create mode 100644 tests/Module/Commercial/Api/ClientExportControllerTest.php create mode 100644 tests/Module/Commercial/Api/ClientFormulaireMainTest.php create mode 100644 tests/Module/Commercial/Api/ClientMigrationTest.php create mode 100644 tests/Module/Commercial/Api/ClientPatchStrictTest.php create mode 100644 tests/Module/Commercial/Api/ClientSecurityTest.php create mode 100644 tests/Module/Commercial/Api/ClientSubResourceApiTest.php create mode 100644 tests/Module/Commercial/Api/ClientUniquenessTest.php create mode 100644 tests/Module/Commercial/Api/ReferentialApiTest.php create mode 100644 tests/Shared/Infrastructure/Export/PhpSpreadsheetExporterTest.php 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/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/cahier-test-back-M1.md b/docs/specs/M1-clients/cahier-test-back-M1.md new file mode 100644 index 0000000..95afbc9 --- /dev/null +++ b/docs/specs/M1-clients/cahier-test-back-M1.md @@ -0,0 +1,79 @@ +# Cahier de test back — M1 Répertoire clients (ticket ERP-60 / #478) + +Mapping **toutes les RG (§ 7) → test(s) PHPUnit**, à jour après ERP-60. + +Légende source : `ERP-55` `ERP-56` `ERP-57` `ERP-58` = tests écrits par les wagons +précédents ; **`ERP-60`** = tests ajoutés par ce ticket (stratégie « combler les +trous, zéro duplication »). + +## Stratégie + +ERP-60 n'écrit QUE les tests des RG non déjà couvertes par la stack, et mappe ici +l'intégralité des RG (existantes + nouvelles + déléguées). Les tests dépendants +des **rôles métier** (matrice RBAC bureau/compta/commerciale/usine + RG-1.04 +fonctionnel) sont **délégués à ERP-74 (#493)** : ces rôles n'existent qu'après le +merge de la stack. + +## Mapping RG → test + +| RG | Intitulé | Test(s) | Source | +|----|----------|---------|--------| +| RG-1.01 | Prénom OU nom obligatoire → 422 | `ClientApiTest::testPostWithoutFirstOrLastNameReturns422` ; `ClientProcessorTest` (unit) | ERP-55 | +| RG-1.02 | phoneSecondary persisté ; max 2 téléphones | `ClientFormulaireMainTest::testPostPersistsSecondaryPhoneNormalized` ; `::testThirdPhoneFieldIsIgnored` | **ERP-60** | +| RG-1.03 | distributor/broker exclusifs + type catégorie | `ClientApiTest::testPostWithDistributorAndBrokerReturns422` ; `::testPostDistributorReferencingNonDistributorReturns422` ; `::testPostValidDistributorReturns201` ; `ClientProcessorTest` (unit) | ERP-55 | +| RG-1.04 | Onglet Information obligatoire pour rôle Commerciale | `ClientProcessorTest::testCommercialeIncompleteInformationIsUnprocessable` ; `::testNonCommercialeSkipsInformationCompleteness` (unit, dormant). **Test fonctionnel + durcissement → ERP-74** | ERP-55 / **ERP-74** | +| RG-1.05 | Contact : prénom OU nom → 422 (CHECK) | `ClientSubResourceApiTest::testPostContactWithoutNameReturns422` | ERP-57 | +| RG-1.06/07/08 | Adresse prospect exclusive de livraison/facturation (CHECK) | `ClientAddressTest::testProspectAddressCannotBeDelivery` ; `::testProspectAddressCannotBeBilling` | **ERP-60** | +| RG-1.09 | Code postal `^[0-9]{4,5}$` → 422 | `ClientSubResourceApiTest::testPostAddressWithInvalidPostalCodeReturns422` | ERP-57 | +| RG-1.10 | ≥ 1 site sur adresse → 422 | `ClientSubResourceApiTest::testPostAddressWithoutSiteReturns422` | ERP-57 | +| RG-1.11 | billingEmail obligatoire ssi isBilling (CHECK) | `ClientAddressTest::testBillingAddressRequiresBillingEmail` ; `::testNonBillingAddressRejectsBillingEmail` | **ERP-60** | +| RG-1.12 | Virement → banque obligatoire → 422 | `ClientProcessorTest::testVirementWithoutBankIsUnprocessable` ; `::testVirementWithBankPasses` (unit) | ERP-55 | +| RG-1.13 | LCR → ≥ 1 RIB ; DELETE dernier RIB en LCR → 409 | `ClientProcessorTest::testLcrWithoutRibIsUnprocessable` / `::testLcrWithRibPasses` (unit) ; `ClientSubResourceApiTest::testDeleteLastRibUnderLcrReturns409` / `::testDeleteRibNonLcrReturns204` | ERP-55 / ERP-57 | +| RG-1.14 | ≥ 1 bloc Contact pour finaliser l'onglet | **Front-driven (pas de state machine back).** Back voisin : `ClientSubResourceApiTest::testDeleteLastContactReturns409` | ERP-57 | +| RG-1.15 | ~~Unicité SIREN~~ supprimée (Q4) — SIREN partageable | `ClientUniquenessTest::testDuplicateSirenIsAllowed` ; `ClientMigrationTest::testNoSirenOrEmailUniqueIndex` | **ERP-60** | +| RG-1.16 | companyName unique (case-insensitive) parmi actifs → 409 | `ClientApiTest::testPostDuplicateCompanyNameReturns409` ; `ClientMigrationTest::testCompanyNameActivePartialIndexExistsExactlyOnce` | ERP-55 / **ERP-60** | +| RG-1.17 | ~~Unicité email~~ supprimée (Q4) — email partageable | `ClientUniquenessTest::testDuplicateEmailIsAllowed` ; `ClientMigrationTest::testNoSirenOrEmailUniqueIndex` | **ERP-60** | +| RG-1.18 | companyName upper-cased serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testCompanyNameIsUppercased` (unit) | ERP-55 | +| RG-1.19 | firstName/lastName capitalize serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testPersonNameIsTitleCased` (unit) ; `ClientSubResourceApiTest::testPostContactNormalizesFields` | ERP-55 / ERP-57 | +| RG-1.20 | Téléphones chiffres-seuls serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testPhoneKeepsOnlyDigits` (unit) ; `ClientFormulaireMainTest::testPostPersistsSecondaryPhoneNormalized` (secondary) | ERP-55 / **ERP-60** | +| RG-1.21 | email lowercase serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testEmailIsLowercased` (unit) ; `ClientSubResourceApiTest::testPostContactNormalizesFields` / `::testPostAddressNormalizesBillingEmail` | ERP-55 / ERP-57 | +| RG-1.22 | Archive : permission `archive` + archivedAt + aucun autre champ | `ClientApiTest::testPatchArchiveSetsArchivedAtThenRestore` ; `::testPatchArchiveWithOtherFieldReturns422` ; `ClientProcessorTest` (unit, gating archive) | ERP-55 | +| RG-1.23 | Restauration : archivedAt=null ; **409 si conflit d'unicité** | `ClientApiTest::testPatchArchiveSetsArchivedAtThenRestore` (cas nominal) ; **`ClientArchiveTest::testRestoreConflictReturns409`** (409 restauration, gap P1) | ERP-55 / **ERP-60** | +| RG-1.24 | Liste exclut les archivés par défaut | `ClientApiTest::testListSortedByCompanyNameAscAndExcludesArchived` | ERP-55 | +| RG-1.25 | `?includeArchived=true` inclut les archivés | `ClientApiTest::testListIncludeArchivedReturnsArchived` | ERP-55 | +| RG-1.26 | Tri par défaut companyName ASC | `ClientApiTest::testListSortedByCompanyNameAscAndExcludesArchived` | ERP-55 | +| RG-1.27 | Timestampable/Blamable : created* figés, updated* mis à jour | `ClientAuditTest::testCreatedFrozenAndUpdatedByReflectsModifier` | **ERP-60** | +| RG-1.28 | PATCH multi-groupes sans permission → 403 strict (tout le payload) | `ClientProcessorTest::testStrictMixWithAccountingFieldIsForbidden` / `::testAccountingFieldWithoutPermissionIsForbidden` (unit) ; **`ClientPatchStrictTest::testMixedGroupsPatchWithoutAccountingPermissionIsForbidden`** (fonctionnel) | ERP-55 / **ERP-60** | +| RG-1.29 | Catégorie d'adresse limitée aux types SECTEUR/AUTRE | **Filtrage LECTURE = front-driven** (SearchFilter `GET /api/categories?categoryType.code[]=…`). **Validation ÉCRITURE (POST/PATPH catégorie DISTRIBUTEUR/COURTIER → 422) NON IMPLÉMENTÉE côté back au M1** (absente du `ClientAddressProcessor` et de la liste § 8.1). → voir « Gaps & suivi » | — (gap) | + +## Couvertures transverses + +| Sujet | Test(s) | Source | +|-------|---------|--------| +| Audit iban/bic présents dans le diff (pas d'`#[AuditIgnore]`) | `ClientAuditTest::testRibCreateAuditIncludesIbanAndBic` | **ERP-60** | +| Sécurité générique : 401 anonyme + 403 sans `commercial.clients.view` | `ClientSecurityTest` (collection + détail) ; `ClientExportControllerTest::testForbiddenWithoutClientsViewPermission` / `::testUnauthorizedWhenAnonymous` | **ERP-60** / ERP-58 | +| Migration : index partiel unique présent (1 seul), pas de siren/email unique | `ClientMigrationTest` | **ERP-60** | +| Référentiels comptables read-only (405 écriture, 401/403) | `ReferentialApiTest` | ERP-56 | +| Export XLSX (colonnes accounting selon permission, 401/403) | `ClientExportControllerTest` | ERP-58 | + +## Délégué à ERP-74 (#493) — NE PAS faire dans ERP-60 + +- **Matrice RBAC différenciée** par rôle métier (Bureau / Compta / Commerciale / + Usine) : 200/403 par verbe et par onglet selon le rôle. +- **RG-1.04 fonctionnel** : PATCH onglet Information par une Commerciale avec + champs incomplets → 422 ; même PATCH par Admin → 200 (+ durcissement code/spec). +- Raison : ces rôles métier ne sont seedés qu'après le merge de la stack M1. + +## Gaps & suivi + +- **RG-1.29 (validation écriture)** : refuser une catégorie de type + `DISTRIBUTEUR`/`COURTIER` sur une `ClientAddress` (→ 422, violation + `categories`) n'est pas implémenté au M1. La spec § 8.1 ne le liste pas comme + cas de test back ; le filtrage de lecture est front-driven. **Suggestion** : + ouvrir un follow-up (durcissement `ClientAddressProcessor`) ou l'intégrer à + ERP-74. Aucune invention de feature dans ERP-60 (ticket test-only). +- **Violations CHECK → statut HTTP** : les CHECK d'adresse (RG-1.06/07/08/11) + sont aujourd'hui rejetées par la base (statut ≥ 400) mais sans mapping fin + vers 422 (pas d'`exception_to_status` ni de listener DBAL→HTTP). Les tests + ERP-60 assertent donc le **rejet** (≥ 400). Un mapping explicite vers 422 + serait une amélioration UX d'API (follow-up possible). 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/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 index 1524a0a..e690504 100644 --- a/src/Module/Commercial/Domain/Entity/Bank.php +++ b/src/Module/Commercial/Domain/Entity/Bank.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace App\Module\Commercial\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; use App\Module\Commercial\Infrastructure\Doctrine\DoctrineBankRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; @@ -13,10 +16,29 @@ use Symfony\Component\Serializer\Attribute\Groups; * CIC, Credit Agricole) : referentiel statique seede par la migration M1 et * re-seede en dev/test par CommercialReferentialFixtures. * - * Lecture seule au M1 (HP-M2-2). Pas de Timestampable/Blamable (referentiel - * statique whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED). Le - * groupe `client:read:accounting` permet l'embarquement dans la reponse Client. + * Lecture seule au M1 (HP-M2-2) : GetCollection + Get uniquement (ERP-56), + * permission commercial.clients.view ; POST/PATCH/DELETE -> 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'])] diff --git a/src/Module/Commercial/Domain/Entity/ClientAddress.php b/src/Module/Commercial/Domain/Entity/ClientAddress.php index 26e5f8d..ca2eac8 100644 --- a/src/Module/Commercial/Domain/Entity/ClientAddress.php +++ b/src/Module/Commercial/Domain/Entity/ClientAddress.php @@ -4,6 +4,13 @@ declare(strict_types=1); namespace App\Module\Commercial\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientAddressProcessor; use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientAddressRepository; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; @@ -28,11 +35,46 @@ use Symfony\Component\Validator\Constraints as Assert; * - sites : SiteInterface (module Sites) via resolve_target_entities * - contacts : ClientContact (meme module) * - categories : CategoryInterface (module Catalog) via resolve_target_entities - * — limitees aux types SECTEUR/AUTRE cote validation (RG-1.29, futur Processor) + * — limitees aux types SECTEUR/AUTRE cote validation (RG-1.29, hors ERP-57) * - * Audite (#[Auditable]) + Timestampable/Blamable. Aucun ApiResource au M1.1 - * (sous-ressources branchees a un ticket dedie). + * Audite (#[Auditable]) + Timestampable/Blamable. + * + * Sous-ressource API (ERP-57, spec § 4.5) : + * - POST /api/clients/{clientId}/addresses : creation rattachee au client parent + * (Link toProperty 'client'), security commercial.clients.manage. + * - PATCH / DELETE /api/client_addresses/{id} : security commercial.clients.manage. + * - GET /api/client_addresses/{id} : lecture unitaire (security view) — la + * lecture courante reste via le parent. Pas de GET collection autonome. + * Tout passe par le ClientAddressProcessor (normalisation RG-1.21 billingEmail). */ +#[ApiResource( + operations: [ + new Get( + security: "is_granted('commercial.clients.view')", + normalizationContext: ['groups' => ['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'])] diff --git a/src/Module/Commercial/Domain/Entity/ClientContact.php b/src/Module/Commercial/Domain/Entity/ClientContact.php index 1b04ef8..06565e2 100644 --- a/src/Module/Commercial/Domain/Entity/ClientContact.php +++ b/src/Module/Commercial/Domain/Entity/ClientContact.php @@ -4,6 +4,13 @@ declare(strict_types=1); namespace App\Module\Commercial\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientContactProcessor; use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientContactRepository; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; @@ -16,13 +23,50 @@ use Symfony\Component\Validator\Constraints as Assert; /** * Contact d'un client (1:n) — onglet Contact. Au moins firstName OU lastName * doit etre renseigne (RG-1.05) : la contrainte est portee par un CHECK BDD - * (chk_client_contact_name) et validee dans le futur ClientContactProcessor ; + * (chk_client_contact_name) et validee dans le ClientContactProcessor ; * l'entite reste permissive (les deux champs sont nullable). * * Audite (#[Auditable]) + Timestampable/Blamable (pattern Shared standard). - * Les operations CRUD (sous-ressources POST/PATCH/DELETE) sont branchees au - * ticket dedie des sous-ressources — aucun ApiResource au M1.1 (ERP-54). + * + * Sous-ressource API (ERP-57, spec § 4.5) : + * - POST /api/clients/{clientId}/contacts : creation rattachee au client parent + * (Link toProperty 'client'), security commercial.clients.manage. + * - PATCH / DELETE /api/client_contacts/{id} : security commercial.clients.manage. + * Le DELETE est physique (sous-collection, pas le client) ; le processor + * refuse la suppression du dernier contact (RG-1.14, 409). + * - GET /api/client_contacts/{id} : lecture unitaire (security view) — la + * lecture courante reste via le parent (client embarque ses contacts). Pas de + * GET collection autonome : non concernee par la pagination ERP-72. + * Tout passe par le ClientContactProcessor (normalisation RG-1.19/1.20/1.21). */ +#[ApiResource( + operations: [ + new Get( + security: "is_granted('commercial.clients.view')", + normalizationContext: ['groups' => ['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'])] diff --git a/src/Module/Commercial/Domain/Entity/ClientRib.php b/src/Module/Commercial/Domain/Entity/ClientRib.php index f1c589d..63a9447 100644 --- a/src/Module/Commercial/Domain/Entity/ClientRib.php +++ b/src/Module/Commercial/Domain/Entity/ClientRib.php @@ -4,6 +4,13 @@ declare(strict_types=1); namespace App\Module\Commercial\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientRibProcessor; use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRibRepository; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; @@ -16,7 +23,7 @@ use Symfony\Component\Validator\Constraints as Assert; /** * Coordonnees bancaires d'un client (1:n) — onglet Comptabilite. Au moins un * RIB est obligatoire si le type de reglement du client est LCR (RG-1.13, - * verifie au futur Processor). + * verifie au ClientRibProcessor : refus du DELETE du dernier RIB sous LCR). * * Audit (#[Auditable]) : TOUS les champs sont audites, y compris `iban` et * `bic` — AUCUN #[AuditIgnore] (decision Matthieu en revue MR 29/05/2026 : @@ -25,8 +32,45 @@ use Symfony\Component\Validator\Constraints as Assert; * * Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony au M1 * (HP-M2-14 : pas de controle externe banque reelle). Timestampable/Blamable - * standard. Aucun ApiResource au M1.1 (sous-ressource branchee ulterieurement). + * standard. + * + * Sous-ressource API (ERP-57, spec § 4.5) — gating comptable renforce : + * - POST /api/clients/{clientId}/ribs : creation rattachee au client parent + * (Link toProperty 'client'), security commercial.clients.accounting.manage. + * - PATCH / DELETE /api/client_ribs/{id} : security commercial.clients.accounting.manage. + * - GET /api/client_ribs/{id} : lecture unitaire, security + * commercial.clients.accounting.view (donnees bancaires sensibles). Pas de + * GET collection autonome. + * Tout passe par le ClientRibProcessor (RG-1.13 sur DELETE). */ +#[ApiResource( + operations: [ + new Get( + security: "is_granted('commercial.clients.accounting.view')", + normalizationContext: ['groups' => ['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'])] diff --git a/src/Module/Commercial/Domain/Entity/PaymentDelay.php b/src/Module/Commercial/Domain/Entity/PaymentDelay.php index cdfbed0..f45f225 100644 --- a/src/Module/Commercial/Domain/Entity/PaymentDelay.php +++ b/src/Module/Commercial/Domain/Entity/PaymentDelay.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace App\Module\Commercial\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; use App\Module\Commercial\Infrastructure\Doctrine\DoctrinePaymentDelayRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; @@ -13,10 +16,29 @@ use Symfony\Component\Serializer\Attribute\Groups; * referentiel statique seede par la migration M1 et re-seede en dev/test par * CommercialReferentialFixtures. * - * Lecture seule au M1 (HP-M2-2). Pas de Timestampable/Blamable (referentiel - * statique whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED). Le - * groupe `client:read:accounting` permet l'embarquement dans la reponse Client. + * Lecture seule au M1 (HP-M2-2) : GetCollection + Get uniquement (ERP-56), + * permission commercial.clients.view ; POST/PATCH/DELETE -> 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'])] diff --git a/src/Module/Commercial/Domain/Entity/PaymentType.php b/src/Module/Commercial/Domain/Entity/PaymentType.php index 3930c42..5402b68 100644 --- a/src/Module/Commercial/Domain/Entity/PaymentType.php +++ b/src/Module/Commercial/Domain/Entity/PaymentType.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace App\Module\Commercial\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; use App\Module\Commercial\Infrastructure\Doctrine\DoctrinePaymentTypeRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; @@ -16,10 +19,29 @@ use Symfony\Component\Serializer\Attribute\Groups; * Le `code` porte une semantique metier : VIREMENT impose une banque (RG-1.12), * LCR impose au moins un RIB (RG-1.13). * - * Lecture seule au M1 (HP-M2-2). Pas de Timestampable/Blamable (referentiel - * statique whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED). Le - * groupe `client:read:accounting` permet l'embarquement dans la reponse Client. + * Lecture seule au M1 (HP-M2-2) : GetCollection + Get uniquement (ERP-56), + * permission commercial.clients.view ; POST/PATCH/DELETE -> 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'])] diff --git a/src/Module/Commercial/Domain/Entity/TvaMode.php b/src/Module/Commercial/Domain/Entity/TvaMode.php index 5a366fb..989fb02 100644 --- a/src/Module/Commercial/Domain/Entity/TvaMode.php +++ b/src/Module/Commercial/Domain/Entity/TvaMode.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace App\Module\Commercial\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; use App\Module\Commercial\Infrastructure\Doctrine\DoctrineTvaModeRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; @@ -13,15 +16,35 @@ use Symfony\Component\Serializer\Attribute\Groups; * referentiel statique seede par la migration M1 (Version20260601000000) et * re-seede en dev/test par CommercialReferentialFixtures. * - * Lecture seule au M1 : pas de POST/PATCH/DELETE (HP-M2-2). L'ApiResource - * (GetCollection + Get, tri position ASC) est branche au ticket dedie des - * referentiels lecture seule. + * Lecture seule au M1 (HP-M2-2) : seules GetCollection et Get sont exposees + * (ERP-56), sous la permission commercial.clients.view ; aucune ecriture + * declaree -> 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'])] diff --git a/src/Module/Commercial/Domain/Repository/ClientRepositoryInterface.php b/src/Module/Commercial/Domain/Repository/ClientRepositoryInterface.php index a3d6ca3..a5c43d7 100644 --- a/src/Module/Commercial/Domain/Repository/ClientRepositoryInterface.php +++ b/src/Module/Commercial/Domain/Repository/ClientRepositoryInterface.php @@ -18,6 +18,18 @@ interface ClientRepositoryInterface * - Exclut toujours les clients soft-deletes (deleted_at IS NOT NULL, RG-1.24). * - Exclut les archives sauf si $includeArchived = true (RG-1.25). * - Tri par defaut : companyName ASC (RG-1.26). + * - $search : recherche fuzzy insensible a la casse sur companyName + + * lastName + email (metacaracteres LIKE echappes). Ignore si null/vide. + * - $categoryType : restreint aux clients possedant au moins une categorie + * du type donne (code). Ignore si null/vide. + * + * Filtrage centralise ICI (et non dans les providers/controllers) pour que + * la liste paginee (ClientProvider) et l'export (ClientExportController) + * partagent strictement la meme logique de selection. */ - public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder; + public function createListQueryBuilder( + bool $includeArchived = false, + ?string $search = null, + ?string $categoryType = null, + ): QueryBuilder; } 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/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 index f401375..8d7c640 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php @@ -11,8 +11,6 @@ use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\State\ProviderInterface; use App\Module\Commercial\Domain\Entity\Client; use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -46,7 +44,6 @@ final class ClientProvider implements ProviderInterface #[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')] private readonly ClientRepositoryInterface $repository, private readonly Pagination $pagination, - private readonly EntityManagerInterface $em, ) {} public function provide(Operation $operation, array $uriVariables = [], array $context = []): Client|iterable|Paginator|null @@ -67,10 +64,15 @@ final class ClientProvider implements ProviderInterface { $filters = $context['filters'] ?? []; $includeArchived = $this->readBool($filters['includeArchived'] ?? false); + $search = $filters['search'] ?? null; + $categoryType = $filters['categoryType'] ?? null; - $qb = $this->repository->createListQueryBuilder($includeArchived); - $this->applySearch($qb, $filters['search'] ?? null); - $this->applyCategoryType($qb, $filters['categoryType'] ?? null); + // Filtrage delegue au repository (logique partagee avec l'export XLSX). + $qb = $this->repository->createListQueryBuilder( + $includeArchived, + is_string($search) ? $search : null, + is_string($categoryType) ? $categoryType : null, + ); // Echappatoire ?pagination=false : collection complete sans Paginator // (cf. convention ERP-72 — utile pour un