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, * qualimatCarrier (statut/validite — RG-4.04), updatedAt. * - DETAIL (+ carrier:item:read + embeds client/supplier/site...) : sous-collections * addresses / contacts / prices embarquees, avec les entites cross-module * (Client/Supplier/Site/adresses) serialisees via leurs read-groups. * * Pas de #[ORM\UniqueConstraint] : l'unicite du nom (RG-4.12) est portee par * l'index partiel fonctionnel uq_carrier_name_active (LOWER(name) WHERE * is_archived = FALSE AND deleted_at IS NULL), inexprimable en attribut ORM. */ #[ApiResource( operations: [ new GetCollection( security: "is_granted('transport.carriers.view')", // Liste : embarque qualimatCarrier (ManyToOne, fetch-join sur cette // seule relation cote repository — § 2.11) pour le statut/date de // validite QUALIMAT (RG-4.04). Aucune sous-collection en liste. normalizationContext: ['groups' => ['carrier:read', 'qualimat:read', 'default:read']], provider: CarrierProvider::class, ), new Get( security: "is_granted('transport.carriers.view')", // Detail : transporteur + qualimatCarrier + sous-collections embarquees // (addresses / contacts / prices). Les relations cross-module des prix // (client / supplier / sites / adresses) sont embarquees via leurs // read-groups (client:read / supplier:read / ... — bugs #1/#2 M1). normalizationContext: ['groups' => [ 'carrier:read', 'carrier:item:read', 'qualimat:read', 'client:read', 'client_address:read', 'supplier:read', 'supplier_address:read', 'site:read', 'default:read', ]], provider: CarrierProvider::class, ), 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\Table(name: 'carrier')] #[ORM\Index(name: 'idx_carrier_is_archived', columns: ['is_archived'])] #[ORM\Index(name: 'idx_carrier_deleted_at', columns: ['deleted_at'])] #[ORM\Index(name: 'idx_carrier_qualimat', columns: ['qualimat_carrier_id'])] #[ORM\Index(name: 'idx_carrier_discharge_document', columns: ['discharge_document_id'])] #[ORM\Index(name: 'idx_carrier_created_by', columns: ['created_by'])] #[ORM\Index(name: 'idx_carrier_updated_by', columns: ['updated_by'])] #[Auditable] 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] #[Groups(['carrier:read'])] private ?int $id = null; #[ORM\Column(length: 255)] #[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', '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)] #[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 — obligatoire si affrete (RG-4.03, validateMainFormConsistency). */ #[ORM\Column(name: 'indexation_rate', type: 'decimal', precision: 5, scale: 2, nullable: true)] #[Groups(['carrier:read', 'carrier:write:main'])] private ?string $indexationRate = null; /** BENNE|FOND_MOUVANT — obligatoire si affrete (RG-4.03). */ #[ORM\Column(name: 'container_type', length: 12, nullable: true)] #[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 — obligatoire si affrete (RG-4.03). */ #[ORM\Column(name: 'volume_m3', type: 'decimal', precision: 10, scale: 2, nullable: true)] #[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', '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', 'carrier:write:main'])] private ?string $liotPlates = null; // === Sous-collections — EMBARQUEES dans le DETAIL (read-group sur le getter) === /** @var Collection */ #[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $addresses; /** @var Collection */ #[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $contacts; /** @var Collection */ #[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierPrice::class, cascade: ['persist', 'remove'], orphanRemoval: true)] 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)] #[Groups(['carrier:read'])] private ?DateTimeImmutable $archivedAt = null; #[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)] private ?DateTimeImmutable $deletedAt = null; public function __construct() { $this->addresses = new ArrayCollection(); $this->contacts = 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 { return $this->id; } public function getName(): ?string { return $this->name; } public function setName(string $name): static { $this->name = $name; return $this; } public function getQualimatCarrier(): ?QualimatCarrier { return $this->qualimatCarrier; } public function setQualimatCarrier(?QualimatCarrier $qualimatCarrier): static { $this->qualimatCarrier = $qualimatCarrier; return $this; } public function getCertificationType(): ?string { return $this->certificationType; } public function setCertificationType(?string $certificationType): static { $this->certificationType = $certificationType; return $this; } // Boolean trap (RETEX M1 bug #3) : #[Groups] + #[SerializedName] sur le getter, // sinon Symfony strip le prefixe "is" et drope la cle du JSON. #[Groups(['carrier:read'])] #[SerializedName('isChartered')] public function isChartered(): bool { return $this->isChartered; } public function setIsChartered(bool $isChartered): static { $this->isChartered = $isChartered; return $this; } public function getIndexationRate(): ?string { return $this->indexationRate; } public function setIndexationRate(?string $indexationRate): static { $this->indexationRate = $indexationRate; return $this; } public function getContainerType(): ?string { return $this->containerType; } public function setContainerType(?string $containerType): static { $this->containerType = $containerType; return $this; } public function getVolumeM3(): ?string { return $this->volumeM3; } public function setVolumeM3(?string $volumeM3): static { $this->volumeM3 = $volumeM3; return $this; } public function getDischargeDocument(): ?UploadedDocument { return $this->dischargeDocument; } public function setDischargeDocument(?UploadedDocument $dischargeDocument): static { $this->dischargeDocument = $dischargeDocument; return $this; } public function getLiotPlates(): ?string { return $this->liotPlates; } public function setLiotPlates(?string $liotPlates): static { $this->liotPlates = $liotPlates; return $this; } /** @return Collection */ #[Groups(['carrier:item:read'])] public function getAddresses(): Collection { return $this->addresses; } public function addAddress(CarrierAddress $address): static { if (!$this->addresses->contains($address)) { $this->addresses->add($address); $address->setCarrier($this); } return $this; } public function removeAddress(CarrierAddress $address): static { if ($this->addresses->removeElement($address) && $address->getCarrier() === $this) { $address->setCarrier(null); } return $this; } /** @return Collection */ #[Groups(['carrier:item:read'])] public function getContacts(): Collection { return $this->contacts; } public function addContact(CarrierContact $contact): static { if (!$this->contacts->contains($contact)) { $this->contacts->add($contact); $contact->setCarrier($this); } return $this; } public function removeContact(CarrierContact $contact): static { if ($this->contacts->removeElement($contact) && $contact->getCarrier() === $this) { $contact->setCarrier(null); } return $this; } /** @return Collection */ #[Groups(['carrier:item:read'])] public function getPrices(): Collection { return $this->prices; } public function addPrice(CarrierPrice $price): static { if (!$this->prices->contains($price)) { $this->prices->add($price); $price->setCarrier($this); } return $this; } public function removePrice(CarrierPrice $price): static { if ($this->prices->removeElement($price) && $price->getCarrier() === $this) { $price->setCarrier(null); } return $this; } // Boolean trap (cf. isChartered) : groupe de lecture + SerializedName sur le getter. #[Groups(['carrier:read'])] #[SerializedName('isArchived')] public function isArchived(): bool { return $this->isArchived; } public function setIsArchived(bool $isArchived): static { $this->isArchived = $isArchived; return $this; } public function getArchivedAt(): ?DateTimeImmutable { return $this->archivedAt; } public function setArchivedAt(?DateTimeImmutable $archivedAt): static { $this->archivedAt = $archivedAt; return $this; } public function getDeletedAt(): ?DateTimeImmutable { return $this->deletedAt; } public function setDeletedAt(?DateTimeImmutable $deletedAt): static { $this->deletedAt = $deletedAt; return $this; } }