feat(transport) : endpoint recherche QualimatCarrier (ERP-156)

GET /api/qualimat_carriers?search= pour la saisie assistee du nom (RG-4.01,
spec-back § 4.7) : recherche fuzzy sur name (+ siret), restreinte aux lignes
actives (is_active = true), triee name ASC, paginee (regle n°13).

- QualimatCarrierRepositoryInterface + DoctrineQualimatCarrierRepository :
  QueryBuilder de recherche (forcage is_active cote serveur, fuzzy multi-champs).
- QualimatCarrierSearchProvider : provider de la GetCollection (pagination Hydra
  + echappatoire ?pagination=false), branche uniquement sur la collection.
- ApiResource : provider custom sur GetCollection, retrait des ApiFilter natifs
  (incapables d'unifier name/siret sous ?search= ni d'imposer l'actif). Mapping
  ORM inchange (schema:update reste no-op). Aucune ecriture exposee.
- Tests : actifs seuls, tri name, match siret, pagination Hydra, 403 sans perm.
This commit is contained in:
Matthieu
2026-06-16 08:34:28 +02:00
parent 97d7cacd2c
commit 456c6682b0
5 changed files with 285 additions and 9 deletions
@@ -4,13 +4,10 @@ declare(strict_types=1);
namespace App\Module\Transport\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Transport\Infrastructure\ApiPlatform\State\Provider\QualimatCarrierSearchProvider;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -26,8 +23,9 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
* - cible de la FK editable `carrier.qualimat_carrier_id` (§ 2.5) ;
* - embarquee (groupe `qualimat:read`) dans la liste et le detail Carrier pour
* afficher statut + date de validite QUALIMAT (RG-4.04) ;
* - endpoint de recherche `GET /api/qualimat_carriers?...` pour la saisie
* assistee du nom (§ 4.7) — filtres built-in name/siret (partiel), isActive.
* - endpoint de recherche `GET /api/qualimat_carriers?search=` pour la saisie
* assistee du nom (§ 4.7) — fuzzy name (+ siret), SEULEMENT les lignes actives,
* tri name ASC, paginee ; logique portee par QualimatCarrierSearchProvider.
*
* La table reste hors `schema_filter` Doctrine (doctrine.yaml) : c'est la
* migration modulaire Version20260612150000 qui possede son DDL et ses COMMENT
@@ -36,8 +34,14 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
*/
#[ApiResource(
operations: [
// Saisie assistee (§ 4.7 / RG-4.01) : ?search= fuzzy name (+ siret),
// SEULEMENT les lignes actives, tri name ASC, paginee. La logique vit
// dans le provider (forcage is_active + recherche multi-champs) car un
// SearchFilter natif ne sait ni unifier name/siret sous un seul ?search=,
// ni imposer cote serveur le filtre actif.
new GetCollection(
security: "is_granted('transport.carriers.view')",
provider: QualimatCarrierSearchProvider::class,
normalizationContext: ['groups' => ['qualimat:read', 'default:read']],
),
new Get(
@@ -46,9 +50,6 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
),
],
)]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'siret' => 'partial'])]
#[ApiFilter(BooleanFilter::class, properties: ['isActive'])]
#[ApiFilter(OrderFilter::class, properties: ['name'], arguments: ['orderParameterName' => 'order'])]
#[ORM\Entity]
// Mapping reproduisant a l'identique le DDL de la migration ERP-39
// (Version20260612150000) pour que `schema:update --force` reste un no-op :
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Domain\Repository;
use Doctrine\ORM\QueryBuilder;
/**
* Contrat du repository du referentiel QUALIMAT (M4, lecture seule). Implementation
* Doctrine : App\Module\Transport\Infrastructure\Doctrine\DoctrineQualimatCarrierRepository.
*
* La table `qualimat_carrier` est alimentee/soft-deletee EXCLUSIVEMENT par la
* commande console `app:qualimat:sync` : ce contrat n'expose que de la lecture.
*/
interface QualimatCarrierRepositoryInterface
{
/**
* QueryBuilder de la saisie assistee (§ 4.7 / RG-4.01). Restreint aux lignes
* actives (is_active = true), recherche fuzzy sur name (+ siret), tri name ASC.
*
* @param null|string $search texte de recherche libre (fuzzy name + siret)
*/
public function createSearchQueryBuilder(?string $search = null): QueryBuilder;
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Transport\Domain\Entity\QualimatCarrier;
use App\Module\Transport\Domain\Repository\QualimatCarrierRepositoryInterface;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider de la saisie assistee QUALIMAT (spec-back § 4.7 / RG-4.01).
*
* GET /api/qualimat_carriers?search=<texte> :
* - restreint aux lignes actives (is_active = true) — regle serveur, pas un
* filtre client desactivable ;
* - recherche fuzzy insensible a la casse sur name (+ siret) ;
* - tri par name ASC ;
* - pagination Hydra (regle n°13) + echappatoire ?pagination=false (selects).
*
* Branche uniquement sur la GetCollection ; le Get unitaire reste servi par le
* provider ORM par defaut (lecture seule, aucune ecriture exposee).
*
* @implements ProviderInterface<QualimatCarrier>
*/
final class QualimatCarrierSearchProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineQualimatCarrierRepository')]
private readonly QualimatCarrierRepositoryInterface $repository,
private readonly Pagination $pagination,
) {}
/**
* @return list<QualimatCarrier>|Paginator<QualimatCarrier>
*/
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|Paginator
{
$filters = $context['filters'] ?? [];
$search = $filters['search'] ?? null;
$qb = $this->repository->createSearchQueryBuilder(is_string($search) ? $search : null);
// Echappatoire ?pagination=false : collection complete (selects front).
if (!$this->pagination->isEnabled($operation, $context)) {
/** @var list<QualimatCarrier> $carriers */
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: false — aucune jointure to-many (referentiel plat).
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\Doctrine;
use App\Module\Transport\Domain\Entity\QualimatCarrier;
use App\Module\Transport\Domain\Repository\QualimatCarrierRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<QualimatCarrier>
*/
class DoctrineQualimatCarrierRepository extends ServiceEntityRepository implements QualimatCarrierRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, QualimatCarrier::class);
}
public function createSearchQueryBuilder(?string $search = null): QueryBuilder
{
// Saisie assistee (§ 4.7) : on ne propose QUE des transporteurs QUALIMAT
// actifs (is_active = true), tries par nom. Le forcage de l'actif est une
// regle serveur (pas un filtre client) — les lignes soft-deletees par la
// synchro restent invisibles.
$qb = $this->createQueryBuilder('q')
->andWhere('q.isActive = true')
->orderBy('q.name', 'ASC')
;
$this->applySearch($qb, $search);
return $qb;
}
/**
* Recherche fuzzy insensible a la casse sur le nom (+ siret) du transporteur
* QUALIMAT (§ 4.7 / RG-4.01). Metacaracteres LIKE (%, _, \) echappes pour
* rester litteraux.
*/
private function applySearch(QueryBuilder $qb, ?string $search): void
{
if (null === $search || '' === trim($search)) {
return;
}
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
$qb->andWhere('LOWER(q.name) LIKE :search OR LOWER(q.siret) LIKE :search')
->setParameter('search', $pattern)
;
}
}