feat(commercial) : add Client API Platform provider + processor + business rules

Branche l'API REST du repertoire clients (M1) sur l'entite Client preparee en
ERP-54. Operations GetCollection / Get / Post / Patch (pas de Delete au M1 :
l'archivage passe par PATCH isArchived).

ClientProvider :
- liste paginee (Paginator ORM, aligne sur la convention ERP-72) + echappatoire
  ?pagination=false
- exclut archives + soft-deletes par defaut (RG-1.24), ?includeArchived=true
  reintegre les archives (RG-1.25)
- tri companyName ASC (RG-1.26), filtres ?search (fuzzy companyName/lastName/
  email) et ?categoryType=<code>
- detail : 404 sur soft-delete, embarque contacts/adresses/ribs

ClientProcessor :
- normalisation serveur via ClientFieldNormalizer (RG-1.18 a 1.21)
- 409 sur doublon de nom de societe (RG-1.16) ; 409 dedie sur conflit de
  restauration (RG-1.23)
- gating par onglet : champ comptable -> accounting.manage, isArchived ->
  archive, mode strict 403 sur tout le payload (RG-1.28) ; archivage exclusif
  (RG-1.22) + pose/retrait archivedAt
- regles metier RG-1.01 (prenom/nom), RG-1.03 (distributor/broker exclusifs +
  controle du type de categorie), RG-1.12 (Virement -> banque), RG-1.13 (LCR ->
  >= 1 RIB), RG-1.04 (completude Information pour le role Commerciale)

Lecture comptable conditionnelle : ClientReadGroupContextBuilder ajoute le
groupe client:read:accounting selon commercial.clients.accounting.view.

Resolution des references categorie : CategoryReferenceDenormalizer resout les
IRI vers Category quand la propriete est type-hintee par le contrat
CategoryInterface (denormalisation impossible sur une interface sinon).

Contrats Shared :
- CategoryInterface::getCategoryTypeCode() (implemente par Category) pour la
  verification de type sans import inter-modules
- BusinessRoleAwareInterface (implemente par User) + BusinessRoles::COMMERCIALE
  pour detecter le role metier ; le code de role sera seede par ERP-74 et
  reutilise par ERP-59/60. RG-1.04 reste dormante tant qu'aucun user ne porte
  ce role.

Coordination stack :
- chaines de permission commercial.clients.* referencees ici, declarees en
  ERP-59 (tests RBAC complets en ERP-60)
- config globale de pagination (itemsPerPage client, max 50) portee par ERP-72
- referentiels comptables (PaymentType/Bank/...) exposes en ERP-56

Tests : 31 tests Commercial (integration admin sur les regles metier + unitaires
sur le gating, RG-1.04/1.12/1.13 et le context builder). Suite complete verte
(339 tests).
This commit is contained in:
Matthieu
2026-05-29 16:41:40 +02:00
parent 311e758dea
commit e1b8f8a28d
17 changed files with 1808 additions and 4 deletions
@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Tests fonctionnels de l'API /api/clients (M1) — branche ERP-55.
*
* Authentifies en ADMIN (bypass RBAC via isAdmin) : on valide ici les regles
* METIER (normalisation, unicite, distributor/broker, archivage, liste). Le
* gating par permission (accounting.manage / archive / RG-1.28 strict, RG-1.04
* Commerciale) est couvert par les tests unitaires du ClientProcessor : il
* exige des users non-admin portant des permissions `commercial.clients.*` qui
* ne sont declarees qu'en ERP-59 (tests RBAC complets en ERP-60).
*
* @internal
*/
final class ClientApiTest extends AbstractCommercialApiTestCase
{
private const string LD = 'application/ld+json';
public function testPostNormalizesTextFields(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$response = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'acme sas',
'firstName' => 'JEAN',
'lastName' => 'dupont',
'phonePrimary' => '06.12.34.56.78',
'email' => 'Jean.DUPONT@ACME.FR',
'categories' => ['/api/categories/'.$cat->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
$data = $response->toArray();
// RG-1.18 / 1.19 / 1.20 / 1.21
self::assertSame('ACME SAS', $data['companyName']);
self::assertSame('Jean', $data['firstName']);
self::assertSame('Dupont', $data['lastName']);
self::assertSame('0612345678', $data['phonePrimary']);
self::assertSame('jean.dupont@acme.fr', $data['email']);
self::assertFalse($data['isArchived']);
}
public function testPostDuplicateCompanyNameReturns409(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$iri = '/api/categories/'.$cat->getId();
$payload = [
'companyName' => 'Doublon SARL',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'dup@test.fr',
'categories' => [$iri],
];
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]);
self::assertResponseStatusCodeSame(201);
// Meme nom (insensible a la casse via l'index LOWER) -> 409 (RG-1.16).
$payload['email'] = 'dup2@test.fr';
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]);
self::assertResponseStatusCodeSame(409);
}
public function testPostWithoutFirstOrLastNameReturns422(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'No Contact Name',
'phonePrimary' => '0102030405',
'email' => 'nc@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
],
]);
// RG-1.01
self::assertResponseStatusCodeSame(422);
}
public function testPostWithoutCategoryReturns422(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'No Category',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'nocat@test.fr',
'categories' => [],
],
]);
// Assert\Count(min: 1)
self::assertResponseStatusCodeSame(422);
}
public function testPostWithDistributorAndBrokerReturns422(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$distributor = $this->seedClient('Distrib Mutex', false, 'DISTRIBUTEUR');
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Mutex Client',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'mutex@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
'distributor' => '/api/clients/'.$distributor->getId(),
'broker' => '/api/clients/'.$distributor->getId(),
],
]);
// RG-1.03 (exclusivite)
self::assertResponseStatusCodeSame(422);
}
public function testPostDistributorReferencingNonDistributorReturns422(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$notDistro = $this->seedClient('Pas Un Distrib', false, 'SECTEUR');
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Bad Distrib Ref',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'baddistrib@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
'distributor' => '/api/clients/'.$notDistro->getId(),
],
]);
// RG-1.03 (le distributor doit etre categorise DISTRIBUTEUR)
self::assertResponseStatusCodeSame(422);
}
public function testPostValidDistributorReturns201(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$distributor = $this->seedClient('Vrai Distrib', false, 'DISTRIBUTEUR');
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Client Avec Distrib',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'okdistrib@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
'distributor' => '/api/clients/'.$distributor->getId(),
],
]);
self::assertResponseStatusCodeSame(201);
}
public function testListSortedByCompanyNameAscAndExcludesArchived(): void
{
$client = $this->createAdminClient();
$this->seedClient('Zebra Co');
$this->seedClient('Alpha Co');
$this->seedClient('Archivé Co', true);
$names = $client->request('GET', '/api/clients?pagination=false', [
'headers' => ['Accept' => self::LD],
])->toArray()['member'];
$companyNames = array_map(static fn (array $c): string => $c['companyName'], $names);
// RG-1.24 : l'archive est exclue par defaut.
self::assertNotContains('ARCHIVÉ CO', $companyNames);
// RG-1.26 : tri companyName ASC (Alpha avant Zebra).
$alpha = array_search('ALPHA CO', $companyNames, true);
$zebra = array_search('ZEBRA CO', $companyNames, true);
self::assertNotFalse($alpha);
self::assertNotFalse($zebra);
self::assertLessThan($zebra, $alpha);
}
public function testListIncludeArchivedReturnsArchived(): void
{
$client = $this->createAdminClient();
$this->seedClient('Hidden Archived', true);
$members = $client->request('GET', '/api/clients?includeArchived=true&pagination=false', [
'headers' => ['Accept' => self::LD],
])->toArray()['member'];
$names = array_map(static fn (array $c): string => $c['companyName'], $members);
// RG-1.25
self::assertContains('HIDDEN ARCHIVED', $names);
}
public function testCollectionIsPaginated(): void
{
$client = $this->createAdminClient();
$this->seedClient('Paginated One');
// Collection Hydra avec total (la cle `view` n'apparait qu'a partir de
// 2 pages cote API Platform 4, donc non assertable sur page unique).
$page1 = $client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('totalItems', $page1);
self::assertNotEmpty($page1['member']);
// Preuve que la pagination serveur est bien engagee : la page 2 d'un jeu
// tenant sur une page est vide (un provider non pagine ignorerait `page`
// et renverrait quand meme les items).
$page2 = $client->request('GET', '/api/clients?page=2', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertSame([], $page2['member']);
}
public function testPatchArchiveSetsArchivedAtThenRestore(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('To Archive');
$iri = '/api/clients/'.$seed->getId();
// Archive (RG-1.22) : admin a la permission archive via bypass isAdmin.
$archived = $client->request('PATCH', $iri, [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['isArchived' => true],
])->toArray();
self::assertResponseStatusCodeSame(200);
self::assertTrue($archived['isArchived']);
self::assertNotNull($archived['archivedAt']);
// Restauration (RG-1.23) : archivedAt repasse a null.
$restored = $client->request('PATCH', $iri, [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['isArchived' => false],
])->toArray();
self::assertResponseStatusCodeSame(200);
self::assertFalse($restored['isArchived']);
self::assertNull($restored['archivedAt']);
}
public function testPatchArchiveWithOtherFieldReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Archive Plus Field');
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['isArchived' => true, 'companyName' => 'Renamed'],
]);
// RG-1.22 : une requete d'archivage ne modifie aucun autre champ.
self::assertResponseStatusCodeSame(422);
}
public function testGetDetailEmbedsSubCollections(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Detail Embed');
$data = $client->request('GET', '/api/clients/'.$seed->getId(), [
'headers' => ['Accept' => self::LD],
])->toArray();
// § 4.2 : le detail embarque contacts / adresses / ribs.
self::assertArrayHasKey('contacts', $data);
self::assertArrayHasKey('addresses', $data);
self::assertArrayHasKey('ribs', $data);
}
}