From 94955905cd86d3f23cb1380030544e342ff83187 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 1 Jun 2026 11:50:11 +0200 Subject: [PATCH] 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]; + } + } +}