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

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

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

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

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

Tests : CarrierWriteApiTest (RG-4.01->4.03/4.12->4.14), CarrierRBACMatrixTest
(matrice bureau/compta/commerciale/usine), CarrierArchiveTest (409 restauration),
CarrierFieldNormalizerTest (RG-4.13). make test vert (750).
This commit is contained in:
Matthieu
2026-06-16 08:14:54 +02:00
parent 0f62deb80f
commit cbabe8f9ac
9 changed files with 1040 additions and 23 deletions
+135 -22
View File
@@ -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;