Files
Starseed/src/Module/Transport/Domain/Entity/Carrier.php
T
Matthieu d9313dbec8 feat(transport) : schéma + entités Carrier + contrat lecture (ERP-155/157)
Schéma BDD du répertoire transporteurs (M4) + entités + contrat de lecture
(liste + détail), socle du front.

- Migration Version20260615150000 : tables carrier / carrier_address /
  carrier_contact / carrier_price (FK cross-module, CHECK enum, index partiel
  uq_carrier_name_active, COMMENT ON COLUMN). uploaded_document et
  qualimat_carrier réutilisées (non recréées).
- Entités Carrier* (#[Auditable], Timestampable/Blamable) + ApiResource
  LECTURE seule (GetCollection + Get via CarrierProvider, anti-N+1, exclusion
  archivés + ?includeArchived). Écriture (POST/PATCH + Processor) reportée WT4+.
- QualimatCarrier : mapping ORM lecture seule sur la table référentielle
  existante (sortie du schema_filter, mapping aligné DDL ERP-39, schema:update
  no-op) + endpoint de recherche read-only (§ 4.7).
- Relations cross-module des prix (Client/Supplier/adresses) via contrats
  Shared (ClientInterface, SupplierInterface, ClientAddressInterface,
  SupplierAddressInterface) + resolve_target_entities — sans import inter-module
  (règle n°1). Ajout du groupe supplier_address:read aux champs de
  SupplierAddress pour l'embed.
- Garde-fous : ColumnCommentsCatalog (carrier* + qualimat_carrier), makefile
  test-db-setup (index partiel carrier), i18n audit (transport_carrier*),
  EntitiesAreTimestampableBlamableTest (QualimatCarrier whitelisté).
- CarrierSerializationContractTest : contrat JSON liste + détail vérifié
  (embeds objet, booléens, enveloppe Hydra) ; JSON réel capturé dans
  spec-back § 4.0.bis.

make db-reset OK, make test vert (731), make nuxt-test vert (480),
php-cs-fixer OK.
2026-06-15 19:15:12 +02:00

413 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Module\Transport\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Transport\Infrastructure\ApiPlatform\State\Provider\CarrierProvider;
use App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Entity\UploadedDocument;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
/**
* Transporteur (M4 Transport) — entite racine du repertoire transporteurs,
* jumelle de Supplier (M2) / Provider (M3). Porte le formulaire principal, le
* lien editable vers le referentiel QUALIMAT (§ 2.5), l'archivage
* (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.
*
* 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,
),
// Pas de Post/Patch/Delete au WT3 (lecture seule). Ecriture + archivage : WT4.
],
)]
#[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;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['carrier:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['carrier:read'])]
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'])]
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'])]
private ?string $certificationType = null;
#[ORM\Column(name: 'is_chartered', options: ['default' => false])]
private bool $isChartered = false;
/** % d'indexation — renseigne si affrete (RG-4.03). */
#[ORM\Column(name: 'indexation_rate', type: 'decimal', precision: 5, scale: 2, nullable: true)]
#[Groups(['carrier:read'])]
private ?string $indexationRate = null;
/** BENNE|FOND_MOUVANT — renseigne si affrete (RG-4.03). */
#[ORM\Column(name: 'container_type', length: 12, nullable: true)]
#[Groups(['carrier:read'])]
private ?string $containerType = null;
/** Volume m3 — renseigne si affrete (RG-4.03). */
#[ORM\Column(name: 'volume_m3', type: 'decimal', precision: 10, scale: 2, nullable: true)]
#[Groups(['carrier:read'])]
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'])]
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'])]
private ?string $liotPlates = null;
// === Sous-collections — EMBARQUEES dans le DETAIL (read-group sur le getter) ===
/** @var Collection<int, CarrierAddress> */
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $addresses;
/** @var Collection<int, CarrierContact> */
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contacts;
/** @var Collection<int, CarrierPrice> */
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierPrice::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $prices;
// === Archive / Soft delete ===
#[ORM\Column(name: 'is_archived', options: ['default' => false])]
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();
}
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<int, CarrierAddress> */
#[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<int, CarrierContact> */
#[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<int, CarrierPrice> */
#[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;
}
}