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:
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
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\QueryBuilder;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Provider du repertoire clients (M1). Cf. spec-back M1 § 4.1 / § 4.2.
|
||||
*
|
||||
* Collection (GET /api/clients) :
|
||||
* - exclut par defaut les archives (is_archived = true) ET les soft-deletes
|
||||
* (deleted_at IS NOT NULL) — RG-1.24 ;
|
||||
* - ?includeArchived=true reintegre les archives (les soft-deletes restent
|
||||
* exclus au M1) — RG-1.25 ;
|
||||
* - tri par defaut companyName ASC — RG-1.26 ;
|
||||
* - filtres ?search=... (fuzzy companyName + lastName + email) et
|
||||
* ?categoryType=<code> (clients ayant >= 1 categorie de ce type) ;
|
||||
* - pagination obligatoire (convention Starseed ERP-72) : Paginator ORM ;
|
||||
* echappatoire ?pagination=false pour alimenter un <select> sans pagination.
|
||||
*
|
||||
* Item (GET /api/clients/{id} + provider de PATCH) :
|
||||
* - 404 si introuvable OU soft-delete (deleted_at non null, jamais expose au
|
||||
* M1) ; les archives restent consultables/restaurables en detail.
|
||||
*
|
||||
* Le filtrage des champs comptables en lecture (groupe client:read:accounting)
|
||||
* n'est PAS fait ici mais dans ClientReadGroupContextBuilder (le provider ne
|
||||
* peut pas influencer les groupes de serialisation).
|
||||
*
|
||||
* @implements ProviderInterface<Client>
|
||||
*/
|
||||
final class ClientProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')]
|
||||
private readonly ClientRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Client|iterable|Paginator|null
|
||||
{
|
||||
if ($operation instanceof CollectionOperationInterface) {
|
||||
return $this->provideCollection($operation, $context);
|
||||
}
|
||||
|
||||
return $this->provideItem($uriVariables);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*
|
||||
* @return list<Client>|Paginator<Client>
|
||||
*/
|
||||
private function provideCollection(Operation $operation, array $context): array|Paginator
|
||||
{
|
||||
$filters = $context['filters'] ?? [];
|
||||
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
|
||||
|
||||
$qb = $this->repository->createListQueryBuilder($includeArchived);
|
||||
$this->applySearch($qb, $filters['search'] ?? null);
|
||||
$this->applyCategoryType($qb, $filters['categoryType'] ?? null);
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||
// (cf. convention ERP-72 — utile pour un <select> cote front).
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
/** @var list<Client> $result */
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
$limit = $this->pagination->getLimit($operation, $context);
|
||||
$page = max(1, $this->pagination->getPage($context));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||
|
||||
// fetchJoinCollection: true pour un COUNT correct des que des JOINs
|
||||
// to-many seront ajoutes (sous-collections embarquees en detail).
|
||||
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $uriVariables
|
||||
*/
|
||||
private function provideItem(array $uriVariables): ?Client
|
||||
{
|
||||
$id = $uriVariables['id'] ?? null;
|
||||
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$client = $this->repository->findById((int) $id);
|
||||
if (null === $client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Soft-delete : jamais expose au M1 (HP-M2-1) — 404 via retour null.
|
||||
// Les archives restent visibles en detail (consultation + restauration).
|
||||
if (null !== $client->getDeletedAt()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
|
||||
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
|
||||
* litteraux.
|
||||
*/
|
||||
private function applySearch(QueryBuilder $qb, mixed $search): void
|
||||
{
|
||||
if (!is_string($search) || '' === trim($search)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
||||
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
||||
|
||||
$qb->andWhere(
|
||||
'LOWER(c.companyName) LIKE :search '
|
||||
.'OR LOWER(c.lastName) LIKE :search '
|
||||
.'OR LOWER(c.email) LIKE :search',
|
||||
)->setParameter('search', $pattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restreint aux clients possedant au moins une categorie du type donne.
|
||||
* Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas
|
||||
* perturber le DISTINCT / ORDER BY de la requete paginee principale.
|
||||
*/
|
||||
private function applyCategoryType(QueryBuilder $qb, mixed $categoryType): void
|
||||
{
|
||||
if (!is_string($categoryType) || '' === trim($categoryType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sub = $this->repository->createQueryBuilder('c2')
|
||||
->select('c2.id')
|
||||
->join('c2.categories', 'cat2')
|
||||
->join('cat2.categoryType', 'ct2')
|
||||
->where('ct2.code = :categoryType')
|
||||
;
|
||||
|
||||
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
|
||||
->setParameter('categoryType', trim($categoryType))
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
||||
*/
|
||||
private function readBool(mixed $raw): bool
|
||||
{
|
||||
if (is_bool($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
|
||||
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user