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:
@@ -4,13 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Module\Transport\Domain\Entity;
|
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\ApiResource;
|
||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use App\Module\Transport\Infrastructure\ApiPlatform\State\Provider\QualimatCarrierSearchProvider;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
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) ;
|
* - cible de la FK editable `carrier.qualimat_carrier_id` (§ 2.5) ;
|
||||||
* - embarquee (groupe `qualimat:read`) dans la liste et le detail Carrier pour
|
* - embarquee (groupe `qualimat:read`) dans la liste et le detail Carrier pour
|
||||||
* afficher statut + date de validite QUALIMAT (RG-4.04) ;
|
* afficher statut + date de validite QUALIMAT (RG-4.04) ;
|
||||||
* - endpoint de recherche `GET /api/qualimat_carriers?...` pour la saisie
|
* - endpoint de recherche `GET /api/qualimat_carriers?search=` pour la saisie
|
||||||
* assistee du nom (§ 4.7) — filtres built-in name/siret (partiel), isActive.
|
* 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
|
* La table reste hors `schema_filter` Doctrine (doctrine.yaml) : c'est la
|
||||||
* migration modulaire Version20260612150000 qui possede son DDL et ses COMMENT
|
* migration modulaire Version20260612150000 qui possede son DDL et ses COMMENT
|
||||||
@@ -36,8 +34,14 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
|||||||
*/
|
*/
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
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(
|
new GetCollection(
|
||||||
security: "is_granted('transport.carriers.view')",
|
security: "is_granted('transport.carriers.view')",
|
||||||
|
provider: QualimatCarrierSearchProvider::class,
|
||||||
normalizationContext: ['groups' => ['qualimat:read', 'default:read']],
|
normalizationContext: ['groups' => ['qualimat:read', 'default:read']],
|
||||||
),
|
),
|
||||||
new Get(
|
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]
|
#[ORM\Entity]
|
||||||
// Mapping reproduisant a l'identique le DDL de la migration ERP-39
|
// Mapping reproduisant a l'identique le DDL de la migration ERP-39
|
||||||
// (Version20260612150000) pour que `schema:update --force` reste un no-op :
|
// (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;
|
||||||
|
}
|
||||||
+64
@@ -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)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Transport\Api;
|
||||||
|
|
||||||
|
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||||
|
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
|
use Symfony\Component\Console\Input\ArrayInput;
|
||||||
|
use Symfony\Component\Console\Output\NullOutput;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint de recherche du referentiel QUALIMAT (spec-back M4 § 4.7 / RG-4.01,
|
||||||
|
* ERP-156). Saisie assistee du nom : GET /api/qualimat_carriers?search= .
|
||||||
|
*
|
||||||
|
* Contrat verifie :
|
||||||
|
* - recherche fuzzy sur name (+ siret), SEULEMENT les lignes actives ;
|
||||||
|
* - tri name ASC ;
|
||||||
|
* - enveloppe Hydra paginee (member / totalItems / view — regle n°13) ;
|
||||||
|
* - 403 sans la permission transport.carriers.view (compta/usine, matrice § 5.2).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class QualimatCarrierSearchTest extends AbstractCarrierApiTestCase
|
||||||
|
{
|
||||||
|
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
|
||||||
|
|
||||||
|
/** Prefixe SIRET dedie (purge par AbstractCarrierApiTestCase::tearDown). */
|
||||||
|
private const string SIRET_PREFIX = 'TESTQ';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin
|
||||||
|
// qu'en recette), requis pour le test de permission (usine sans acces).
|
||||||
|
self::bootKernel();
|
||||||
|
$application = new Application(self::$kernel);
|
||||||
|
$application->setAutoExit(false);
|
||||||
|
$exit = $application->run(
|
||||||
|
new ArrayInput([
|
||||||
|
'command' => 'app:seed-rbac',
|
||||||
|
'--with-demo-users' => true,
|
||||||
|
'--password' => self::PWD,
|
||||||
|
]),
|
||||||
|
new NullOutput(),
|
||||||
|
);
|
||||||
|
self::assertSame(0, $exit, 'app:seed-rbac a echoue (permissions transport.carriers.* synchronisees ?).');
|
||||||
|
|
||||||
|
self::ensureKernelShutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSearchReturnsOnlyActiveOrderedByName(): void
|
||||||
|
{
|
||||||
|
// Marqueur unique partage par les 3 lignes : isole la recherche d'eventuelles
|
||||||
|
// autres lignes du referentiel.
|
||||||
|
$this->insertQualimat('QSEARCH GAMMA', true, 'A1');
|
||||||
|
$this->insertQualimat('QSEARCH ALPHA', true, 'A2');
|
||||||
|
$this->insertQualimat('QSEARCH BETA', false, 'A3'); // inactive -> exclue
|
||||||
|
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('GET', '/api/qualimat_carriers?search=qsearch', ['headers' => ['Accept' => self::LD]]);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$data = $client->getResponse()->toArray();
|
||||||
|
$names = array_column($data['member'], 'name');
|
||||||
|
|
||||||
|
self::assertSame(2, $data['totalItems'], 'Seules les 2 lignes actives doivent remonter (BETA inactive exclue).');
|
||||||
|
self::assertSame(['QSEARCH ALPHA', 'QSEARCH GAMMA'], $names, 'Tri name ASC, sans la ligne inactive.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSearchMatchesSiret(): void
|
||||||
|
{
|
||||||
|
// Le nom ne porte pas le marqueur : la correspondance se fait via le siret.
|
||||||
|
$this->insertQualimat('TRANSPORTEUR SANS MARQUEUR', true, 'SIRETHIT1');
|
||||||
|
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('GET', '/api/qualimat_carriers?search=testqsirethit1', ['headers' => ['Accept' => self::LD]]);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$data = $client->getResponse()->toArray();
|
||||||
|
self::assertSame(1, $data['totalItems'], 'La recherche fuzzy doit aussi cibler le siret.');
|
||||||
|
self::assertSame('TRANSPORTEUR SANS MARQUEUR', $data['member'][0]['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCollectionExposesHydraPagination(): void
|
||||||
|
{
|
||||||
|
$this->insertQualimat('QPAGE UN', true, 'P1');
|
||||||
|
$this->insertQualimat('QPAGE DEUX', true, 'P2');
|
||||||
|
$this->insertQualimat('QPAGE TROIS', true, 'P3');
|
||||||
|
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('GET', '/api/qualimat_carriers?search=qpage&itemsPerPage=2', ['headers' => ['Accept' => self::LD]]);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$data = $client->getResponse()->toArray();
|
||||||
|
self::assertArrayHasKey('totalItems', $data, 'La collection doit exposer totalItems.');
|
||||||
|
self::assertArrayHasKey('view', $data, 'La collection doit exposer view quand totalItems > itemsPerPage.');
|
||||||
|
self::assertIsArray($data['member']);
|
||||||
|
self::assertSame(3, $data['totalItems']);
|
||||||
|
self::assertCount(2, $data['member'], 'La page doit etre bornee a itemsPerPage=2.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testForbiddenWithoutPermission(): void
|
||||||
|
{
|
||||||
|
// Usine : aucun acces transporteurs (matrice § 5.2) -> 403 sur la recherche.
|
||||||
|
$client = $this->authenticatedClient('usine', self::PWD);
|
||||||
|
$client->request('GET', '/api/qualimat_carriers', ['headers' => ['Accept' => self::LD]]);
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insere une ligne qualimat_carrier de test en DBAL brut (l'entite mappee est
|
||||||
|
* en lecture seule). SIRET prefixe TESTQ pour la purge ciblee du tearDown.
|
||||||
|
*/
|
||||||
|
private function insertQualimat(string $name, bool $isActive, string $siretSuffix): void
|
||||||
|
{
|
||||||
|
$this->getEm()->getConnection()->insert('qualimat_carrier', [
|
||||||
|
'siret' => self::SIRET_PREFIX.$siretSuffix,
|
||||||
|
'name' => $name,
|
||||||
|
'status' => 'Valide',
|
||||||
|
'validity_date' => '2027-12-31',
|
||||||
|
'is_active' => $isActive ? 'true' : 'false',
|
||||||
|
'last_synced_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user