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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user