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

Merged
matthieu merged 2 commits from feat/erp-156-qualimat-search into feat/erp-158-carrier-processor 2026-06-16 13:49:02 +00:00
Owner

Entité lecture seule + GET /api/qualimat_carriers?search=. Ticket ERP-156.

Recherche fuzzy name (+ siret), restreinte aux lignes actives (is_active = true), triée name ASC, paginée (règle n°13). Security transport.carriers.view. Aucune écriture exposée. Mapping ORM inchangé (schema:update reste no-op).

Implémentation : QualimatCarrierSearchProvider sur la GetCollection + repository de recherche (forçage actif côté serveur, ?search= unifié name/siret). Tests : actifs seuls, tri name, match siret, pagination Hydra, 403 sans permission.

Entité lecture seule + GET /api/qualimat_carriers?search=. Ticket ERP-156. Recherche fuzzy name (+ siret), restreinte aux lignes actives (is_active = true), triée name ASC, paginée (règle n°13). Security transport.carriers.view. Aucune écriture exposée. Mapping ORM inchangé (schema:update reste no-op). Implémentation : QualimatCarrierSearchProvider sur la GetCollection + repository de recherche (forçage actif côté serveur, ?search= unifié name/siret). Tests : actifs seuls, tri name, match siret, pagination Hydra, 403 sans permission.
matthieu added the backM4-Transporteurtype/feat labels 2026-06-16 06:35:04 +00:00
tristan reviewed 2026-06-16 09:41:21 +00:00
tristan left a comment
Owner

Code review ERP-156 (endpoint recherche QualimatCarrier — saisie assistée RG-4.01) — relue contre spec-back § 4.7.

Verdict : PR petite, propre, aucun constat bloquant. Elle remplace les ApiFilter natifs par un provider custom — bon choix, car un SearchFilter natif ne sait ni unifier name/siret sous un seul ?search= ni imposer côté serveur le filtre is_active. Conforme § 4.7 : fuzzy name+siret, actifs seuls, tri name ASC, pagination Hydra (règle n°13) + échappatoire ?pagination=false, lecture seule (aucune opération d'écriture), security transport.carriers.view. Échappement des métacaractères LIKE présent. Tests : actifs-seuls+tri, match siret, enveloppe Hydra paginée, 403 sans permission.

Quelques 🟡 mineurs en inline (longueur min de recherche / volumétrie du référentiel, injection repo, trous de tests). Rien qui bloque.

Code review ERP-156 (endpoint recherche QualimatCarrier — saisie assistée RG-4.01) — relue contre spec-back § 4.7. **Verdict : PR petite, propre, aucun constat bloquant.** Elle remplace les `ApiFilter` natifs par un provider custom — bon choix, car un `SearchFilter` natif ne sait ni unifier `name`/`siret` sous un seul `?search=` ni **imposer côté serveur** le filtre `is_active`. Conforme § 4.7 : fuzzy name+siret, actifs seuls, tri name ASC, pagination Hydra (règle n°13) + échappatoire `?pagination=false`, lecture seule (aucune opération d'écriture), security `transport.carriers.view`. Échappement des métacaractères LIKE présent. Tests : actifs-seuls+tri, match siret, enveloppe Hydra paginée, 403 sans permission. Quelques 🟡 mineurs en inline (longueur min de recherche / volumétrie du référentiel, injection repo, trous de tests). Rien qui bloque.
@@ -39,2 +41,4 @@
// ni imposer cote serveur le filtre actif.
new GetCollection(
security: "is_granted('transport.carriers.view')",
provider: QualimatCarrierSearchProvider::class,
Owner

🟢 Bon arbitrage : remplacer les ApiFilter natifs par un provider custom. Un SearchFilter natif ne peut ni unifier name/siret sous un seul ?search=, ni forcer is_active=true côté serveur (un BooleanFilter reste désactivable par le client). Le provider garantit ces deux invariants. Read-only confirmé (seulement Get + GetCollection, alimentation par app:qualimat:sync), security transport.carriers.view. RAS.

🟢 Bon arbitrage : remplacer les `ApiFilter` natifs par un provider custom. Un `SearchFilter` natif ne peut ni unifier `name`/`siret` sous un seul `?search=`, ni **forcer** `is_active=true` côté serveur (un `BooleanFilter` reste désactivable par le client). Le provider garantit ces deux invariants. Read-only confirmé (seulement `Get` + `GetCollection`, alimentation par `app:qualimat:sync`), security `transport.carriers.view`. RAS.
@@ -0,0 +31,4 @@
final class QualimatCarrierSearchProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineQualimatCarrierRepository')]
Owner

🟡 Mineur, identique à mon constat sur #112 (CarrierProvider:38) : repository injecté via #[Autowire(service: '…\DoctrineQualimatCarrierRepository')] (FQCN de l'impl) alors que le type-hint est l'interface. Couple le provider au FQCN de l'impl ; un alias DI QualimatCarrierRepositoryInterface -> Doctrine… serait plus idiomatique. À traiter de façon cohérente avec #112 (même pattern dans les deux providers).

🟡 Mineur, identique à mon constat sur #112 (`CarrierProvider:38`) : repository injecté via `#[Autowire(service: '…\DoctrineQualimatCarrierRepository')]` (FQCN de l'impl) alors que le type-hint est l'interface. Couple le provider au FQCN de l'impl ; un alias DI `QualimatCarrierRepositoryInterface -> Doctrine…` serait plus idiomatique. À traiter de façon cohérente avec #112 (même pattern dans les deux providers).
@@ -0,0 +59,4 @@
$qb->setFirstResult($offset)->setMaxResults($limit);
// fetchJoinCollection: false — aucune jointure to-many (referentiel plat).
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
Owner

🟢 Pagination Hydra correcte (jumeau de CarrierProvider) : Paginator wrappant le DoctrinePaginator, fetchJoinCollection: false justifié (référentiel plat, pas de to-many), et échappatoire ?pagination=false gérée (l.50-53). totalItems/view préservés. RAS.

🟢 Pagination Hydra correcte (jumeau de `CarrierProvider`) : `Paginator` wrappant le `DoctrinePaginator`, `fetchJoinCollection: false` justifié (référentiel plat, pas de to-many), et échappatoire `?pagination=false` gérée (l.50-53). `totalItems`/`view` préservés. RAS.
@@ -0,0 +43,4 @@
*/
private function applySearch(QueryBuilder $qb, ?string $search): void
{
if (null === $search || '' === trim($search)) {
Owner

🟡 Durcissement optionnel (volumétrie). qualimat_carrier est un référentiel synchronisé quotidiennement depuis qualimat.org → potentiellement plusieurs milliers de lignes. Or :

  • un ?search= vide (ou très court) renvoie toutes les lignes actives (paginées à 10, OK) ;
  • mais ?pagination=false sur cet endpoint renverrait l'intégralité du référentiel en une fois (deep-fetch).

La spec § 4.7 ne fixe pas de longueur minimale, donc tu es conforme. Mais pour une saisie assistée, envisager : (a) une longueur min de recherche (ex. ≥2 caractères, sinon collection vide) côté provider, et/ou (b) ne pas exposer ?pagination=false ici (contrairement aux petits référentiels clients/fournisseurs). Non bloquant.

🟡 Durcissement optionnel (volumétrie). `qualimat_carrier` est un référentiel synchronisé quotidiennement depuis qualimat.org → potentiellement plusieurs milliers de lignes. Or : - un `?search=` vide (ou très court) renvoie **toutes** les lignes actives (paginées à 10, OK) ; - mais `?pagination=false` sur cet endpoint renverrait **l'intégralité** du référentiel en une fois (deep-fetch). La spec § 4.7 ne fixe pas de longueur minimale, donc tu es conforme. Mais pour une saisie assistée, envisager : (a) une longueur min de recherche (ex. ≥2 caractères, sinon collection vide) côté provider, et/ou (b) ne pas exposer `?pagination=false` ici (contrairement aux petits référentiels clients/fournisseurs). Non bloquant.
@@ -0,0 +103,4 @@
self::assertCount(2, $data['member'], 'La page doit etre bornee a itemsPerPage=2.');
}
public function testForbiddenWithoutPermission(): void
Owner

🟡 Trous de couverture (mineurs, non bloquants) :

  • ?pagination=false (chemin de code distinct retournant un array — non exercé, comme sur #112) ;
  • recherche vide / 1 caractère (comportement « tout le référentiel » non figé par un test — pertinent si tu ajoutes une longueur min) ;
  • échappement LIKE (un ?search=% doit être traité littéralement — la logique existe l.50 mais n'est pas testée).

Le cœur du contrat (actifs-seuls, tri, siret, Hydra, 403) est bien couvert.

🟡 Trous de couverture (mineurs, non bloquants) : - `?pagination=false` (chemin de code distinct retournant un array — non exercé, comme sur #112) ; - recherche vide / 1 caractère (comportement « tout le référentiel » non figé par un test — pertinent si tu ajoutes une longueur min) ; - échappement LIKE (un `?search=%` doit être traité littéralement — la logique existe l.50 mais n'est pas testée). Le cœur du contrat (actifs-seuls, tri, siret, Hydra, 403) est bien couvert.
matthieu force-pushed feat/erp-156-qualimat-search from 456c6682b0 to df3b924b17 2026-06-16 13:06:28 +00:00 Compare
matthieu added 1 commit 2026-06-16 13:15:02 +00:00
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.
matthieu force-pushed feat/erp-156-qualimat-search from df3b924b17 to 397fb22c62 2026-06-16 13:15:02 +00:00 Compare
matthieu added 1 commit 2026-06-16 13:47:35 +00:00
POST /api/carriers/{id}/addresses + PATCH/DELETE /api/carrier_addresses/{id}
(security transport.carriers.manage), spec-back § 4.5. Jumelle de SupplierAddress
(M2) / ProviderAddress (M3), sans address_type ni M2M.

- CarrierAddress : ajout #[ApiResource] (Get/Post/Patch/Delete) + groupe
  d'ecriture carrier:write:addresses + contraintes FR. RG-4.06 : code postal
  ^[0-9]{4,5}$ (Assert\Regex). Mapping ORM/colonnes inchange.
- CarrierAddressProcessor : rattachement parent (404 si absent) + RG-4.05
  (transporteur affrete -> Pays/CP/Ville/Adresse obligatoires, 422 par champ).
  RG-4.05 portee par le processor car le parent est indisponible a la validation
  Symfony sur un POST sous-ressource read:false. RG-4.07 = front (PATCH accepte).
- EXCLUDED_LENGTH_MIRROR : CarrierAddress::postalCode (Regex borne la longueur).
- Tests : CP invalide 422, affrete incomplet 422, affrete complet 201,
  PATCH/DELETE OK (manage), 403 sans manage.
matthieu merged commit 7012306a78 into feat/erp-158-carrier-processor 2026-06-16 13:49:02 +00:00
matthieu deleted branch feat/erp-156-qualimat-search 2026-06-16 13:49:02 +00:00
Sign in to join this conversation.