feat(transport) : CarrierProcessor + champs conditionnels formulaire principal (ERP-158)

Ecriture du formulaire principal transporteur (M4, WT4) : POST/PATCH via
CarrierProcessor + CarrierFieldNormalizer, contraintes conditionnelles sur
l'entite Carrier.

- RG-4.01 : POST qualimatCarrier -> certificationType=QUALIMAT + FK persistee ;
  cas LIOT (name=LIOT) -> certification non requise, liotPlates accepte.
- RG-4.02 : certificationType=AUTRE sans dischargeDocument -> 422 (Assert\Callback).
- RG-4.03 : isChartered=true sans indexationRate/containerType/volumeM3 -> 422.
- RG-4.12 : doublon de nom (parmi actifs) -> 409 (index partiel uq_carrier_name_active).
- RG-4.13 : normalisation serveur (name UPPER, liotPlates ;-split/trim/UPPER) +
  methodes personne/telephone/email pour les sous-ressources Contact (WT7).
- RG-4.14 : PATCH isArchived exige transport.carriers.archive (Admin seul),
  mode strict -> 403 + 422 si autre champ ; restauration en conflit -> 409.

Operations Post/Patch ajoutees a l'ApiResource (lecture posee au WT3 conservee).
RG conditionnelles portees par validateMainFormConsistency (Assert\Callback +
->atPath()) pour un propertyPath mappable inline (useFormErrors, ERP-101).

certificationType / containerType whitelistes dans EXCLUDED_LENGTH_MIRROR (Choice
borne deja les valeurs, miroir SupplierAddress::addressType).

Tests : CarrierWriteApiTest (RG-4.01->4.03/4.12->4.14), CarrierRBACMatrixTest
(matrice bureau/compta/commerciale/usine), CarrierArchiveTest (409 restauration),
CarrierFieldNormalizerTest (RG-4.13). make test vert (750).
This commit is contained in:
Matthieu
2026-06-16 08:14:54 +02:00
parent d9313dbec8
commit 97d7cacd2c
9 changed files with 1040 additions and 23 deletions
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Application\Service;
/**
* Normalisation serveur des champs texte d'un Carrier / CarrierContact, appliquee
* par le CarrierProcessor (et les futurs processors de sous-ressources, WT6/7/8)
* AVANT persistance. Cf. spec-back M4 § 2.10 + RG-4.13. Jumeau de
* SupplierFieldNormalizer (M2), enrichi du cas LIOT (immatriculations).
*
* - name : UPPERCASE integral (RG-4.13)
* - firstName / lastName (personnes, sur CarrierContact) : Title Case (RG-4.13)
* - phone* : chiffres uniquement, ex "06.12.34.56.78" -> "0612345678" (RG-4.13).
* Le formatage d'affichage "XX XX XX XX XX" est de la responsabilite du front.
* - email : lowercase integral (RG-4.13)
* - liotPlates : liste « ; » -> split, trim, UPPER, rejoin "; " (cas LIOT RG-4.01).
*
* Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide apres
* trim devient null (evite de persister "" dans des colonnes nullable).
*/
final class CarrierFieldNormalizer
{
/**
* Raison sociale en majuscules (RG-4.13). Conserve null tel quel ; une chaine
* non vide est trim + upper. Une chaine vide reste "" (champ obligatoire :
* c'est l'Assert\NotBlank qui rejette, pas le normalizer).
*/
public function normalizeName(?string $value): ?string
{
if (null === $value) {
return null;
}
return mb_strtoupper(trim($value), 'UTF-8');
}
/**
* Nom/prenom de personne en Title Case (RG-4.13) : "JEAN dupont" ->
* "Jean Dupont". Une chaine vide apres trim devient null.
*/
public function normalizePersonName(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8');
}
/**
* Email en minuscules (RG-4.13). Une chaine vide apres trim devient null.
*/
public function normalizeEmail(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : mb_strtolower($value, 'UTF-8');
}
/**
* Telephone reduit aux chiffres (RG-4.13) : "06.12.34.56.78" -> "0612345678".
* Une valeur sans aucun chiffre devient null.
*/
public function normalizePhone(?string $value): ?string
{
if (null === $value) {
return null;
}
$digits = preg_replace('/\D+/', '', $value) ?? '';
return '' === $digits ? null : $digits;
}
/**
* Immatriculations LIOT (RG-4.01 / RG-4.13) : la saisie « ; »-separee est
* decoupee, chaque plaque trim + UPPER, les segments vides ecartes, puis
* recomposee avec le separateur canonique "; ". Une saisie sans aucune plaque
* exploitable devient null.
*/
public function normalizeLiotPlates(?string $value): ?string
{
if (null === $value) {
return null;
}
$plates = [];
foreach (explode(';', $value) as $plate) {
$plate = trim($plate);
if ('' !== $plate) {
$plates[] = mb_strtoupper($plate, 'UTF-8');
}
}
return [] === $plates ? null : implode('; ', $plates);
}
}
+135 -22
View File
@@ -7,6 +7,9 @@ namespace App\Module\Transport\Domain\Entity;
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 ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierProcessor;
use App\Module\Transport\Infrastructure\ApiPlatform\State\Provider\CarrierProvider; use App\Module\Transport\Infrastructure\ApiPlatform\State\Provider\CarrierProvider;
use App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository; use App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository;
use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Attribute\Auditable;
@@ -20,6 +23,8 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName; use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/** /**
* Transporteur (M4 Transport) — entite racine du repertoire transporteurs, * Transporteur (M4 Transport) — entite racine du repertoire transporteurs,
@@ -28,16 +33,17 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
* (is_archived / archived_at) et le soft-delete technique prepare mais non * (is_archived / archived_at) et le soft-delete technique prepare mais non
* expose au M4 (deleted_at). * expose au M4 (deleted_at).
* *
* Perimetre WT3 (ERP-155/157) = CONTRAT DE LECTURE uniquement : l'#[ApiResource] * Perimetre WT4 (ERP-158) = formulaire principal en ecriture. L'#[ApiResource]
* n'expose que GetCollection + Get (via CarrierProvider). La creation / * expose desormais Post + Patch (via CarrierProcessor : normalisation RG-4.13,
* modification (POST/PATCH + CarrierProcessor : normalisation, RG-4.01→4.14, * gating archive mode strict RG-4.14, 409 doublon de nom RG-4.12) en plus du
* 409 doublon, gating archive) et les sous-ressources d'ecriture * contrat de lecture pose au WT3. Les proprietes du formulaire principal portent
* (adresses/contacts/prix) arrivent aux worktrees suivants (WT4+). C'est * leur groupe d'ecriture (carrier:write:main / carrier:write:archive) et leurs
* pourquoi les proprietes ne portent ICI que des read-groups (carrier:read / * contraintes Assert ; les RG conditionnelles (RG-4.01 certification obligatoire
* carrier:item:read / qualimat:read), sans groupe d'ecriture ni contrainte * sauf LIOT, RG-4.02 AUTRE -> decharge, RG-4.03 affrete -> indexation/benne/volume)
* Assert de validation (qui appartiennent au flux d'ecriture). Les invariants * sont portees par validateMainFormConsistency (Assert\Callback + ->atPath()).
* BDD (NOT NULL, CHECK enum, FK, unicite partielle) restent garantis par la * Les sous-ressources d'ecriture (adresses/contacts/prix) arrivent aux worktrees
* migration Version20260615150000. * suivants (WT6/7/8). Les invariants BDD (NOT NULL, CHECK enum, FK, unicite
* partielle) restent garantis par la migration Version20260615150000.
* *
* Contrat de serialisation (RETEX M1, 3 maillons — spec § 4.0) : * Contrat de serialisation (RETEX M1, 3 maillons — spec § 4.0) :
* - LISTE (carrier:read + qualimat:read + default:read) : name, certificationType, * - LISTE (carrier:read + qualimat:read + default:read) : name, certificationType,
@@ -79,7 +85,27 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
]], ]],
provider: CarrierProvider::class, provider: CarrierProvider::class,
), ),
// Pas de Post/Patch/Delete au WT3 (lecture seule). Ecriture + archivage : WT4. new Post(
// Creation du formulaire principal (RG-4.01 -> RG-4.03 / RG-4.12 /
// RG-4.13). La reponse 201 ne renvoie que les scalaires principaux +
// id : le front enchaine ensuite les sous-ressources par onglet.
security: "is_granted('transport.carriers.manage')",
normalizationContext: ['groups' => ['carrier:read', 'default:read']],
denormalizationContext: ['groups' => ['carrier:write:main']],
processor: CarrierProcessor::class,
),
new Patch(
// Security elargie au seul `manage` (Admin + Bureau). Le CarrierProcessor
// re-gate ensuite l'archivage : un payload basculant isArchived exige
// `transport.carriers.archive` (Admin seul -> Bureau recoit 403, mode
// strict RG-4.14).
security: "is_granted('transport.carriers.manage')",
normalizationContext: ['groups' => ['carrier:read', 'default:read']],
denormalizationContext: ['groups' => ['carrier:write:main', 'carrier:write:archive']],
provider: CarrierProvider::class,
processor: CarrierProcessor::class,
),
// Pas de Delete au M4 (HP-M4-C). Archivage via PATCH { isArchived: true }.
], ],
)] )]
#[ORM\Entity(repositoryClass: DoctrineCarrierRepository::class)] #[ORM\Entity(repositoryClass: DoctrineCarrierRepository::class)]
@@ -95,6 +121,12 @@ class Carrier implements TimestampableInterface, BlamableInterface
{ {
use TimestampableBlamableTrait; use TimestampableBlamableTrait;
/** RG-4.01 : nom du cas special « compte-propre » LIOT (comparaison insensible a la casse). */
private const string LIOT_NAME = 'LIOT';
/** RG-4.02 : valeur de certification imposant le champ Decharge. */
private const string CERTIFICATION_AUTRE = 'AUTRE';
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
@@ -102,47 +134,65 @@ class Carrier implements TimestampableInterface, BlamableInterface
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
#[Groups(['carrier:read'])] #[Assert\NotBlank(message: 'Le nom du transporteur est obligatoire.', normalizer: 'trim')]
#[Assert\Length(
min: 2,
max: 255,
minMessage: 'Le nom du transporteur doit contenir au moins {{ limit }} caractères.',
maxMessage: 'Le nom du transporteur ne peut dépasser {{ limit }} caractères.',
normalizer: 'trim',
)]
#[Groups(['carrier:read', 'carrier:write:main'])]
private ?string $name = null; private ?string $name = null;
/** Lien editable vers le referentiel QUALIMAT (saisie assistee RG-4.01, § 2.5). */ /** Lien editable vers le referentiel QUALIMAT (saisie assistee RG-4.01, § 2.5). */
#[ORM\ManyToOne(targetEntity: QualimatCarrier::class)] #[ORM\ManyToOne(targetEntity: QualimatCarrier::class)]
#[ORM\JoinColumn(name: 'qualimat_carrier_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] #[ORM\JoinColumn(name: 'qualimat_carrier_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['carrier:read'])] #[Groups(['carrier:read', 'carrier:write:main'])]
private ?QualimatCarrier $qualimatCarrier = null; private ?QualimatCarrier $qualimatCarrier = null;
/** QUALIMAT|GMP_PLUS|OVOCOM|COMPTE_PROPRE|AUTRE ; null en cas LIOT (RG-4.01). */ /** QUALIMAT|GMP_PLUS|OVOCOM|COMPTE_PROPRE|AUTRE ; null en cas LIOT (RG-4.01). */
#[ORM\Column(length: 20, nullable: true)] #[ORM\Column(length: 20, nullable: true)]
#[Groups(['carrier:read'])] #[Assert\Choice(
choices: ['QUALIMAT', 'GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'],
message: 'Type de certification invalide.',
)]
// Obligatoire SAUF en cas LIOT (champ masque) : controle conditionnel dans
// validateMainFormConsistency (RG-4.01). La longueur est bornee par le Choice
// (whitelist EntityConstraintsHaveFrenchMessageTest::EXCLUDED_LENGTH_MIRROR).
#[Groups(['carrier:read', 'carrier:write:main'])]
private ?string $certificationType = null; private ?string $certificationType = null;
#[ORM\Column(name: 'is_chartered', options: ['default' => false])] #[ORM\Column(name: 'is_chartered', options: ['default' => false])]
#[Groups(['carrier:write:main'])]
private bool $isChartered = false; private bool $isChartered = false;
/** % d'indexation — renseigne si affrete (RG-4.03). */ /** % d'indexation — obligatoire si affrete (RG-4.03, validateMainFormConsistency). */
#[ORM\Column(name: 'indexation_rate', type: 'decimal', precision: 5, scale: 2, nullable: true)] #[ORM\Column(name: 'indexation_rate', type: 'decimal', precision: 5, scale: 2, nullable: true)]
#[Groups(['carrier:read'])] #[Groups(['carrier:read', 'carrier:write:main'])]
private ?string $indexationRate = null; private ?string $indexationRate = null;
/** BENNE|FOND_MOUVANT — renseigne si affrete (RG-4.03). */ /** BENNE|FOND_MOUVANT — obligatoire si affrete (RG-4.03). */
#[ORM\Column(name: 'container_type', length: 12, nullable: true)] #[ORM\Column(name: 'container_type', length: 12, nullable: true)]
#[Groups(['carrier:read'])] #[Assert\Choice(choices: ['BENNE', 'FOND_MOUVANT'], message: 'Type de contenant invalide.')]
// Longueur bornee par le Choice (whitelist EXCLUDED_LENGTH_MIRROR).
#[Groups(['carrier:read', 'carrier:write:main'])]
private ?string $containerType = null; private ?string $containerType = null;
/** Volume m3 — renseigne si affrete (RG-4.03). */ /** Volume m3 — obligatoire si affrete (RG-4.03). */
#[ORM\Column(name: 'volume_m3', type: 'decimal', precision: 10, scale: 2, nullable: true)] #[ORM\Column(name: 'volume_m3', type: 'decimal', precision: 10, scale: 2, nullable: true)]
#[Groups(['carrier:read'])] #[Groups(['carrier:read', 'carrier:write:main'])]
private ?string $volumeM3 = null; private ?string $volumeM3 = null;
/** Decharge (upload, visible si certificationType = AUTRE — RG-4.02). Infra Shared (§ 2.7). */ /** Decharge (upload, visible si certificationType = AUTRE — RG-4.02). Infra Shared (§ 2.7). */
#[ORM\ManyToOne(targetEntity: UploadedDocument::class)] #[ORM\ManyToOne(targetEntity: UploadedDocument::class)]
#[ORM\JoinColumn(name: 'discharge_document_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] #[ORM\JoinColumn(name: 'discharge_document_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['carrier:read'])] #[Groups(['carrier:read', 'carrier:write:main'])]
private ?UploadedDocument $dischargeDocument = null; private ?UploadedDocument $dischargeDocument = null;
/** Immatriculations LIOT separees par « ; » (cas LIOT — RG-4.01). */ /** Immatriculations LIOT separees par « ; » (cas LIOT — RG-4.01). */
#[ORM\Column(name: 'liot_plates', type: 'text', nullable: true)] #[ORM\Column(name: 'liot_plates', type: 'text', nullable: true)]
#[Groups(['carrier:read'])] #[Groups(['carrier:read', 'carrier:write:main'])]
private ?string $liotPlates = null; private ?string $liotPlates = null;
// === Sous-collections — EMBARQUEES dans le DETAIL (read-group sur le getter) === // === Sous-collections — EMBARQUEES dans le DETAIL (read-group sur le getter) ===
@@ -159,7 +209,11 @@ class Carrier implements TimestampableInterface, BlamableInterface
private Collection $prices; private Collection $prices;
// === Archive / Soft delete === // === Archive / Soft delete ===
// Le groupe de LECTURE est declare sur le getter isArchived() avec
// SerializedName('isArchived') (piege booleen #3 M1) ; le groupe d'ECRITURE
// vit sur la propriete pour que la denormalisation cible setIsArchived.
#[ORM\Column(name: 'is_archived', options: ['default' => false])] #[ORM\Column(name: 'is_archived', options: ['default' => false])]
#[Groups(['carrier:write:archive'])]
private bool $isArchived = false; private bool $isArchived = false;
#[ORM\Column(name: 'archived_at', type: 'datetime_immutable', nullable: true)] #[ORM\Column(name: 'archived_at', type: 'datetime_immutable', nullable: true)]
@@ -176,6 +230,65 @@ class Carrier implements TimestampableInterface, BlamableInterface
$this->prices = new ArrayCollection(); $this->prices = new ArrayCollection();
} }
/**
* Coherence conditionnelle du formulaire principal (RG-4.01 / RG-4.02 /
* RG-4.03). Decision figee (miroir M2 RG-2.07/2.08) : ces RG inter-champs
* passent par une contrainte d'entite (Assert\Callback + ->atPath()) et NON par
* le CarrierProcessor, afin que chaque 422 porte un propertyPath exploitable
* par useFormErrors (mapping inline sous le champ, pas un toast — ERP-101).
* Jouee par API Platform AVANT le processor, sur POST comme sur PATCH.
*
* Cas LIOT (RG-4.01) : seul liotPlates est pertinent ; les autres champs sont
* masques cote front et le back ne les valide pas (« stocke ce qu'il recoit,
* pas de 422 sur la presence residuelle »). Le nom est compare en majuscules
* car la normalisation UPPER n'intervient qu'au processor (apres validation).
*/
#[Assert\Callback]
public function validateMainFormConsistency(ExecutionContextInterface $context): void
{
if (self::LIOT_NAME === mb_strtoupper(trim((string) $this->name), 'UTF-8')) {
return;
}
// RG-4.01 : certification obligatoire hors cas LIOT.
if (null === $this->certificationType || '' === $this->certificationType) {
$context->buildViolation('Le type de certification est obligatoire.')
->atPath('certificationType')
->addViolation()
;
}
// RG-4.02 : certification AUTRE -> decharge obligatoire.
if (self::CERTIFICATION_AUTRE === $this->certificationType && null === $this->dischargeDocument) {
$context->buildViolation('La décharge est obligatoire pour une certification « Autre ».')
->atPath('dischargeDocument')
->addViolation()
;
}
// RG-4.03 : affrete -> indexation + benne/fond mouvant + volume obligatoires.
if ($this->isChartered) {
if (null === $this->indexationRate) {
$context->buildViolation('Le taux d\'indexation est obligatoire pour un transporteur affrété.')
->atPath('indexationRate')
->addViolation()
;
}
if (null === $this->containerType) {
$context->buildViolation('Le type de contenant est obligatoire pour un transporteur affrété.')
->atPath('containerType')
->addViolation()
;
}
if (null === $this->volumeM3) {
$context->buildViolation('Le volume est obligatoire pour un transporteur affrété.')
->atPath('volumeM3')
->addViolation()
;
}
}
}
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -0,0 +1,275 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Transport\Application\Service\CarrierFieldNormalizer;
use App\Module\Transport\Domain\Entity\Carrier;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use JsonException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* Processor d'ecriture du repertoire transporteurs (M4, formulaire principal). Cf.
* spec-back M4 § 4.3 / § 4.4 + RG-4.12 / RG-4.13 / RG-4.14. Jumeau du
* SupplierProcessor (M2), recentre sur le perimetre WT4 (formulaire principal :
* normalisation, gating archive, 409 doublon de nom).
*
* Sequence (POST / PATCH) :
* 1. Gating archive (mode strict RG-4.14). La security d'operation laisse entrer
* `transport.carriers.manage` (Admin + Bureau) ; ce processor re-gate
* finement : un payload basculant `isArchived` exige `transport.carriers.archive`
* (Admin seul) -> 403, et une requete d'archivage ne peut modifier aucun autre
* champ -> 422. C'est ce qui empeche Bureau d'archiver (manage sans archive).
* 2. Normalisation serveur (RG-4.13) via CarrierFieldNormalizer (name UPPER,
* liotPlates « ; »-normalise). Les champs personne / telephone / email sont
* portes par les sous-ressources Contact (WT7), pas par le formulaire principal.
* 3. Pose / retrait de archivedAt (RG-4.14 true=now, false=null).
* 4. Persistance via le persist_processor Doctrine, avec traduction des
* collisions d'unicite partielle (uq_carrier_name_active) en 409 (RG-4.12
* doublon de nom ; conflit de restauration).
*
* Les RG conditionnelles du formulaire principal (RG-4.01 certification obligatoire
* sauf LIOT, RG-4.02 AUTRE -> decharge, RG-4.03 affrete -> indexation/benne/volume)
* sont portees par un Assert\Callback + ->atPath() sur l'entite Carrier (joue par
* API Platform AVANT ce processor), pour que chaque 422 porte un propertyPath
* consommable par useFormErrors (mapping inline, pas un toast — convention ERP-101).
*
* @implements ProcessorInterface<Carrier, Carrier>
*/
final class CarrierProcessor implements ProcessorInterface
{
/** Champs ecrivables du formulaire principal (groupe carrier:write:main). */
private const array MAIN_FIELDS = [
'name', 'qualimatCarrier', 'certificationType', 'isChartered',
'indexationRate', 'containerType', 'volumeM3', 'dischargeDocument', 'liotPlates',
];
/** Champ d'archivage (groupe carrier:write:archive). */
private const string ARCHIVE_FIELD = 'isArchived';
private const string PERM_ARCHIVE = 'transport.carriers.archive';
/**
* Memoisation du dernier corps de requete decode, clos par le contenu brut.
* payloadKeys() est appele plusieurs fois par requete : on evite de rejouer
* json_decode. La cle etant le contenu lui-meme et le calcul une fonction pure
* de ce contenu, aucune fuite n'est possible entre requetes sur ce service
* partage (un meme corps redonne les memes cles).
*/
private ?string $decodedContent = null;
/** @var list<string> Cles de premier niveau correspondant au corps memoise. */
private array $decodedPayloadKeys = [];
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly CarrierFieldNormalizer $normalizer,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Carrier) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
// Reinitialisation de la memoisation du payload en debut de traitement :
// le service est partage (stateful), on repart du corps de LA requete
// courante et on n'herite jamais des cles decodees d'une requete passee.
$this->decodedContent = null;
$this->decodedPayloadKeys = [];
$isArchiveRequest = $this->guardArchive($data, $this->writablePayloadKeys());
$this->normalize($data);
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
// Le seul index unique partiel est uq_carrier_name_active
// (LOWER(name) parmi non-archives/non-deletes — § 2.6).
if ($isArchiveRequest && false === $data->isArchived()) {
// RG-4.14 : restauration en conflit avec un homonyme actif.
throw new ConflictHttpException(
'Restauration impossible : un autre transporteur a pris le nom entre-temps.',
$e,
);
}
// RG-4.12 : doublon de nom de transporteur.
throw new ConflictHttpException(
sprintf('Un transporteur nommé "%s" existe déjà.', (string) $data->getName()),
$e,
);
}
}
/**
* RG-4.14 : si le payload bascule reellement isArchived, exige la permission
* archive (403), interdit toute autre modification (422) et pose/retire
* archivedAt. Retourne true si la requete est une requete d'archivage.
*
* Le gating est restreint a la mise a jour d'un transporteur existant ET au
* seul cas ou isArchived change vraiment : un POST (entite non encore geree
* par l'ORM) ou un PATCH « representation complete » renvoyant isArchived
* inchange ne doit declencher ni 403 ni 422 parasite.
*
* @param list<string> $writableKeys cles ecrivables du payload (hors @* et champs inconnus)
*/
private function guardArchive(Carrier $data, array $writableKeys): bool
{
// POST / entite non geree : l'archivage est une action de mise a jour.
if (!$this->em->contains($data)) {
return false;
}
// isArchived inchange par rapport a l'etat persiste : pas une requete
// d'archivage (cas du PATCH representation complete).
if (!$this->fieldChanged($data, 'isArchived', $data->isArchived())) {
return false;
}
if (!$this->security->isGranted(self::PERM_ARCHIVE)) {
throw new AccessDeniedHttpException(sprintf(
'Le champ "%s" requiert la permission "%s".',
self::ARCHIVE_FIELD,
self::PERM_ARCHIVE,
));
}
// RG-4.14 : une requete d'archivage ne modifie aucun autre champ ecrivable.
if ([] !== array_diff($writableKeys, [self::ARCHIVE_FIELD])) {
throw new UnprocessableEntityHttpException(
'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".',
);
}
// RG-4.14 (true -> now) / (false -> null).
$data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null);
return true;
}
/**
* Normalisation serveur du formulaire principal (RG-4.13). name (non-nullable)
* et liotPlates (cas LIOT) sont les seuls champs texte du formulaire principal ;
* le contact (personne / telephone / email) est normalise par son propre
* processor (sous-ressource, WT7). Les setters ne sont touches que si une valeur
* est presente, pour ne jamais ecraser l'existant lors d'un PATCH partiel.
*/
private function normalize(Carrier $data): void
{
if (null !== $data->getName()) {
$data->setName((string) $this->normalizer->normalizeName($data->getName()));
}
if (null !== $data->getLiotPlates()) {
$data->setLiotPlates($this->normalizer->normalizeLiotPlates($data->getLiotPlates()));
}
}
/**
* Cles ecrivables effectivement presentes dans le payload : on retire les cles
* JSON-LD (@id, @context...) et tout champ non rattache a un groupe d'ecriture
* connu. C'est la base du 422 d'archivage (RG-4.14) — sans elles, un PATCH
* « representation complete » porteur de @id ferait croire a une modification
* multi-champs.
*
* @return list<string>
*/
private function writablePayloadKeys(): array
{
$writable = array_merge(self::MAIN_FIELDS, [self::ARCHIVE_FIELD]);
return array_values(array_intersect($this->payloadKeys(), $writable));
}
/**
* Vrai si la valeur courante d'un champ differe de l'etat persiste. Pour une
* entite non geree (creation/POST), l'etat persiste est vide : toute valeur
* non-null est alors un changement.
*/
private function fieldChanged(Carrier $data, string $field, mixed $newValue): bool
{
$original = $this->originalData($data);
return $newValue !== ($original[$field] ?? null);
}
/**
* Snapshot des valeurs persistees de l'entite (telles que chargees, avant
* application du payload). Vide pour une entite non geree (POST).
*
* @return array<string, mixed>
*/
private function originalData(Carrier $data): array
{
if (!$this->em->contains($data)) {
return [];
}
return $this->em->getUnitOfWork()->getOriginalEntityData($data);
}
/**
* Cles de premier niveau effectivement envoyees par le client (payload JSON
* brut), filtrage compris. Pour un PATCH merge-patch+json, ce sont les seuls
* champs modifies.
*
* @return list<string>
*/
private function payloadKeys(): array
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return [];
}
$content = $request->getContent();
// Cache hit : meme corps brut que le dernier decodage -> memes cles.
if ($content === $this->decodedContent) {
return $this->decodedPayloadKeys;
}
$this->decodedContent = $content;
$this->decodedPayloadKeys = $this->extractPayloadKeys($content);
return $this->decodedPayloadKeys;
}
/**
* Decode le corps brut et en extrait les cles de premier niveau (chaines).
* Corps vide ou JSON invalide -> aucune cle.
*
* @return list<string>
*/
private function extractPayloadKeys(string $content): array
{
if ('' === $content) {
return [];
}
try {
$decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return [];
}
return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
}
}
@@ -58,6 +58,10 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
'ProviderAddress::postalCode' => 'Regex {4,5} borne deja la longueur.', 'ProviderAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
// Le Choice {PROSPECT,DEPART,RENDU} borne les valeurs (<= 8 < 20). // Le Choice {PROSPECT,DEPART,RENDU} borne les valeurs (<= 8 < 20).
'SupplierAddress::addressType' => 'Choice {PROSPECT,DEPART,RENDU} borne deja les valeurs.', 'SupplierAddress::addressType' => 'Choice {PROSPECT,DEPART,RENDU} borne deja les valeurs.',
// Le Choice {QUALIMAT,GMP_PLUS,OVOCOM,COMPTE_PROPRE,AUTRE} borne les valeurs (<= 13 < 20).
'Carrier::certificationType' => 'Choice des 5 certifications borne deja les valeurs.',
// Le Choice {BENNE,FOND_MOUVANT} borne les valeurs (<= 12).
'Carrier::containerType' => 'Choice {BENNE,FOND_MOUVANT} borne deja les valeurs.',
// Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres. // Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres.
'Site::color' => 'Regex code hex #RRGGBB borne deja la longueur.', 'Site::color' => 'Regex code hex #RRGGBB borne deja la longueur.',
]; ];
@@ -31,7 +31,8 @@ use DateTimeImmutable;
*/ */
abstract class AbstractCarrierApiTestCase extends AbstractApiTestCase abstract class AbstractCarrierApiTestCase extends AbstractApiTestCase
{ {
protected const string LD = 'application/ld+json'; protected const string LD = 'application/ld+json';
protected const string MERGE = 'application/merge-patch+json';
/** Prefixe SIRET des lignes qualimat_carrier seedees par les tests (purge ciblee). */ /** Prefixe SIRET des lignes qualimat_carrier seedees par les tests (purge ciblee). */
private const string TEST_SIRET_PREFIX = 'TESTQ'; private const string TEST_SIRET_PREFIX = 'TESTQ';
@@ -63,6 +64,22 @@ abstract class AbstractCarrierApiTestCase extends AbstractApiTestCase
return $this->authenticatedClient('admin', 'admin'); return $this->authenticatedClient('admin', 'admin');
} }
/**
* Payload minimal valide du formulaire principal (transporteur non-QUALIMAT,
* non affrete) : nom + certification GMP_PLUS. Sert de base aux tests
* d'ecriture / RBAC.
*
* @return array<string, mixed>
*/
protected function validMainPayload(string $name): array
{
return [
'name' => $name,
'certificationType' => 'GMP_PLUS',
'isChartered' => false,
];
}
/** /**
* Seede un transporteur minimal (nom en MAJUSCULES, comme le ferait le * Seede un transporteur minimal (nom en MAJUSCULES, comme le ferait le
* futur Processor). Sert aux tests de liste / archivage. * futur Processor). Sert aux tests de liste / archivage.
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
/**
* Archivage / restauration transporteur — trou 409 de restauration en conflit
* d'unicite (M4, RG-4.14). Le nominal (archive pose archivedAt) et le 422
* « archive + autre champ » sont couverts par CarrierWriteApiTest. Jumeau de
* SupplierArchiveTest (M2).
*
* @internal
*/
final class CarrierArchiveTest extends AbstractCarrierApiTestCase
{
/**
* RG-4.14 : restaurer un transporteur archive dont le nom a ete repris par un
* transporteur actif entre-temps doit echouer en 409 (index partiel
* uq_carrier_name_active : un seul actif portant ce nom).
*/
public function testRestoreConflictReturns409(): void
{
$client = $this->createAdminClient();
$archived = $this->seedCarrier('Acme Conflict', true);
$this->seedCarrier('Acme Conflict', false);
$client->request('PATCH', '/api/carriers/'.$archived->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => false],
]);
self::assertResponseStatusCodeSame(409);
}
}
@@ -0,0 +1,158 @@
<?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 Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* Matrice RBAC du repertoire transporteurs par role metier (spec-back M4 § 5.2 +
* ERP-153/158). Valide 200/403 par verbe pour bureau / compta / commerciale /
* usine ; l'archivage reste admin seul (gating CarrierProcessor, RG-4.14). Jumeau
* de SupplierRBACMatrixTest (M2).
*
* Matrice § 5.2 — rappel :
* - bureau : view + manage (PAS archive)
* - commerciale : view seul (ni manage ni archive)
* - compta : aucun acces (403 sur view ET manage)
* - usine : aucun acces (403 partout)
* - archive : admin seul
*
* @internal
*/
final class CarrierRBACMatrixTest extends AbstractCarrierApiTestCase
{
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
protected function setUp(): void
{
parent::setUp();
// Seed idempotent via la commande applicative (roles + matrice § 5.2 +
// comptes demo) — meme chemin qu'en recette.
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 : les permissions transport.carriers.* sont-elles synchronisees (app:sync-permissions) ?',
);
self::ensureKernelShutdown();
}
public function testUsineIsForbiddenEverywhere(): void
{
$seed = $this->seedCarrier('Usine Target');
$client = $this->authAs('usine');
$client->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403);
$client->request('GET', '/api/carriers/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403);
$client->request('POST', '/api/carriers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Usine Post'),
]);
self::assertResponseStatusCodeSame(403);
}
public function testComptaHasNoAccess(): void
{
$seed = $this->seedCarrier('Compta Target');
$client = $this->authAs('compta');
// PAS view (matrice § 5.2 : Compta sans acces transporteurs).
$client->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403);
// PAS manage : creation refusee.
$client->request('POST', '/api/carriers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Compta Post'),
]);
self::assertResponseStatusCodeSame(403);
$client->request('PATCH', '/api/carriers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['name' => 'Renamed By Compta'],
]);
self::assertResponseStatusCodeSame(403);
}
public function testBureauHasViewAndManageButNoArchive(): void
{
$seed = $this->seedCarrier('Bureau Target');
$client = $this->authAs('bureau');
// view
$client->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// manage : creation OK
$client->request('POST', '/api/carriers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Bureau Created'),
]);
self::assertResponseStatusCodeSame(201);
// manage : edition formulaire principal OK
$client->request('PATCH', '/api/carriers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['name' => 'Bureau Renamed'],
]);
self::assertResponseStatusCodeSame(200);
// PAS archive : archivage refuse (RG-4.14, gating CarrierProcessor).
$client->request('PATCH', '/api/carriers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testCommercialeHasViewOnly(): void
{
$seed = $this->seedCarrier('Commerciale Target');
$client = $this->authAs('commerciale');
// view (consultation « Tout »)
$client->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// PAS manage : creation refusee
$client->request('POST', '/api/carriers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Commerciale Post'),
]);
self::assertResponseStatusCodeSame(403);
// PAS manage : edition refusee
$client->request('PATCH', '/api/carriers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['name' => 'Renamed By Commerciale'],
]);
self::assertResponseStatusCodeSame(403);
}
private function authAs(string $role): Client
{
return $this->authenticatedClient($role, self::PWD);
}
}
@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
/**
* Ecriture du formulaire principal transporteur (M4, WT4 — ERP-158). Couvre les
* RG du CarrierProcessor + contraintes conditionnelles : RG-4.01 (QUALIMAT + cas
* LIOT), RG-4.02 (AUTRE -> decharge), RG-4.03 (affrete -> indexation/benne/volume),
* RG-4.12 (doublon de nom -> 409), RG-4.13 (normalisation), RG-4.14 (archivage +
* mode strict). Jumeau des SupplierApiTest / SupplierPatchStrictTest (M2).
*
* @internal
*/
final class CarrierWriteApiTest extends AbstractCarrierApiTestCase
{
/**
* RG-4.01 : POST avec qualimatCarrier -> certificationType=QUALIMAT accepte et
* FK persistee (verifiee au detail, qualimatCarrier embarque).
*/
public function testPostQualimatPersistsCertificationAndForeignKey(): void
{
$client = $this->createAdminClient();
$qualimat = $this->seedQualimatCarrier('Transports Grelillier');
$created = $client->request('POST', '/api/carriers', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'name' => 'Transports Grelillier',
'qualimatCarrier' => '/api/qualimat_carriers/'.$qualimat->getId(),
'certificationType' => 'QUALIMAT',
'isChartered' => false,
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('QUALIMAT', $created['certificationType']);
$detail = $client->request('GET', $created['@id'], ['headers' => ['Accept' => self::LD]])->toArray();
self::assertIsArray($detail['qualimatCarrier']);
self::assertSame((int) $qualimat->getId(), (int) $detail['qualimatCarrier']['id']);
}
/**
* RG-4.01 (cas LIOT) : nom = LIOT -> certificationType non requis (champ masque)
* et liotPlates accepte (et normalise, RG-4.13).
*/
public function testPostLiotAcceptsPlatesWithoutCertification(): void
{
$client = $this->createAdminClient();
$created = $client->request('POST', '/api/carriers', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'name' => 'LIOT',
'liotPlates' => 'ab-123-cd ; ef-456-gh',
'isChartered' => false,
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertNull($created['certificationType']);
self::assertSame('AB-123-CD; EF-456-GH', $created['liotPlates']);
}
/**
* RG-4.01 : hors cas LIOT, l'absence de certification est rejetee (422 cible
* sur certificationType).
*/
public function testPostWithoutCertificationOutsideLiotIsRejected(): void
{
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/carriers', [
'headers' => ['Content-Type' => self::LD],
'json' => ['name' => 'Sans Certif', 'isChartered' => false],
]);
self::assertResponseStatusCodeSame(422);
self::assertViolationOnPath($response, 'certificationType');
}
/**
* RG-4.02 : certificationType=AUTRE sans dischargeDocument -> 422 cible ; une
* certification != AUTRE sans decharge passe (201).
*/
public function testAutreCertificationRequiresDischarge(): void
{
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/carriers', [
'headers' => ['Content-Type' => self::LD],
'json' => ['name' => 'Sans Decharge', 'certificationType' => 'AUTRE', 'isChartered' => false],
]);
self::assertResponseStatusCodeSame(422);
self::assertViolationOnPath($response, 'dischargeDocument');
// Certification != AUTRE : pas de decharge requise.
$client->request('POST', '/api/carriers', [
'headers' => ['Content-Type' => self::LD],
'json' => ['name' => 'Avec GmpPlus', 'certificationType' => 'GMP_PLUS', 'isChartered' => false],
]);
self::assertResponseStatusCodeSame(201);
}
/**
* RG-4.03 : isChartered=true sans indexationRate / containerType / volumeM3 ->
* 422 (violations ciblees) ; complet -> 201.
*/
public function testCharteredRequiresIndexationContainerAndVolume(): void
{
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/carriers', [
'headers' => ['Content-Type' => self::LD],
'json' => ['name' => 'Affrete Incomplet', 'certificationType' => 'GMP_PLUS', 'isChartered' => true],
]);
self::assertResponseStatusCodeSame(422);
self::assertViolationOnPath($response, 'indexationRate');
self::assertViolationOnPath($response, 'containerType');
self::assertViolationOnPath($response, 'volumeM3');
$client->request('POST', '/api/carriers', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'name' => 'Affrete Complet',
'certificationType' => 'GMP_PLUS',
'isChartered' => true,
'indexationRate' => '5.00',
'containerType' => 'BENNE',
'volumeM3' => '90.00',
],
]);
self::assertResponseStatusCodeSame(201);
}
/**
* RG-4.12 : nom deja pris (parmi actifs) -> 409. Le meme nom redevient
* disponible apres archivage de l'ancien -> 201.
*/
public function testDuplicateNameReturns409AndIsFreedAfterArchive(): void
{
$client = $this->createAdminClient();
$existing = $this->seedCarrier('Doublon Co');
$client->request('POST', '/api/carriers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Doublon Co'),
]);
self::assertResponseStatusCodeSame(409);
// Archivage de l'ancien -> le nom se libere (index partiel sur actifs).
$client->request('PATCH', '/api/carriers/'.$existing->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(200);
$client->request('POST', '/api/carriers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Doublon Co'),
]);
self::assertResponseStatusCodeSame(201);
}
/**
* RG-4.13 : le nom est persiste en MAJUSCULES (normalisation serveur).
*/
public function testNameIsUpperCasedOnPersist(): void
{
$client = $this->createAdminClient();
$created = $client->request('POST', '/api/carriers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('transports x'),
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('TRANSPORTS X', $created['name']);
}
/**
* RG-4.14 : PATCH isArchived=true par Admin -> 200 + archivedAt rempli ;
* restauration -> archivedAt remis a null.
*/
public function testAdminArchiveSetsArchivedAtAndRestoreClearsIt(): void
{
$client = $this->createAdminClient();
$carrier = $this->seedCarrier('A Archiver');
$archived = $client->request('PATCH', '/api/carriers/'.$carrier->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
])->toArray();
self::assertResponseStatusCodeSame(200);
self::assertTrue($archived['isArchived']);
self::assertNotNull($archived['archivedAt']);
$restored = $client->request('PATCH', '/api/carriers/'.$carrier->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => false],
])->toArray();
self::assertResponseStatusCodeSame(200);
self::assertFalse($restored['isArchived']);
self::assertNull($restored['archivedAt']);
}
/**
* RG-4.14 (mode strict) : une requete d'archivage ne peut modifier aucun autre
* champ ecrivable -> 422.
*/
public function testArchiveRequestMixingOtherFieldIsRejected(): void
{
$client = $this->createAdminClient();
$carrier = $this->seedCarrier('Strict Co');
$client->request('PATCH', '/api/carriers/'.$carrier->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true, 'name' => 'Renamed While Archiving'],
]);
self::assertResponseStatusCodeSame(422);
}
/**
* Verifie qu'une violation 422 cible bien la propriete attendue (propertyPath),
* gage du mapping inline front (useFormErrors, ERP-101).
*/
private function assertViolationOnPath(object $response, string $path): void
{
/** @var \Symfony\Contracts\HttpClient\ResponseInterface $response */
$paths = array_column($response->toArray(false)['violations'] ?? [], 'propertyPath');
self::assertContains(
$path,
$paths,
sprintf('Aucune violation sur "%s" (paths: %s).', $path, implode(', ', $paths)),
);
}
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Application;
use App\Module\Transport\Application\Service\CarrierFieldNormalizer;
use PHPUnit\Framework\TestCase;
/**
* Normalisation serveur des champs texte du repertoire transporteurs (RG-4.13 +
* cas LIOT RG-4.01). Jumeau de SupplierFieldNormalizerTest (M2), enrichi de
* normalizeLiotPlates.
*
* @internal
*/
final class CarrierFieldNormalizerTest extends TestCase
{
private CarrierFieldNormalizer $normalizer;
protected function setUp(): void
{
$this->normalizer = new CarrierFieldNormalizer();
}
public function testNameIsUpperCasedAndTrimmed(): void
{
self::assertSame('TRANSPORTS X', $this->normalizer->normalizeName(' transports x '));
self::assertNull($this->normalizer->normalizeName(null));
}
public function testPersonNameIsTitleCased(): void
{
self::assertSame('Jean Dupont', $this->normalizer->normalizePersonName('JEAN dupont'));
self::assertNull($this->normalizer->normalizePersonName(' '));
self::assertNull($this->normalizer->normalizePersonName(null));
}
public function testEmailIsLowerCased(): void
{
self::assertSame('marie.martin@seed.test', $this->normalizer->normalizeEmail(' Marie.MARTIN@Seed.Test '));
self::assertNull($this->normalizer->normalizeEmail(' '));
self::assertNull($this->normalizer->normalizeEmail(null));
}
public function testPhoneKeepsDigitsOnly(): void
{
self::assertSame('0612345678', $this->normalizer->normalizePhone('06.12.34.56.78'));
self::assertSame('33612345678', $this->normalizer->normalizePhone('+33 6 12 34 56 78'));
self::assertNull($this->normalizer->normalizePhone('sans chiffre'));
self::assertNull($this->normalizer->normalizePhone(null));
}
/**
* RG-4.01 / RG-4.13 : la saisie « ; »-separee est decoupee, chaque plaque trim
* + UPPER, segments vides ecartes, recomposee avec "; ".
*/
public function testLiotPlatesAreSplitTrimmedUpperedAndRejoined(): void
{
self::assertSame(
'AB-123-CD; EF-456-GH',
$this->normalizer->normalizeLiotPlates('ab-123-cd ; ef-456-gh'),
);
// Segments vides (« ;; » / fin de chaine) ecartes.
self::assertSame('AB-123-CD', $this->normalizer->normalizeLiotPlates(' ab-123-cd ; ; '));
self::assertNull($this->normalizer->normalizeLiotPlates(' ; ; '));
self::assertNull($this->normalizer->normalizeLiotPlates(null));
}
}