From 97d7cacd2c2da35ed1e4665f7e0adfd792fdff74 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 16 Jun 2026 08:14:54 +0200 Subject: [PATCH] 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). --- .../Service/CarrierFieldNormalizer.php | 105 +++++++ .../Transport/Domain/Entity/Carrier.php | 157 ++++++++-- .../State/Processor/CarrierProcessor.php | 275 ++++++++++++++++++ ...EntityConstraintsHaveFrenchMessageTest.php | 4 + .../Api/AbstractCarrierApiTestCase.php | 19 +- .../Transport/Api/CarrierArchiveTest.php | 36 +++ .../Transport/Api/CarrierRBACMatrixTest.php | 158 ++++++++++ .../Transport/Api/CarrierWriteApiTest.php | 240 +++++++++++++++ .../CarrierFieldNormalizerTest.php | 69 +++++ 9 files changed, 1040 insertions(+), 23 deletions(-) create mode 100644 src/Module/Transport/Application/Service/CarrierFieldNormalizer.php create mode 100644 src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierProcessor.php create mode 100644 tests/Module/Transport/Api/CarrierArchiveTest.php create mode 100644 tests/Module/Transport/Api/CarrierRBACMatrixTest.php create mode 100644 tests/Module/Transport/Api/CarrierWriteApiTest.php create mode 100644 tests/Module/Transport/Application/CarrierFieldNormalizerTest.php diff --git a/src/Module/Transport/Application/Service/CarrierFieldNormalizer.php b/src/Module/Transport/Application/Service/CarrierFieldNormalizer.php new file mode 100644 index 0000000..9880294 --- /dev/null +++ b/src/Module/Transport/Application/Service/CarrierFieldNormalizer.php @@ -0,0 +1,105 @@ + "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); + } +} diff --git a/src/Module/Transport/Domain/Entity/Carrier.php b/src/Module/Transport/Domain/Entity/Carrier.php index 6d10014..de39955 100644 --- a/src/Module/Transport/Domain/Entity/Carrier.php +++ b/src/Module/Transport/Domain/Entity/Carrier.php @@ -7,6 +7,9 @@ namespace App\Module\Transport\Domain\Entity; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; 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\Doctrine\DoctrineCarrierRepository; use App\Shared\Domain\Attribute\Auditable; @@ -20,6 +23,8 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; 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, @@ -28,16 +33,17 @@ use Symfony\Component\Serializer\Attribute\SerializedName; * (is_archived / archived_at) et le soft-delete technique prepare mais non * expose au M4 (deleted_at). * - * Perimetre WT3 (ERP-155/157) = CONTRAT DE LECTURE uniquement : l'#[ApiResource] - * n'expose que GetCollection + Get (via CarrierProvider). La creation / - * modification (POST/PATCH + CarrierProcessor : normalisation, RG-4.01→4.14, - * 409 doublon, gating archive) et les sous-ressources d'ecriture - * (adresses/contacts/prix) arrivent aux worktrees suivants (WT4+). C'est - * pourquoi les proprietes ne portent ICI que des read-groups (carrier:read / - * carrier:item:read / qualimat:read), sans groupe d'ecriture ni contrainte - * Assert de validation (qui appartiennent au flux d'ecriture). Les invariants - * BDD (NOT NULL, CHECK enum, FK, unicite partielle) restent garantis par la - * migration Version20260615150000. + * Perimetre WT4 (ERP-158) = formulaire principal en ecriture. L'#[ApiResource] + * expose desormais Post + Patch (via CarrierProcessor : normalisation RG-4.13, + * gating archive mode strict RG-4.14, 409 doublon de nom RG-4.12) en plus du + * contrat de lecture pose au WT3. Les proprietes du formulaire principal portent + * leur groupe d'ecriture (carrier:write:main / carrier:write:archive) et leurs + * contraintes Assert ; les RG conditionnelles (RG-4.01 certification obligatoire + * sauf LIOT, RG-4.02 AUTRE -> decharge, RG-4.03 affrete -> indexation/benne/volume) + * sont portees par validateMainFormConsistency (Assert\Callback + ->atPath()). + * Les sous-ressources d'ecriture (adresses/contacts/prix) arrivent aux worktrees + * 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) : * - LISTE (carrier:read + qualimat:read + default:read) : name, certificationType, @@ -79,7 +85,27 @@ use Symfony\Component\Serializer\Attribute\SerializedName; ]], 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)] @@ -95,6 +121,12 @@ class Carrier implements TimestampableInterface, BlamableInterface { 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\GeneratedValue] #[ORM\Column] @@ -102,47 +134,65 @@ class Carrier implements TimestampableInterface, BlamableInterface private ?int $id = null; #[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; /** Lien editable vers le referentiel QUALIMAT (saisie assistee RG-4.01, § 2.5). */ #[ORM\ManyToOne(targetEntity: QualimatCarrier::class)] #[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; /** QUALIMAT|GMP_PLUS|OVOCOM|COMPTE_PROPRE|AUTRE ; null en cas LIOT (RG-4.01). */ #[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; #[ORM\Column(name: 'is_chartered', options: ['default' => false])] + #[Groups(['carrier:write:main'])] 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)] - #[Groups(['carrier:read'])] + #[Groups(['carrier:read', 'carrier:write:main'])] 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)] - #[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; - /** 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)] - #[Groups(['carrier:read'])] + #[Groups(['carrier:read', 'carrier:write:main'])] private ?string $volumeM3 = null; /** Decharge (upload, visible si certificationType = AUTRE — RG-4.02). Infra Shared (§ 2.7). */ #[ORM\ManyToOne(targetEntity: UploadedDocument::class)] #[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; /** Immatriculations LIOT separees par « ; » (cas LIOT — RG-4.01). */ #[ORM\Column(name: 'liot_plates', type: 'text', nullable: true)] - #[Groups(['carrier:read'])] + #[Groups(['carrier:read', 'carrier:write:main'])] private ?string $liotPlates = null; // === Sous-collections — EMBARQUEES dans le DETAIL (read-group sur le getter) === @@ -159,7 +209,11 @@ class Carrier implements TimestampableInterface, BlamableInterface private Collection $prices; // === 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])] + #[Groups(['carrier:write:archive'])] private bool $isArchived = false; #[ORM\Column(name: 'archived_at', type: 'datetime_immutable', nullable: true)] @@ -176,6 +230,65 @@ class Carrier implements TimestampableInterface, BlamableInterface $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 { return $this->id; diff --git a/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierProcessor.php b/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierProcessor.php new file mode 100644 index 0000000..27d39a3 --- /dev/null +++ b/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierProcessor.php @@ -0,0 +1,275 @@ + 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 + */ +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 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 $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 + */ + 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 + */ + 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 + */ + 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 + */ + 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')) : []; + } +} diff --git a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php index 1e2213f..a8978ea 100644 --- a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php +++ b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php @@ -58,6 +58,10 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase 'ProviderAddress::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). + '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. 'Site::color' => 'Regex code hex #RRGGBB borne deja la longueur.', ]; diff --git a/tests/Module/Transport/Api/AbstractCarrierApiTestCase.php b/tests/Module/Transport/Api/AbstractCarrierApiTestCase.php index 8a2310f..e613d25 100644 --- a/tests/Module/Transport/Api/AbstractCarrierApiTestCase.php +++ b/tests/Module/Transport/Api/AbstractCarrierApiTestCase.php @@ -31,7 +31,8 @@ use DateTimeImmutable; */ 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). */ private const string TEST_SIRET_PREFIX = 'TESTQ'; @@ -63,6 +64,22 @@ abstract class AbstractCarrierApiTestCase extends AbstractApiTestCase 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 + */ + 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 * futur Processor). Sert aux tests de liste / archivage. diff --git a/tests/Module/Transport/Api/CarrierArchiveTest.php b/tests/Module/Transport/Api/CarrierArchiveTest.php new file mode 100644 index 0000000..3b5eefb --- /dev/null +++ b/tests/Module/Transport/Api/CarrierArchiveTest.php @@ -0,0 +1,36 @@ +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); + } +} diff --git a/tests/Module/Transport/Api/CarrierRBACMatrixTest.php b/tests/Module/Transport/Api/CarrierRBACMatrixTest.php new file mode 100644 index 0000000..6e248fd --- /dev/null +++ b/tests/Module/Transport/Api/CarrierRBACMatrixTest.php @@ -0,0 +1,158 @@ +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); + } +} diff --git a/tests/Module/Transport/Api/CarrierWriteApiTest.php b/tests/Module/Transport/Api/CarrierWriteApiTest.php new file mode 100644 index 0000000..130575b --- /dev/null +++ b/tests/Module/Transport/Api/CarrierWriteApiTest.php @@ -0,0 +1,240 @@ + 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)), + ); + } +} diff --git a/tests/Module/Transport/Application/CarrierFieldNormalizerTest.php b/tests/Module/Transport/Application/CarrierFieldNormalizerTest.php new file mode 100644 index 0000000..c1b1833 --- /dev/null +++ b/tests/Module/Transport/Application/CarrierFieldNormalizerTest.php @@ -0,0 +1,69 @@ +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)); + } +}