From efa24c7280c71009e62b63df2c429f5b7def4ac5 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 1 Jun 2026 09:45:17 +0200 Subject: [PATCH 1/7] feat(commercial) : declare commercial.clients permissions + sync RBAC mirrors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute CommercialModule::permissions() (5 codes commercial.clients.* : view, manage, accounting.view, accounting.manage, archive) — alignes sur les is_granted deja references par ERP-55 (Client ApiResource, ClientProcessor, ClientReadGroupContextBuilder). Synchronise les 3 sources RBAC (regle ABSOLUE n8) : item sidebar "Repertoire clients" (commercial.clients.view), persona user-full dans personas.ts et SeedE2ECommand.php, cle i18n sidebar.commercial.clients. Les roles metier Bureau/Compta/Commerciale/Usine sont seedes par ERP-74 : les 5 permissions sont mappees ici sur le seul persona technique user-full en attendant, sans creer de nouveau persona (regle n7). --- config/sidebar.php | 7 ++++ frontend/i18n/locales/fr.json | 1 + frontend/tests/e2e/_fixtures/personas.ts | 10 ++++++ src/Module/Commercial/CommercialModule.php | 32 +++++++++++++++++++ .../Infrastructure/Console/SeedE2ECommand.php | 9 ++++++ 5 files changed, 59 insertions(+) diff --git a/config/sidebar.php b/config/sidebar.php index 2d019df..b4dbe2f 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' => '/commercial/clients', + 'icon' => 'mdi:account-group-outline', + 'module' => 'commercial', + 'permission' => 'commercial.clients.view', + ], [ 'label' => 'sidebar.commercial.suppliers', 'to' => '/suppliers', 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/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', ], ], [ -- 2.39.5 From 0085249917bf674dfa0f39bce0db8f8309429cd0 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 1 Jun 2026 16:16:00 +0200 Subject: [PATCH 2/7] fix(commercial) : flatten clients route --- config/sidebar.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/sidebar.php b/config/sidebar.php index b4dbe2f..c51494d 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -105,7 +105,7 @@ return [ 'items' => [ [ 'label' => 'sidebar.commercial.clients', - 'to' => '/commercial/clients', + 'to' => '/clients', 'icon' => 'mdi:account-group-outline', 'module' => 'commercial', 'permission' => 'commercial.clients.view', -- 2.39.5 From ca18f5be712a6c0684d670eb7fefb72fcaf75fcc Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 1 Jun 2026 11:50:11 +0200 Subject: [PATCH 3/7] feat(commercial) : expose accounting referentials read-only API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose TvaMode, PaymentDelay, PaymentType et Bank en lecture seule (GetCollection + Get), security commercial.clients.view au niveau operations + ressource. Aucune ecriture declaree -> POST/PATCH/DELETE renvoient 405. Tri par defaut position ASC puis label ASC (spec M1 § 4.7). Pagination serveur conservee (ERP-72) avec paginationClientEnabled pour activer l'echappatoire ?pagination=false (alimenter un select sans pagination). Endpoints : GET /api/tva_modes, /api/payment_delays, /api/payment_types, /api/banks. Tests fonctionnels : 200 + seed, tri position/label, 405 ecritures, 403 sans permission, 401 anonyme, pagination toggle. --- src/Module/Commercial/Domain/Entity/Bank.php | 28 +- .../Commercial/Domain/Entity/PaymentDelay.php | 28 +- .../Commercial/Domain/Entity/PaymentType.php | 28 +- .../Commercial/Domain/Entity/TvaMode.php | 29 +- .../Commercial/Api/ReferentialApiTest.php | 253 ++++++++++++++++++ 5 files changed, 354 insertions(+), 12 deletions(-) create mode 100644 tests/Module/Commercial/Api/ReferentialApiTest.php 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/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/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]; + } + } +} -- 2.39.5 From c5f195f5753ae002d05bb6cdc5fd83521bb29158 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 1 Jun 2026 13:43:19 +0200 Subject: [PATCH 4/7] feat(commercial) : add client sub-resources processors (contacts/addresses/ribs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose les sous-ressources Contacts / Adresses / RIB du repertoire clients (M1, spec § 4.5) : - 3 Processors dedies (ClientContactProcessor, ClientAddressProcessor, ClientRibProcessor) : normalisation serveur reutilisant ClientFieldNormalizer (RG-1.19 capitalize, RG-1.20 telephones chiffres, RG-1.21 emails/billingEmail lowercase) + regles metier. - Operations API Platform : - POST /api/clients/{id}/contacts|addresses, PATCH/DELETE /api/client_contacts|addresses/{id} (security commercial.clients.manage) - POST /api/clients/{id}/ribs, PATCH/DELETE /api/client_ribs/{id} (security commercial.clients.accounting.manage) - GET item par sous-ressource (lecture unitaire) ; pas de GET collection autonome (lecture via le parent, non concernee par la pagination ERP-72). - Regles de gestion : - RG-1.13 : DELETE du dernier RIB d'un client en reglement LCR -> 409. - RG-1.14 : DELETE du dernier contact d'un client -> 409 (completude front au M1). - RG-1.05 : prenom OU nom du contact obligatoire -> 422. - Validations deja portees par l'entite et desormais exercees : Assert\Count(min:1) sur ClientAddress.sites (RG-1.10), Assert\Regex code postal (RG-1.09), Assert\Iban / Assert\Bic sur ClientRib. - SiteReferenceDenormalizer : resout les IRIs /api/sites vers SiteInterface (meme pattern que CategoryReferenceDenormalizer, sans import cross-module). - Ajout de symfony/intl, requis par Assert\Bic. Tests : ClientSubResourceApiTest (13 cas) couvrant CRUD, normalisation, RG-1.13/1.14, gating 403 sur client_ribs sans accounting.manage. Suite back complete au vert (383 tests). --- composer.json | 1 + composer.lock | 91 ++++- .../Domain/Entity/ClientAddress.php | 48 ++- .../Domain/Entity/ClientContact.php | 50 ++- .../Commercial/Domain/Entity/ClientRib.php | 48 ++- .../Serializer/SiteReferenceDenormalizer.php | 71 ++++ .../Processor/ClientAddressProcessor.php | 92 +++++ .../Processor/ClientContactProcessor.php | 151 +++++++++ .../State/Processor/ClientRibProcessor.php | 104 ++++++ .../Api/ClientSubResourceApiTest.php | 320 ++++++++++++++++++ 10 files changed, 967 insertions(+), 9 deletions(-) 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 tests/Module/Commercial/Api/ClientSubResourceApiTest.php diff --git a/composer.json b/composer.json index eb9a778..1d80d05 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,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..af355c0 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": "2410dcfdb94553f520e1186a73fa98c5", "packages": [ { "name": "api-platform/doctrine-common", @@ -5172,6 +5172,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", 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/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/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(); + } +} -- 2.39.5 From 80ca74811e2971c4be920fa0939a2a250e1af7eb Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 1 Jun 2026 15:41:49 +0200 Subject: [PATCH 5/7] feat(shared) : add reusable XLSX spreadsheet exporter --- composer.json | 1 + composer.lock | 505 +++++++++++++++--- .../Contract/SpreadsheetExporterInterface.php | 31 ++ .../Export/PhpSpreadsheetExporter.php | 85 +++ .../Export/PhpSpreadsheetExporterTest.php | 99 ++++ 5 files changed, 641 insertions(+), 80 deletions(-) create mode 100644 src/Shared/Domain/Contract/SpreadsheetExporterInterface.php create mode 100644 src/Shared/Infrastructure/Export/PhpSpreadsheetExporter.php create mode 100644 tests/Shared/Infrastructure/Export/PhpSpreadsheetExporterTest.php diff --git a/composer.json b/composer.json index 1d80d05..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.*", diff --git a/composer.lock b/composer.lock index af355c0..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": "2410dcfdb94553f520e1186a73fa98c5", + "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", @@ -8352,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/src/Shared/Domain/Contract/SpreadsheetExporterInterface.php b/src/Shared/Domain/Contract/SpreadsheetExporterInterface.php new file mode 100644 index 0000000..340353c --- /dev/null +++ b/src/Shared/Domain/Contract/SpreadsheetExporterInterface.php @@ -0,0 +1,31 @@ + 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/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/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); + } + } +} -- 2.39.5 From bebaa009b9916283d43f83fae881a66465c3851e Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 1 Jun 2026 15:44:00 +0200 Subject: [PATCH 6/7] feat(commercial) : add clients XLSX export endpoint --- .../Repository/ClientRepositoryInterface.php | 14 +- .../State/Provider/ClientProvider.php | 63 +----- .../Controller/ClientExportController.php | 201 ++++++++++++++++++ .../Doctrine/DoctrineClientRepository.php | 55 ++++- .../Api/ClientExportControllerTest.php | 185 ++++++++++++++++ 5 files changed, 460 insertions(+), 58 deletions(-) create mode 100644 src/Module/Commercial/Infrastructure/Controller/ClientExportController.php create mode 100644 tests/Module/Commercial/Api/ClientExportControllerTest.php 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/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