Compare commits

..

3 Commits

Author SHA1 Message Date
Matthieu 8570bd09f0 feat(transport) : sous-ressource contacts transporteur (ERP-160) 2026-06-16 10:18:10 +02:00
Matthieu 54ac034c1b feat(transport) : sous-ressource adresses transporteur (ERP-159)
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.
2026-06-16 09:19:20 +02:00
Matthieu 456c6682b0 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.
2026-06-16 08:34:28 +02:00
12 changed files with 1193 additions and 23 deletions
@@ -4,22 +4,86 @@ declare(strict_types=1);
namespace App\Module\Transport\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierAddressProcessor;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Adresse d'un transporteur (1:n) — onglet Adresse (M4). Jumelle de
* SupplierAddress (M2), version simplifiee (pas de type d'adresse, pas de M2M
* sites/categories sur l'adresse : les sites du M4 vivent dans l'onglet Prix).
*
* WT3 (ERP-155/157) = LECTURE seule : proprietes en `carrier:item:read`
* (embarquees au detail du transporteur). Les sous-ressources d'ecriture
* (POST/PATCH/DELETE) + RG-4.05→4.07 arrivent au worktree dedie (WT6).
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
* transporteur). Ecriture : groupe `carrier:write:addresses`.
*
* Sous-ressource API (ERP-159, spec § 4.5) — jumelle de SupplierAddress (M2) /
* ProviderAddress (M3), sans address_type ni M2M (les sites du M4 vivent dans
* l'onglet Prix) :
* - POST /api/carriers/{carrierId}/addresses : creation rattachee au
* transporteur parent (Link toProperty 'carrier'), security
* transport.carriers.manage.
* - PATCH / DELETE /api/carrier_addresses/{id} : security
* transport.carriers.manage.
* - GET /api/carrier_addresses/{id} : lecture unitaire (security view) — la
* lecture courante reste via le parent. Pas de GET collection autonome.
* Tout passe par le CarrierAddressProcessor (rattachement parent + RG-4.05).
*
* Regles de l'onglet Adresse :
* - RG-4.06 : code postal a 4 ou 5 chiffres (Assert\Regex ; pas de controle
* CP/ville serveur, l'autocomplete BAN est front).
* - RG-4.05 : si le transporteur est affrete (isChartered), l'adresse devient
* obligatoire (Pays / CP / Ville / Adresse) — validation conditionnelle portee
* par le CarrierAddressProcessor (le parent n'est pas disponible a la
* validation Symfony sur un POST sous-ressource en read:false).
* - RG-4.07 : masquage du bouton « Valider » si QUALIMAT = front ; le back
* accepte le PATCH normalement (aucune garde back specifique).
*
* Audite (#[Auditable]) + Timestampable / Blamable.
*/
#[ApiResource(
operations: [
new Get(
security: "is_granted('transport.carriers.view')",
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
),
new Post(
uriTemplate: '/carriers/{carrierId}/addresses',
uriVariables: [
'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'),
],
// read:false : pas de stade lecture du parent. Le Link toProperty
// resoudrait l'enfant (SELECT CarrierAddress ... WHERE carrier = :id)
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
// manuellement par CarrierAddressProcessor::linkParent (404 si absent).
read: false,
security: "is_granted('transport.carriers.manage')",
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
denormalizationContext: ['groups' => ['carrier:write:addresses']],
processor: CarrierAddressProcessor::class,
),
new Patch(
security: "is_granted('transport.carriers.manage')",
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
denormalizationContext: ['groups' => ['carrier:write:addresses']],
processor: CarrierAddressProcessor::class,
),
new Delete(
security: "is_granted('transport.carriers.manage')",
processor: CarrierAddressProcessor::class,
),
],
)]
#[ORM\Entity]
#[ORM\Table(name: 'carrier_address')]
#[ORM\Index(name: 'idx_carrier_address_carrier', columns: ['carrier_id'])]
@@ -41,23 +105,32 @@ class CarrierAddress implements TimestampableInterface, BlamableInterface
private ?Carrier $carrier = null;
#[ORM\Column(length: 80, options: ['default' => 'France'])]
#[Groups(['carrier:item:read'])]
#[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
private string $country = 'France';
// RG-4.06 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur,
// l'autocomplete BAN est front). Le Regex borne deja la longueur (<= 5) : pas
// de Length redondant (whitelist EXCLUDED_LENGTH_MIRROR). Nullable : obligatoire
// seulement si affrete (RG-4.05, garde CarrierAddressProcessor).
#[ORM\Column(name: 'postal_code', length: 20, nullable: true)]
#[Groups(['carrier:item:read'])]
#[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')]
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
private ?string $postalCode = null;
#[ORM\Column(length: 120, nullable: true)]
#[Groups(['carrier:item:read'])]
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
private ?string $city = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['carrier:item:read'])]
#[Assert\Length(max: 255, maxMessage: 'L\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
private ?string $street = null;
#[ORM\Column(name: 'street_complement', length: 255, nullable: true)]
#[Groups(['carrier:item:read'])]
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
private ?string $streetComplement = null;
#[ORM\Column(options: ['default' => 0])]
@@ -4,21 +4,80 @@ declare(strict_types=1);
namespace App\Module\Transport\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierContactProcessor;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Contact d'un transporteur (1:n) — onglet Contact (M4). Jumeau de
* SupplierContact (M2) : au moins un champ rempli (RG-4.08, garanti par le
* CHECK chk_carrier_contact_filled + le Processor), max 2 telephones.
*
* WT3 (ERP-155/157) = LECTURE seule : proprietes en `carrier:item:read`
* (embarquees au detail). Les sous-ressources d'ecriture arrivent au WT7.
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
* transporteur). Ecriture : groupe `carrier:write:contacts`.
*
* Sous-ressource API (ERP-160, spec § 4.5) — jumelle de SupplierContact (M2) :
* - POST /api/carriers/{carrierId}/contacts : creation rattachee au
* transporteur parent (Link toProperty 'carrier'), security
* transport.carriers.manage.
* - PATCH / DELETE /api/carrier_contacts/{id} : security
* transport.carriers.manage.
* - GET /api/carrier_contacts/{id} : lecture unitaire (security view) — la
* lecture courante reste via le parent. Pas de GET collection autonome.
* Tout passe par le CarrierContactProcessor (rattachement parent + RG-4.08 +
* RG-4.13).
*
* Telephones (RG-4.08, max 2) : le contrat d'ecriture expose un tableau virtuel
* `phones` (liste dynamique cote front « x1, +1 possible, max 2 ») mappe par le
* Processor vers `phonePrimary` / `phoneSecondary` (un 3e numero -> 422). Les
* deux colonnes scalaires restent en lecture seule (embarquees au detail).
*
* Audite (#[Auditable]) + Timestampable / Blamable.
*/
#[ApiResource(
operations: [
new Get(
security: "is_granted('transport.carriers.view')",
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
),
new Post(
uriTemplate: '/carriers/{carrierId}/contacts',
uriVariables: [
'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'),
],
// read:false : pas de stade lecture du parent. Le Link toProperty
// resoudrait l'enfant (SELECT CarrierContact ... WHERE carrier = :id)
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
// manuellement par CarrierContactProcessor::linkParent (404 si absent).
read: false,
security: "is_granted('transport.carriers.manage')",
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
denormalizationContext: ['groups' => ['carrier:write:contacts']],
processor: CarrierContactProcessor::class,
),
new Patch(
security: "is_granted('transport.carriers.manage')",
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
denormalizationContext: ['groups' => ['carrier:write:contacts']],
processor: CarrierContactProcessor::class,
),
new Delete(
security: "is_granted('transport.carriers.manage')",
processor: CarrierContactProcessor::class,
),
],
)]
#[ORM\Entity]
#[ORM\Table(name: 'carrier_contact')]
#[ORM\Index(name: 'idx_carrier_contact_carrier', columns: ['carrier_id'])]
@@ -39,18 +98,27 @@ class CarrierContact implements TimestampableInterface, BlamableInterface
#[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Carrier $carrier = null;
// RG-4.08 : aucun champ obligatoire isolement (≥ 1 champ rempli, garde
// Processor + CHECK BDD). Les colonnes restent nullable au niveau ORM.
#[ORM\Column(name: 'first_name', length: 120, nullable: true)]
#[Groups(['carrier:item:read'])]
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
private ?string $firstName = null;
#[ORM\Column(name: 'last_name', length: 120, nullable: true)]
#[Groups(['carrier:item:read'])]
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
private ?string $lastName = null;
#[ORM\Column(name: 'job_title', length: 120, nullable: true)]
#[Groups(['carrier:item:read'])]
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
private ?string $jobTitle = null;
// Telephones en LECTURE seule : alimentes en ecriture via le tableau virtuel
// `phones` (mappe par le CarrierContactProcessor). Pas de groupe write -> pas
// de saisie directe (et donc exemptes du miroir Assert\Length, le Processor
// borne deja la longueur).
#[ORM\Column(name: 'phone_primary', length: 20, nullable: true)]
#[Groups(['carrier:item:read'])]
private ?string $phonePrimary = null;
@@ -60,9 +128,22 @@ class CarrierContact implements TimestampableInterface, BlamableInterface
private ?string $phoneSecondary = null;
#[ORM\Column(length: 180, nullable: true)]
#[Groups(['carrier:item:read'])]
#[Assert\Email(message: 'L\'adresse email n\'est pas valide.')]
#[Assert\Length(max: 180, maxMessage: 'L\'email ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
private ?string $email = null;
/**
* Telephones en ecriture (RG-4.08, max 2), NON persiste : le
* CarrierContactProcessor normalise chaque numero (RG-4.13) puis le mappe vers
* phonePrimary / phoneSecondary. null = non fourni (PATCH partiel : on ne
* touche pas aux telephones existants). Un 3e numero -> 422 sur `phones`.
*
* @var null|list<string>
*/
#[Groups(['carrier:write:contacts'])]
private ?array $phones = null;
#[ORM\Column(options: ['default' => 0])]
private int $position = 0;
@@ -155,6 +236,24 @@ class CarrierContact implements TimestampableInterface, BlamableInterface
return $this;
}
/**
* @return null|list<string>
*/
public function getPhones(): ?array
{
return $this->phones;
}
/**
* @param null|list<string> $phones
*/
public function setPhones(?array $phones): static
{
$this->phones = $phones;
return $this;
}
public function getPosition(): int
{
return $this->position;
@@ -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,134 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Entity\CarrierAddress;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture de la sous-ressource Adresse d'un transporteur (M4,
* spec-back § 4.5). Jumeau du SupplierAddressProcessor (M2) / ProviderAddressProcessor
* (M3), recentre sur le perimetre ERP-159, AVEC une garde propre au M4 : RG-4.05
* (adresse obligatoire si le transporteur est affrete).
*
* Sequence :
* - POST / PATCH : rattachement au transporteur parent (linkParent) puis garde
* RG-4.05 (guardCharteredAddress). RG-4.06 (code postal, Assert\Regex) est portee
* par l'entite et jouee par API Platform AVANT ce processor.
* - DELETE : aucune regle metier specifique (suppression physique directe).
*
* RG-4.05 vit ICI (et non en Assert\Callback sur l'entite) car elle depend du
* transporteur PARENT, indisponible a la validation Symfony sur un POST
* sous-ressource en read:false (le parent n'est rattache qu'au stade processor).
* La violation est construite a la main avec le meme rendu Hydra que les
* contraintes Symfony, donc consommable inline par champ (convention ERP-101).
*
* RG-4.07 (masquage du bouton « Valider » si QUALIMAT) est purement front : le
* back accepte le PATCH normalement, aucune garde ici.
*
* La security d'operation (transport.carriers.manage) est appliquee par API
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut.
*
* @implements ProcessorInterface<CarrierAddress, null|CarrierAddress>
*/
final class CarrierAddressProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof CarrierAddress) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
$this->guardCharteredAddress($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache l'adresse au transporteur parent de la sous-ressource POST
* (/carriers/{carrierId}/addresses) : la relation n'est pas peuplee
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
*/
private function linkParent(CarrierAddress $address, array $uriVariables): void
{
if (null !== $address->getCarrier()) {
return;
}
$carrierId = $uriVariables['carrierId'] ?? null;
if (null === $carrierId) {
return;
}
$carrier = $carrierId instanceof Carrier
? $carrierId
: $this->em->getRepository(Carrier::class)->find($carrierId);
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
// contrainte carrier_id NOT NULL).
if (!$carrier instanceof Carrier) {
throw new NotFoundHttpException('Transporteur introuvable.');
}
$address->setCarrier($carrier);
}
/**
* RG-4.05 : si le transporteur parent est affrete (isChartered), l'adresse doit
* porter Pays / Code postal / Ville / Adresse. Chaque champ manquant -> une
* violation 422 sur son propre propertyPath (mapping inline ERP-101). La
* validation porte sur l'ETAT RESULTANT de l'adresse (apres application du
* payload), donc identique sur POST et sur PATCH partiel. Sans affretement,
* l'adresse reste partielle (champs nullable, RG-4.06 inchangee).
*/
private function guardCharteredAddress(CarrierAddress $address): void
{
$carrier = $address->getCarrier();
if (!$carrier instanceof Carrier || !$carrier->isChartered()) {
return;
}
$required = [
'country' => [$address->getCountry(), 'Le pays est obligatoire pour un transporteur affrété.'],
'postalCode' => [$address->getPostalCode(), 'Le code postal est obligatoire pour un transporteur affrété.'],
'city' => [$address->getCity(), 'La ville est obligatoire pour un transporteur affrété.'],
'street' => [$address->getStreet(), 'L\'adresse est obligatoire pour un transporteur affrété.'],
];
$violations = new ConstraintViolationList();
foreach ($required as $path => [$value, $message]) {
if (null === $value || '' === trim($value)) {
$violations->add(new ConstraintViolation($message, null, [], $address, $path, $value));
}
}
if (0 < $violations->count()) {
throw new ValidationException($violations);
}
}
}
@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Transport\Application\Service\CarrierFieldNormalizer;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Entity\CarrierContact;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use function count;
use function is_string;
/**
* Processor d'ecriture de la sous-ressource Contact d'un transporteur (M4,
* spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le
* perimetre ERP-160, AVEC deux specificites M4 : RG-4.08 (≥ 1 champ rempli, max
* 2 telephones) portee a la fois par le CHECK BDD chk_carrier_contact_filled et
* par ce Processor.
*
* Sequence :
* - POST / PATCH : rattachement au transporteur parent (linkParent),
* normalisation serveur RG-4.13 (prenom/nom Title Case, email lowercase),
* mapping du tableau d'ecriture `phones` -> phonePrimary/phoneSecondary
* (max 2, chiffres uniquement), puis garde RG-4.08 (≥ 1 champ) avant
* persistance.
* - DELETE : aucune regle metier specifique (suppression physique directe).
*
* RG-4.08 vit ICI (double du CHECK BDD) pour transformer une violation SQL (500
* generique) en 422 propre rattachee au champ `firstName` (mapping inline
* ERP-101). Le « max 2 telephones » est rattache au champ `phones` : seul
* point de saisie des numeros (les colonnes phonePrimary/phoneSecondary sont en
* lecture seule).
*
* La security d'operation (transport.carriers.manage) est appliquee par API
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut
* (Assert\Email, Assert\Length...).
*
* @implements ProcessorInterface<CarrierContact, null|CarrierContact>
*/
final class CarrierContactProcessor implements ProcessorInterface
{
/** RG-4.08 : nombre maximal de telephones par contact. */
private const int MAX_PHONES = 2;
/** Longueur max d'un telephone normalise (colonne VARCHAR(20)). */
private const int PHONE_MAX_LENGTH = 20;
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly CarrierFieldNormalizer $normalizer,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof CarrierContact) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
$this->normalize($data);
$this->applyPhones($data);
$this->validateAtLeastOneField($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache le contact au transporteur parent de la sous-ressource POST
* (/carriers/{carrierId}/contacts) : la relation n'est pas peuplee
* automatiquement par le Link sur une ecriture. Sur PATCH (entite existante),
* le transporteur est deja present -> no-op.
*/
private function linkParent(CarrierContact $contact, array $uriVariables): void
{
if (null !== $contact->getCarrier()) {
return;
}
$carrierId = $uriVariables['carrierId'] ?? null;
if (null === $carrierId) {
return;
}
$carrier = $carrierId instanceof Carrier
? $carrierId
: $this->em->getRepository(Carrier::class)->find($carrierId);
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
// contrainte carrier_id NOT NULL).
if (!$carrier instanceof Carrier) {
throw new NotFoundHttpException('Transporteur introuvable.');
}
$contact->setCarrier($carrier);
}
/**
* Normalisation serveur RG-4.13 des champs texte. Toutes les methodes du
* normalizer sont null-safe : une chaine vide apres trim devient null (donc la
* garde RG-4.08 detecte bien « champ non rempli »). Les telephones sont
* traites a part (applyPhones).
*/
private function normalize(CarrierContact $contact): void
{
$contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
$contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
$contact->setJobTitle($this->blankToNull($contact->getJobTitle()));
$contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
}
/**
* Mappe le tableau d'ecriture `phones` (max 2, RG-4.08) vers phonePrimary /
* phoneSecondary apres normalisation RG-4.13 (chiffres uniquement). Les
* numeros vides (sans chiffre) sont ecartes. null = champ non fourni (PATCH
* partiel) -> on ne touche pas aux telephones existants. Un 3e numero
* exploitable, ou un numero trop long (> colonne VARCHAR(20)), -> 422 sur
* `phones`.
*/
private function applyPhones(CarrierContact $contact): void
{
$phones = $contact->getPhones();
if (null === $phones) {
return;
}
$normalized = [];
foreach ($phones as $phone) {
$digits = $this->normalizer->normalizePhone(is_string($phone) ? $phone : null);
if (null !== $digits) {
$normalized[] = $digits;
}
}
$violations = new ConstraintViolationList();
if (self::MAX_PHONES < count($normalized)) {
$violations->add(new ConstraintViolation(
'Un contact ne peut comporter plus de deux téléphones.',
null,
[],
$contact,
'phones',
$phones,
));
}
foreach ($normalized as $digits) {
if (self::PHONE_MAX_LENGTH < mb_strlen($digits)) {
$violations->add(new ConstraintViolation(
'Un numéro de téléphone ne peut dépasser '.self::PHONE_MAX_LENGTH.' caractères.',
null,
[],
$contact,
'phones',
$phones,
));
break;
}
}
if (0 < $violations->count()) {
throw new ValidationException($violations);
}
$contact->setPhonePrimary($normalized[0] ?? null);
$contact->setPhoneSecondary($normalized[1] ?? null);
// Nettoie le champ virtuel (non persiste, mais evite toute fuite ulterieure).
$contact->setPhones(null);
}
/**
* RG-4.08 : un bloc Contact est valide des qu'au moins 1 champ est rempli
* (firstName, lastName, jobTitle, phonePrimary ou email — meme perimetre que
* le CHECK BDD chk_carrier_contact_filled, qui exclut phoneSecondary). Double
* garde : leve une 422 propre rattachee a `firstName` plutot qu'une 500 SQL.
* Joue apres normalisation + mapping telephones, donc les chaines vides sont
* deja ramenees a null.
*/
private function validateAtLeastOneField(CarrierContact $contact): void
{
if (
null === $contact->getFirstName()
&& null === $contact->getLastName()
&& null === $contact->getJobTitle()
&& null === $contact->getPhonePrimary()
&& null === $contact->getEmail()
) {
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Renseignez au moins un champ pour le contact.',
null,
[],
$contact,
'firstName',
null,
));
throw new ValidationException($violations);
}
}
/**
* Trim + chaine vide -> null (la fonction n'est pas normalisee en casse,
* contrairement aux noms de personne). Garantit que RG-4.08 detecte un champ
* « non rempli » meme si le client envoie une chaine vide.
*/
private function blankToNull(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : $value;
}
}
@@ -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)
;
}
}
@@ -56,6 +56,8 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
'SupplierAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
// Idem cote prestataire (meme Regex CP — M3 Technique).
'ProviderAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
// Idem cote transporteur (meme Regex CP — M4 Transport).
'CarrierAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
// Le Choice {PROSPECT,DEPART,RENDU} borne les valeurs (<= 8 < 20).
'SupplierAddress::addressType' => 'Choice {PROSPECT,DEPART,RENDU} borne deja les valeurs.',
// Le Choice {QUALIMAT,GMP_PLUS,OVOCOM,COMPTE_PROPRE,AUTRE} borne les valeurs (<= 13 < 20).
@@ -0,0 +1,172 @@
<?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 App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Entity\CarrierAddress;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* Sous-ressource Adresse d'un transporteur (spec-back M4 § 4.5, ERP-159).
* POST /api/carriers/{id}/addresses, PATCH/DELETE /api/carrier_addresses/{id}.
*
* Contrat verifie :
* - RG-4.06 : code postal hors ^[0-9]{4,5}$ -> 422 ;
* - RG-4.05 : transporteur affrete + adresse incomplete -> 422 (par champ) ;
* - RG-4.05 : transporteur affrete + adresse complete -> 201 ;
* - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul).
*
* @internal
*/
final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
{
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
protected function setUp(): void
{
parent::setUp();
// Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin
// qu'en recette), requis pour les tests de permission (bureau/commerciale).
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 testInvalidPostalCodeReturns422(): void
{
// Transporteur NON affrete : RG-4.05 ne s'applique pas, seule RG-4.06 joue.
$carrier = $this->seedCarrierWithChartered('Cp Invalide', false);
$client = $this->createAdminClient();
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => ['postalCode' => '123'], // 3 chiffres -> Regex KO
]);
self::assertResponseStatusCodeSame(422);
}
public function testCharteredCarrierIncompleteAddressReturns422(): void
{
// Transporteur affrete : RG-4.05 exige Pays/CP/Ville/Adresse. CP valide mais
// ville + rue manquantes -> 422 conditionnelle (CarrierAddressProcessor).
$carrier = $this->seedCarrierWithChartered('Affrete Incomplet', true);
$client = $this->createAdminClient();
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => ['postalCode' => '86000'],
]);
self::assertResponseStatusCodeSame(422);
}
public function testCharteredCarrierCompleteAddressIsCreated(): void
{
$carrier = $this->seedCarrierWithChartered('Affrete Complet', true);
$client = $this->createAdminClient();
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'country' => 'France',
'postalCode' => '86000',
'city' => 'Poitiers',
'street' => '12 rue des Acacias',
],
]);
self::assertResponseStatusCodeSame(201);
}
public function testPatchAndDeleteSucceedWithManage(): void
{
$address = $this->seedAddress('Patch Delete', false);
$client = $this->authenticatedClient('bureau', self::PWD); // manage (matrice § 5.2)
// PATCH (manage) -> 200
$client->request('PATCH', '/api/carrier_addresses/'.$address->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['city' => 'Lyon'],
]);
self::assertResponseStatusCodeSame(200);
// DELETE (manage) -> 204
$client->request('DELETE', '/api/carrier_addresses/'.$address->getId());
self::assertResponseStatusCodeSame(204);
}
public function testWriteForbiddenWithoutManage(): void
{
$address = $this->seedAddress('Forbidden', false);
$carrier = $address->getCarrier();
self::assertNotNull($carrier);
$client = $this->authenticatedClient('commerciale', self::PWD); // view seul
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => ['postalCode' => '86000', 'city' => 'Poitiers', 'street' => '1 rue X'],
]);
self::assertResponseStatusCodeSame(403);
$client->request('PATCH', '/api/carrier_addresses/'.$address->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['city' => 'Lyon'],
]);
self::assertResponseStatusCodeSame(403);
$client->request('DELETE', '/api/carrier_addresses/'.$address->getId());
self::assertResponseStatusCodeSame(403);
}
/**
* Seede un transporteur minimal en controlant le flag affrete (RG-4.05).
*/
private function seedCarrierWithChartered(string $name, bool $isChartered): Carrier
{
$em = $this->getEm();
$carrier = new Carrier();
$carrier->setName(mb_strtoupper($name, 'UTF-8'));
$carrier->setCertificationType('GMP_PLUS');
$carrier->setIsChartered($isChartered);
$em->persist($carrier);
$em->flush();
return $carrier;
}
/**
* Seede un transporteur + une adresse rattachee (pour les tests PATCH/DELETE).
*/
private function seedAddress(string $name, bool $isChartered): CarrierAddress
{
$em = $this->getEm();
$carrier = $this->seedCarrierWithChartered($name, $isChartered);
$address = new CarrierAddress();
$address->setCarrier($carrier);
$address->setPostalCode('86000');
$address->setCity('Poitiers');
$address->setStreet('12 rue des Acacias');
$carrier->addAddress($address);
$em->persist($address);
$em->flush();
return $address;
}
}
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Entity\CarrierContact;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* Sous-ressource Contact d'un transporteur (spec-back M4 § 4.5, ERP-160).
* POST /api/carriers/{id}/contacts, PATCH/DELETE /api/carrier_contacts/{id}.
*
* Contrat verifie :
* - RG-4.08 : contact totalement vide -> 422 (au moins 1 champ requis) ;
* - RG-4.08 : 1 seul champ rempli -> 201 ;
* - RG-4.08 : 3 telephones (tableau `phones`) -> 422 (max 2) ;
* - mapping `phones[]` -> phonePrimary / phoneSecondary + normalisation (RG-4.13) ;
* - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul).
*
* @internal
*/
final class CarrierContactApiTest extends AbstractCarrierApiTestCase
{
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
protected function setUp(): void
{
parent::setUp();
// Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin
// qu'en recette), requis pour les tests de permission (bureau/commerciale).
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 testEmptyContactReturns422(): void
{
// RG-4.08 : aucun champ rempli -> 422 (garde Processor, double du CHECK BDD).
$carrier = $this->seedCarrier('Contact Vide');
$client = $this->createAdminClient();
$client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => [],
]);
self::assertResponseStatusCodeSame(422);
}
public function testSingleFieldContactIsCreated(): void
{
// RG-4.08 : un seul champ suffit a valider le bloc.
$carrier = $this->seedCarrier('Contact Mono');
$client = $this->createAdminClient();
$client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => ['lastName' => 'martin'],
]);
self::assertResponseStatusCodeSame(201);
// RG-4.13 : nom capitalise serveur.
self::assertJsonContains(['lastName' => 'Martin']);
}
public function testThirdPhoneReturns422(): void
{
// RG-4.08 : max 2 telephones. Le contrat d'ecriture accepte un tableau
// `phones` (liste dynamique cote front « x1, +1 possible, max 2 ») ; un 3e
// numero -> 422 rattachee au champ `phones`.
$carrier = $this->seedCarrier('Contact Trois Tel');
$client = $this->createAdminClient();
$client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'firstName' => 'Jean',
'phones' => ['0611111111', '0622222222', '0633333333'],
],
]);
self::assertResponseStatusCodeSame(422);
}
public function testPhonesAreMappedAndNormalized(): void
{
// Mapping `phones[0]` -> phonePrimary, `phones[1]` -> phoneSecondary +
// normalisation RG-4.13 (chiffres uniquement).
$carrier = $this->seedCarrier('Contact Deux Tel');
$client = $this->createAdminClient();
$client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'lastName' => 'Dupont',
'phones' => ['06.11.11.11.11', '06 22 22 22 22'],
],
]);
self::assertResponseStatusCodeSame(201);
self::assertJsonContains([
'phonePrimary' => '0611111111',
'phoneSecondary' => '0622222222',
]);
}
public function testPatchAndDeleteSucceedWithManage(): void
{
$contact = $this->seedContact('Patch Delete');
$client = $this->authenticatedClient('bureau', self::PWD); // manage (matrice § 5.2)
// PATCH (manage) -> 200
$client->request('PATCH', '/api/carrier_contacts/'.$contact->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['jobTitle' => 'Directeur'],
]);
self::assertResponseStatusCodeSame(200);
// DELETE (manage) -> 204
$client->request('DELETE', '/api/carrier_contacts/'.$contact->getId());
self::assertResponseStatusCodeSame(204);
}
public function testWriteForbiddenWithoutManage(): void
{
$contact = $this->seedContact('Forbidden');
$carrier = $contact->getCarrier();
self::assertNotNull($carrier);
$client = $this->authenticatedClient('commerciale', self::PWD); // view seul
$client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => ['lastName' => 'Bernard'],
]);
self::assertResponseStatusCodeSame(403);
$client->request('PATCH', '/api/carrier_contacts/'.$contact->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['jobTitle' => 'Chef'],
]);
self::assertResponseStatusCodeSame(403);
$client->request('DELETE', '/api/carrier_contacts/'.$contact->getId());
self::assertResponseStatusCodeSame(403);
}
/**
* Seede un transporteur + un contact rattache (pour les tests PATCH/DELETE).
*/
private function seedContact(string $name): CarrierContact
{
$em = $this->getEm();
$carrier = $this->seedCarrier($name);
$contact = new CarrierContact();
$contact->setCarrier($carrier);
$contact->setLastName('Martin');
$contact->setPhonePrimary('0612345678');
$carrier->addContact($contact);
$em->persist($contact);
$em->flush();
return $contact;
}
}
@@ -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'),
]);
}
}