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.
This commit is contained in:
Matthieu
2026-06-15 19:15:12 +02:00
parent e607cccf08
commit d9313dbec8
39 changed files with 4696 additions and 16 deletions
@@ -15,6 +15,7 @@ use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\ClientInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
@@ -147,7 +148,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Index(name: 'idx_client_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_client_updated_by', columns: ['updated_by'])]
#[Auditable]
class Client implements TimestampableInterface, BlamableInterface
class Client implements TimestampableInterface, BlamableInterface, ClientInterface
{
use TimestampableBlamableTrait;
@@ -15,6 +15,7 @@ use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientAddressRepositor
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\ClientAddressInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
@@ -89,7 +90,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Table(name: 'client_address')]
#[ORM\Index(name: 'idx_client_address_client', columns: ['client_id'])]
#[Auditable]
class ClientAddress implements TimestampableInterface, BlamableInterface
class ClientAddress implements TimestampableInterface, BlamableInterface, ClientAddressInterface
{
use TimestampableBlamableTrait;
@@ -16,6 +16,7 @@ use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SupplierInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
@@ -142,7 +143,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Index(name: 'idx_supplier_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_supplier_updated_by', columns: ['updated_by'])]
#[Auditable]
class Supplier implements TimestampableInterface, BlamableInterface
class Supplier implements TimestampableInterface, BlamableInterface, SupplierInterface
{
use TimestampableBlamableTrait;
@@ -16,6 +16,7 @@ use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SupplierAddressInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\Common\Collections\ArrayCollection;
@@ -96,7 +97,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Table(name: 'supplier_address')]
#[ORM\Index(name: 'idx_supplier_address_supplier', columns: ['supplier_id'])]
#[Auditable]
class SupplierAddress implements TimestampableInterface, BlamableInterface
class SupplierAddress implements TimestampableInterface, BlamableInterface, SupplierAddressInterface
{
use TimestampableBlamableTrait;
@@ -117,7 +118,11 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['supplier:item:read'])]
// supplier_address:read : groupe additif consomme par l'embed cross-module
// (CarrierPrice.supplierSupplyAddress, M4 § 3.4). Inerte pour M2 (ses contextes
// ne l'incluent pas) — expose le libelle d'adresse quand un autre module embarque
// une SupplierAddress.
#[Groups(['supplier:item:read', 'supplier_address:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Supplier::class, inversedBy: 'addresses')]
@@ -130,12 +135,12 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
#[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'Le type d\'adresse est obligatoire.', normalizer: 'trim')]
#[Assert\Choice(choices: self::ADDRESS_TYPES, message: 'Le type d\'adresse doit être Prospect, Départ ou Rendu.')]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
private ?string $addressType = null;
#[ORM\Column(length: 80, options: ['default' => 'France'])]
#[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
private string $country = 'France';
// RG-2.05 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur).
@@ -143,24 +148,24 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
#[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'Le code postal est obligatoire.', normalizer: 'trim')]
#[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
private ?string $postalCode = null;
#[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
private ?string $city = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
private ?string $street = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
private ?string $streetComplement = null;
// Specifique fournisseur : nombre de bennes sur le site.
@@ -0,0 +1,412 @@
<?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;
}
}
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Domain\Entity;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Adresse d'un transporteur (1:n) — onglet Adresse (M4). Jumelle de
* SupplierAddress (M2), version simplifiee (pas de type d'adresse, pas de M2M
* sites/categories sur l'adresse : les sites du M4 vivent dans l'onglet Prix).
*
* WT3 (ERP-155/157) = LECTURE seule : proprietes en `carrier:item:read`
* (embarquees au detail du transporteur). Les sous-ressources d'ecriture
* (POST/PATCH/DELETE) + RG-4.05→4.07 arrivent au worktree dedie (WT6).
*/
#[ORM\Entity]
#[ORM\Table(name: 'carrier_address')]
#[ORM\Index(name: 'idx_carrier_address_carrier', columns: ['carrier_id'])]
#[ORM\Index(name: 'idx_carrier_address_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_carrier_address_updated_by', columns: ['updated_by'])]
#[Auditable]
class CarrierAddress implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['carrier:item:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Carrier::class, inversedBy: 'addresses')]
#[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Carrier $carrier = null;
#[ORM\Column(length: 80, options: ['default' => 'France'])]
#[Groups(['carrier:item:read'])]
private string $country = 'France';
#[ORM\Column(name: 'postal_code', length: 20, nullable: true)]
#[Groups(['carrier:item:read'])]
private ?string $postalCode = null;
#[ORM\Column(length: 120, nullable: true)]
#[Groups(['carrier:item:read'])]
private ?string $city = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['carrier:item:read'])]
private ?string $street = null;
#[ORM\Column(name: 'street_complement', length: 255, nullable: true)]
#[Groups(['carrier:item:read'])]
private ?string $streetComplement = null;
#[ORM\Column(options: ['default' => 0])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getCarrier(): ?Carrier
{
return $this->carrier;
}
public function setCarrier(?Carrier $carrier): static
{
$this->carrier = $carrier;
return $this;
}
public function getCountry(): string
{
return $this->country;
}
public function setCountry(string $country): static
{
$this->country = $country;
return $this;
}
public function getPostalCode(): ?string
{
return $this->postalCode;
}
public function setPostalCode(?string $postalCode): static
{
$this->postalCode = $postalCode;
return $this;
}
public function getCity(): ?string
{
return $this->city;
}
public function setCity(?string $city): static
{
$this->city = $city;
return $this;
}
public function getStreet(): ?string
{
return $this->street;
}
public function setStreet(?string $street): static
{
$this->street = $street;
return $this;
}
public function getStreetComplement(): ?string
{
return $this->streetComplement;
}
public function setStreetComplement(?string $streetComplement): static
{
$this->streetComplement = $streetComplement;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Domain\Entity;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Contact d'un transporteur (1:n) — onglet Contact (M4). Jumeau de
* SupplierContact (M2) : au moins un champ rempli (RG-4.08, garanti par le
* CHECK chk_carrier_contact_filled + le Processor), max 2 telephones.
*
* WT3 (ERP-155/157) = LECTURE seule : proprietes en `carrier:item:read`
* (embarquees au detail). Les sous-ressources d'ecriture arrivent au WT7.
*/
#[ORM\Entity]
#[ORM\Table(name: 'carrier_contact')]
#[ORM\Index(name: 'idx_carrier_contact_carrier', columns: ['carrier_id'])]
#[ORM\Index(name: 'idx_carrier_contact_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_carrier_contact_updated_by', columns: ['updated_by'])]
#[Auditable]
class CarrierContact implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['carrier:item:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Carrier::class, inversedBy: 'contacts')]
#[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Carrier $carrier = null;
#[ORM\Column(name: 'first_name', length: 120, nullable: true)]
#[Groups(['carrier:item:read'])]
private ?string $firstName = null;
#[ORM\Column(name: 'last_name', length: 120, nullable: true)]
#[Groups(['carrier:item:read'])]
private ?string $lastName = null;
#[ORM\Column(name: 'job_title', length: 120, nullable: true)]
#[Groups(['carrier:item:read'])]
private ?string $jobTitle = null;
#[ORM\Column(name: 'phone_primary', length: 20, nullable: true)]
#[Groups(['carrier:item:read'])]
private ?string $phonePrimary = null;
#[ORM\Column(name: 'phone_secondary', length: 20, nullable: true)]
#[Groups(['carrier:item:read'])]
private ?string $phoneSecondary = null;
#[ORM\Column(length: 180, nullable: true)]
#[Groups(['carrier:item:read'])]
private ?string $email = null;
#[ORM\Column(options: ['default' => 0])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getCarrier(): ?Carrier
{
return $this->carrier;
}
public function setCarrier(?Carrier $carrier): static
{
$this->carrier = $carrier;
return $this;
}
public function getFirstName(): ?string
{
return $this->firstName;
}
public function setFirstName(?string $firstName): static
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): ?string
{
return $this->lastName;
}
public function setLastName(?string $lastName): static
{
$this->lastName = $lastName;
return $this;
}
public function getJobTitle(): ?string
{
return $this->jobTitle;
}
public function setJobTitle(?string $jobTitle): static
{
$this->jobTitle = $jobTitle;
return $this;
}
public function getPhonePrimary(): ?string
{
return $this->phonePrimary;
}
public function setPhonePrimary(?string $phonePrimary): static
{
$this->phonePrimary = $phonePrimary;
return $this;
}
public function getPhoneSecondary(): ?string
{
return $this->phoneSecondary;
}
public function setPhoneSecondary(?string $phoneSecondary): static
{
$this->phoneSecondary = $phoneSecondary;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): static
{
$this->email = $email;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,284 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Domain\Entity;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\ClientAddressInterface;
use App\Shared\Domain\Contract\ClientInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SupplierAddressInterface;
use App\Shared\Domain\Contract\SupplierInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Prix d'un transporteur (1:n) — onglet Prix (M4, RG-4.09→4.11). Une ligne porte
* soit une branche CLIENT (client + adresse de livraison + site de depart), soit
* une branche FOURNISSEUR (supplier + adresse d'appro + site de livraison),
* selon `direction`. La coherence des branches est garantie en BDD par les CHECK
* chk_carrier_price_client_branch / chk_carrier_price_supplier_branch.
*
* Relations cross-module (Client/Supplier/adresses M1-M2, Site Sites) referencees
* via des contrats Shared (ClientInterface, SupplierInterface, ...) + resolve_target_entities
* — JAMAIS d'import direct d'une entite d'un autre module (regle ABSOLUE n°1).
* L'embed JSON au detail passe par les read-groups des entites concretes
* (client:read / client_address:read / supplier:read / supplier_address:read /
* site:read), inclus dans le contexte du Get racine de Carrier (§ 4.0).
*
* WT3 (ERP-155/157) = LECTURE seule : proprietes en `carrier:item:read`. Les
* sous-ressources d'ecriture + validation des branches (Processor) : WT8.
*/
#[ORM\Entity]
#[ORM\Table(name: 'carrier_price')]
#[ORM\Index(name: 'idx_carrier_price_carrier', columns: ['carrier_id'])]
#[ORM\Index(name: 'idx_carrier_price_client', columns: ['client_id'])]
#[ORM\Index(name: 'idx_carrier_price_client_address', columns: ['client_delivery_address_id'])]
#[ORM\Index(name: 'idx_carrier_price_departure_site', columns: ['departure_site_id'])]
#[ORM\Index(name: 'idx_carrier_price_supplier', columns: ['supplier_id'])]
#[ORM\Index(name: 'idx_carrier_price_supplier_address', columns: ['supplier_supply_address_id'])]
#[ORM\Index(name: 'idx_carrier_price_delivery_site', columns: ['delivery_site_id'])]
#[ORM\Index(name: 'idx_carrier_price_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_carrier_price_updated_by', columns: ['updated_by'])]
#[Auditable]
class CarrierPrice implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['carrier:item:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Carrier::class, inversedBy: 'prices')]
#[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Carrier $carrier = null;
/** CLIENT|FOURNISSEUR (RG-4.09) — pilote la branche active. */
#[ORM\Column(length: 12)]
#[Groups(['carrier:item:read'])]
private ?string $direction = null;
// === Branche CLIENT (RG-4.10) ===
#[ORM\ManyToOne(targetEntity: ClientInterface::class)]
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['carrier:item:read'])]
private ?ClientInterface $client = null;
#[ORM\ManyToOne(targetEntity: ClientAddressInterface::class)]
#[ORM\JoinColumn(name: 'client_delivery_address_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['carrier:item:read'])]
private ?ClientAddressInterface $clientDeliveryAddress = null;
/** Adresse de depart = un des 3 sites (86/17/82). */
#[ORM\ManyToOne(targetEntity: SiteInterface::class)]
#[ORM\JoinColumn(name: 'departure_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['carrier:item:read'])]
private ?SiteInterface $departureSite = null;
// === Branche FOURNISSEUR (RG-4.11) ===
#[ORM\ManyToOne(targetEntity: SupplierInterface::class)]
#[ORM\JoinColumn(name: 'supplier_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['carrier:item:read'])]
private ?SupplierInterface $supplier = null;
#[ORM\ManyToOne(targetEntity: SupplierAddressInterface::class)]
#[ORM\JoinColumn(name: 'supplier_supply_address_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['carrier:item:read'])]
private ?SupplierAddressInterface $supplierSupplyAddress = null;
/** Adresse de livraison = un des 3 sites (86/17/82). */
#[ORM\ManyToOne(targetEntity: SiteInterface::class)]
#[ORM\JoinColumn(name: 'delivery_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['carrier:item:read'])]
private ?SiteInterface $deliverySite = null;
// === Commun ===
/** BENNE|FOND_MOUVANT. */
#[ORM\Column(name: 'container_type', length: 12)]
#[Groups(['carrier:item:read'])]
private ?string $containerType = null;
/** FORFAIT|TONNE. */
#[ORM\Column(name: 'pricing_unit', length: 8)]
#[Groups(['carrier:item:read'])]
private ?string $pricingUnit = null;
#[ORM\Column(type: 'decimal', precision: 12, scale: 2)]
#[Groups(['carrier:item:read'])]
private ?string $price = null;
/** EN_COURS|VALIDE|NON_VALIDE. */
#[ORM\Column(name: 'price_state', length: 12)]
#[Groups(['carrier:item:read'])]
private ?string $priceState = null;
#[ORM\Column(options: ['default' => 0])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getCarrier(): ?Carrier
{
return $this->carrier;
}
public function setCarrier(?Carrier $carrier): static
{
$this->carrier = $carrier;
return $this;
}
public function getDirection(): ?string
{
return $this->direction;
}
public function setDirection(?string $direction): static
{
$this->direction = $direction;
return $this;
}
public function getClient(): ?ClientInterface
{
return $this->client;
}
public function setClient(?ClientInterface $client): static
{
$this->client = $client;
return $this;
}
public function getClientDeliveryAddress(): ?ClientAddressInterface
{
return $this->clientDeliveryAddress;
}
public function setClientDeliveryAddress(?ClientAddressInterface $clientDeliveryAddress): static
{
$this->clientDeliveryAddress = $clientDeliveryAddress;
return $this;
}
public function getDepartureSite(): ?SiteInterface
{
return $this->departureSite;
}
public function setDepartureSite(?SiteInterface $departureSite): static
{
$this->departureSite = $departureSite;
return $this;
}
public function getSupplier(): ?SupplierInterface
{
return $this->supplier;
}
public function setSupplier(?SupplierInterface $supplier): static
{
$this->supplier = $supplier;
return $this;
}
public function getSupplierSupplyAddress(): ?SupplierAddressInterface
{
return $this->supplierSupplyAddress;
}
public function setSupplierSupplyAddress(?SupplierAddressInterface $supplierSupplyAddress): static
{
$this->supplierSupplyAddress = $supplierSupplyAddress;
return $this;
}
public function getDeliverySite(): ?SiteInterface
{
return $this->deliverySite;
}
public function setDeliverySite(?SiteInterface $deliverySite): static
{
$this->deliverySite = $deliverySite;
return $this;
}
public function getContainerType(): ?string
{
return $this->containerType;
}
public function setContainerType(?string $containerType): static
{
$this->containerType = $containerType;
return $this;
}
public function getPricingUnit(): ?string
{
return $this->pricingUnit;
}
public function setPricingUnit(?string $pricingUnit): static
{
$this->pricingUnit = $pricingUnit;
return $this;
}
public function getPrice(): ?string
{
return $this->price;
}
public function setPrice(?string $price): static
{
$this->price = $price;
return $this;
}
public function getPriceState(): ?string
{
return $this->priceState;
}
public function setPriceState(?string $priceState): static
{
$this->priceState = $priceState;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
/**
* Mapping ORM LECTURE SEULE sur la table existante `qualimat_carrier`
* (referentiel des transporteurs agrees QUALIMAT, ERP-39). La table est
* alimentee/soft-deletee EXCLUSIVEMENT par la commande console `app:qualimat:sync` ;
* cette entite n'expose donc AUCUNE ecriture (ni Post/Patch/Delete).
*
* Role M4 (ERP-155/157) :
* - cible de la FK editable `carrier.qualimat_carrier_id` (§ 2.5) ;
* - embarquee (groupe `qualimat:read`) dans la liste et le detail Carrier pour
* afficher statut + date de validite QUALIMAT (RG-4.04) ;
* - endpoint de recherche `GET /api/qualimat_carriers?...` pour la saisie
* assistee du nom (§ 4.7) — filtres built-in name/siret (partiel), isActive.
*
* La table reste hors `schema_filter` Doctrine (doctrine.yaml) : c'est la
* migration modulaire Version20260612150000 qui possede son DDL et ses COMMENT
* (pas l'ORM). Lecture seule + referentiel synchronise => exclue de
* EntitiesAreTimestampableBlamableTest et non #[Auditable].
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('transport.carriers.view')",
normalizationContext: ['groups' => ['qualimat:read', 'default:read']],
),
new Get(
security: "is_granted('transport.carriers.view')",
normalizationContext: ['groups' => ['qualimat:read', 'default:read']],
),
],
)]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'siret' => 'partial'])]
#[ApiFilter(BooleanFilter::class, properties: ['isActive'])]
#[ApiFilter(OrderFilter::class, properties: ['name'], arguments: ['orderParameterName' => 'order'])]
#[ORM\Entity]
// Mapping reproduisant a l'identique le DDL de la migration ERP-39
// (Version20260612150000) pour que `schema:update --force` reste un no-op :
// contrainte d'unicite siret + index is_active.
#[ORM\Table(name: 'qualimat_carrier')]
#[ORM\UniqueConstraint(name: 'uq_qualimat_carrier_siret', columns: ['siret'])]
#[ORM\Index(name: 'idx_qualimat_carrier_active', columns: ['is_active'])]
class QualimatCarrier
{
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
#[ORM\Column(type: 'bigint')]
#[Groups(['qualimat:read'])]
private ?string $id = null;
#[ORM\Column(length: 20)]
#[Groups(['qualimat:read'])]
private ?string $siret = null;
#[ORM\Column(length: 255)]
#[Groups(['qualimat:read'])]
private ?string $name = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['qualimat:read'])]
private ?string $address = null;
#[ORM\Column(name: 'postal_code', length: 10, nullable: true)]
#[Groups(['qualimat:read'])]
private ?string $postalCode = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['qualimat:read'])]
private ?string $city = null;
#[ORM\Column(length: 32, nullable: true)]
#[Groups(['qualimat:read'])]
private ?string $phone = null;
#[ORM\Column(length: 64, nullable: true)]
#[Groups(['qualimat:read'])]
private ?string $department = null;
#[ORM\Column(length: 32)]
#[Groups(['qualimat:read'])]
private ?string $status = null;
#[ORM\Column(name: 'validity_date', type: 'date_immutable', nullable: true)]
#[Groups(['qualimat:read'])]
private ?DateTimeImmutable $validityDate = null;
#[ORM\Column(name: 'is_active', options: ['default' => true])]
#[Groups(['qualimat:read'])]
#[SerializedName('isActive')]
private bool $isActive = true;
// Colonne technique de synchro (soft-delete) — mappee pour completude, non
// serialisee. Alimentee par app:qualimat:sync. columnDefinition pin la
// precision TIMESTAMP(6) du DDL ERP-39 pour eviter un ALTER de schema:update
// (le datetime_immutable par defaut mapperait sur TIMESTAMP(0)).
#[ORM\Column(name: 'last_synced_at', type: 'datetime_immutable', columnDefinition: 'TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL')]
private ?DateTimeImmutable $lastSyncedAt = null;
public function getId(): ?string
{
return $this->id;
}
public function getSiret(): ?string
{
return $this->siret;
}
public function getName(): ?string
{
return $this->name;
}
public function getAddress(): ?string
{
return $this->address;
}
public function getPostalCode(): ?string
{
return $this->postalCode;
}
public function getCity(): ?string
{
return $this->city;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function getDepartment(): ?string
{
return $this->department;
}
public function getStatus(): ?string
{
return $this->status;
}
public function getValidityDate(): ?DateTimeImmutable
{
return $this->validityDate;
}
public function isActive(): bool
{
return $this->isActive;
}
public function getLastSyncedAt(): ?DateTimeImmutable
{
return $this->lastSyncedAt;
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Domain\Repository;
use App\Module\Transport\Domain\Entity\Carrier;
use Doctrine\ORM\QueryBuilder;
/**
* Contrat du repository transporteurs (M4). Implementation Doctrine :
* App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository.
*/
interface CarrierRepositoryInterface
{
public function findById(int $id): ?Carrier;
public function save(Carrier $carrier): void;
/**
* QueryBuilder de SELECTION (filtres + tri) pour la liste. Exclut les
* soft-deletes (deleted_at IS NOT NULL) et, par defaut, les archives.
* Fetch-join uniquement qualimatCarrier (ManyToOne, sur — § 2.11) : la liste
* n'embarque aucune sous-collection. Tri par defaut name ASC.
*
* @param list<string> $certificationTypes filtre repetable (OR) sur certificationType
*/
public function createListQueryBuilder(
bool $includeArchived = false,
?string $search = null,
array $certificationTypes = [],
): QueryBuilder;
}
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Repository\CarrierRepositoryInterface;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider du repertoire transporteurs (M4, spec-back § 4.1 / § 4.2). Jumeau du
* SupplierProvider (M2), simplifie : pas de cloisonnement par site (§ 2.3) et
* aucune sous-collection a hydrater en liste (le contrat liste n'embarque que
* qualimatCarrier, deja fetch-joine par le repository — § 2.11).
*
* Collection (GET /api/carriers) :
* - exclut par defaut les archives (is_archived = true) ET les soft-deletes ;
* - ?includeArchived=true reintegre les archives (soft-deletes toujours exclus) ;
* - filtres ?search= (fuzzy name) et ?certificationType= (repetable) ;
* - tri par defaut name ASC ; pagination Hydra (regle n°13) + echappatoire
* ?pagination=false.
*
* Item (GET /api/carriers/{id}) : 404 si introuvable OU soft-delete. Les archives
* restent consultables en detail.
*
* @implements ProviderInterface<Carrier>
*/
final class CarrierProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository')]
private readonly CarrierRepositoryInterface $repository,
private readonly Pagination $pagination,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Carrier|iterable|Paginator|null
{
if ($operation instanceof CollectionOperationInterface) {
return $this->provideCollection($operation, $context);
}
return $this->provideItem($uriVariables);
}
/**
* @param array<string, mixed> $context
*
* @return list<Carrier>|Paginator<Carrier>
*/
private function provideCollection(Operation $operation, array $context): array|Paginator
{
$filters = $context['filters'] ?? [];
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
$search = $filters['search'] ?? null;
$certificationTypes = $this->readStringList($filters['certificationType'] ?? []);
$qb = $this->repository->createListQueryBuilder(
$includeArchived,
is_string($search) ? $search : null,
$certificationTypes,
);
// Echappatoire ?pagination=false : collection complete (selects front).
if (!$this->pagination->isEnabled($operation, $context)) {
/** @var list<Carrier> $carriers */
return $qb->getQuery()->getResult();
}
$limit = $this->pagination->getLimit($operation, $context);
$page = max(1, $this->pagination->getPage($context));
$offset = ($page - 1) * $limit;
$qb->setFirstResult($offset)->setMaxResults($limit);
// fetchJoinCollection: false — la seule jointure est un ManyToOne (sur),
// pas une to-many : pas de besoin du mode collection du Paginator.
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
}
/**
* @param array<string, mixed> $uriVariables
*/
private function provideItem(array $uriVariables): ?Carrier
{
$id = $uriVariables['id'] ?? null;
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
return null;
}
$carrier = $this->repository->findById((int) $id);
if (null === $carrier) {
return null;
}
// Soft-delete : jamais expose (404). Les archives restent consultables.
if (null !== $carrier->getDeletedAt()) {
return null;
}
return $carrier;
}
private function readBool(mixed $raw): bool
{
if (is_bool($raw)) {
return $raw;
}
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
}
/**
* Normalise un filtre en liste de chaines (valeur unique ou ?key[]=a&key[]=b).
*
* @return list<string>
*/
private function readStringList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if (is_string($value) && '' !== trim($value)) {
$out[] = trim($value);
}
}
return $out;
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\DataFixtures;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Entity\CarrierContact;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
/**
* Fixtures dev/test MINIMALES du repertoire transporteurs (M4, ERP-155/157) :
* 2 transporteurs de demonstration suffisant a faire tourner les ecrans de
* lecture (liste + detail). Les fixtures completes (cas QUALIMAT, affrete,
* LIOT, prix CLIENT/FOURNISSEUR...) sont livrees par le worktree dedie (WT10) —
* ne pas les developper ici (scope WT3 : contrat de lecture).
*
* Aucune dependance cross-module (pas de prix, pas de lien QUALIMAT) : la
* fixture reste autonome et joue en fin de chaine sans contrainte d'ordre.
*/
final class CarrierFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
// Transporteur certifie « classique ».
$alpha = new Carrier();
$alpha->setName('TRANSPORTS ALPHA');
$alpha->setCertificationType('GMP_PLUS');
$manager->persist($alpha);
$contact = new CarrierContact();
$contact->setCarrier($alpha);
$contact->setLastName('Durand');
$contact->setPhonePrimary('0612345678');
$alpha->addContact($contact);
$manager->persist($contact);
// Transporteur affrete (RG-4.03).
$beta = new Carrier();
$beta->setName('TRANSPORTS BETA');
$beta->setCertificationType('AUTRE');
$beta->setIsChartered(true);
$beta->setIndexationRate('5.00');
$beta->setContainerType('BENNE');
$beta->setVolumeM3('90.00');
$manager->persist($beta);
$manager->flush();
}
}
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\Doctrine;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Module\Transport\Domain\Repository\CarrierRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Carrier>
*/
class DoctrineCarrierRepository extends ServiceEntityRepository implements CarrierRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Carrier::class);
}
public function findById(int $id): ?Carrier
{
return $this->find($id);
}
public function save(Carrier $carrier): void
{
$this->getEntityManager()->persist($carrier);
$this->getEntityManager()->flush();
}
public function createListQueryBuilder(
bool $includeArchived = false,
?string $search = null,
array $certificationTypes = [],
): QueryBuilder {
// Fetch-join de la SEULE relation ManyToOne qualimatCarrier (sur, pas de
// cartesien) pour exposer statut/date de validite QUALIMAT en liste sans
// N+1 (§ 2.11). Aucune sous-collection (addresses/contacts/prices) jointe
// en liste : elles ne sont embarquees qu'au detail (carrier:item:read).
$qb = $this->createQueryBuilder('c')
->leftJoin('c.qualimatCarrier', 'q')->addSelect('q')
->andWhere('c.deletedAt IS NULL')
->orderBy('c.name', 'ASC')
;
// Pas de cloisonnement par site (§ 2.3) : referentiel global.
if (!$includeArchived) {
$qb->andWhere('c.isArchived = false');
}
$this->applySearch($qb, $search);
$this->applyCertificationTypes($qb, $certificationTypes);
return $qb;
}
/**
* Recherche fuzzy insensible a la casse sur le nom du transporteur (§ 4.1).
* Metacaracteres LIKE (%, _, \) echappes pour rester litteraux.
*/
private function applySearch(QueryBuilder $qb, ?string $search): void
{
if (null === $search || '' === trim($search)) {
return;
}
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
$qb->andWhere('LOWER(c.name) LIKE :search')
->setParameter('search', $pattern)
;
}
/**
* Restreint aux transporteurs dont la certification figure dans la liste (OR).
* Alimente le filtre « Certification » de la liste (§ 4.1).
*
* @param list<string> $certificationTypes
*/
private function applyCertificationTypes(QueryBuilder $qb, array $certificationTypes): void
{
$codes = [];
foreach ($certificationTypes as $code) {
if (is_string($code) && '' !== trim($code)) {
$codes[] = trim($code);
}
}
if ([] === $codes) {
return;
}
$qb->andWhere('c.certificationType IN (:certificationTypes)')
->setParameter('certificationTypes', $codes)
;
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat minimal d'une adresse de Client (M1 Commercial) exposable a un autre
* module sans couplage direct (regle ABSOLUE n°1). Mappe vers
* App\Module\Commercial\Domain\Entity\ClientAddress via `resolve_target_entities`.
*
* Implemente par App\Module\Commercial\Domain\Entity\ClientAddress. Utilise
* comme type-hint des relations ORM cross-module (ex: CarrierPrice.clientDeliveryAddress,
* M4). La serialisation passe par le read-group de l'entite concrete
* (client_address:read), pas par cette interface.
*/
interface ClientAddressInterface
{
public function getId(): ?int;
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat minimal exposant ce qu'un autre module doit connaitre d'un Client
* (M1 Commercial) sans creer de couplage direct vers le module Commercial
* (regle ABSOLUE n°1). Mappe vers App\Module\Commercial\Domain\Entity\Client
* via `resolve_target_entities` (doctrine.yaml).
*
* Implemente par App\Module\Commercial\Domain\Entity\Client. Utilise comme
* type-hint dans les relations ORM cross-module (ex: CarrierPrice.client, M4).
* La serialisation passe par les read-groups de l'entite concrete (client:read),
* pas par cette interface.
*/
interface ClientInterface
{
public function getId(): ?int;
public function getCompanyName(): ?string;
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat minimal d'une adresse de Supplier (M2 Commercial) exposable a un autre
* module sans couplage direct (regle ABSOLUE n°1). Mappe vers
* App\Module\Commercial\Domain\Entity\SupplierAddress via `resolve_target_entities`.
*
* Implemente par App\Module\Commercial\Domain\Entity\SupplierAddress. Utilise
* comme type-hint des relations ORM cross-module (ex: CarrierPrice.supplierSupplyAddress,
* M4). La serialisation passe par le read-group de l'entite concrete
* (supplier_address:read), pas par cette interface.
*/
interface SupplierAddressInterface
{
public function getId(): ?int;
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat minimal exposant ce qu'un autre module doit connaitre d'un Supplier
* (M2 Commercial) sans creer de couplage direct vers le module Commercial
* (regle ABSOLUE n°1). Mappe vers App\Module\Commercial\Domain\Entity\Supplier
* via `resolve_target_entities` (doctrine.yaml).
*
* Implemente par App\Module\Commercial\Domain\Entity\Supplier. Utilise comme
* type-hint dans les relations ORM cross-module (ex: CarrierPrice.supplier, M4).
* La serialisation passe par les read-groups de l'entite concrete (supplier:read),
* pas par cette interface.
*/
interface SupplierInterface
{
public function getId(): ?int;
public function getCompanyName(): ?string;
}
@@ -458,6 +458,86 @@ final class ColumnCommentsCatalog
'iban' => 'IBAN du compte (≤ 34 caracteres).',
'position' => 'Ordre d affichage du RIB dans la liste du prestataire (croissant).',
] + self::timestampableBlamableComments(),
// === M4 Transport — referentiel QUALIMAT (ERP-39, mappe lecture seule des ERP-155) ===
// Mappe par l'entite QualimatCarrier depuis M4 -> retire du schema_filter,
// donc ses COMMENT sont rejoues par app:apply-column-comments apres schema:update.
'qualimat_carrier' => [
'_table' => "Referentiel des transporteurs agrees QUALIMAT, synchronise quotidiennement depuis l'API qualimat.org (type=operateur_transport).",
'id' => 'Cle technique auto-incrementee.',
'siret' => 'SIRET normalise (chiffres sans espaces). Cle naturelle de synchro (unique). Source parfois incomplete (longueur variable), non contrainte a 14.',
'name' => 'Raison sociale du transporteur (champs Nom = Societe de la source, identiques).',
'address' => 'Adresse postale (voie). Nullable.',
'postal_code' => 'Code postal. Nullable.',
'city' => 'Ville. Nullable.',
'phone' => 'Telephone au format source "indicatif|numero" (ex: +33|0608890316). Nullable.',
'department' => 'Departement au format source "code - libelle" (ex: 65 - Hautes-Pyrenees). Nullable.',
'status' => "Statut d'agrement QUALIMAT (valeurs connues : Audite, Valide, Suspendu). Valeur brute de la source, non contrainte.",
'validity_date' => 'Date de fin de validite de la certification (convertie depuis dd/mm/yyyy). Nullable.',
'is_active' => 'Faux = transporteur absent du dernier import (soft-delete). Toute ligne non revue par le dernier run passe a FALSE.',
'last_synced_at' => 'Horodatage du run de synchro ayant vu cette ligne en dernier (soft-delete : last_synced_at < run courant).',
],
// === M4 Transport — repertoire transporteurs (ERP-155/157) ===
'carrier' => [
'_table' => 'Repertoire transporteurs (M4 Transport) — entites editables, archivables (is_archived) et soft-deletables (deleted_at). Distinct du referentiel qualimat_carrier.',
'id' => 'Identifiant interne auto-incremente.',
'qualimat_carrier_id' => 'Lien editable vers le referentiel QUALIMAT (saisie assistee RG-4.01). FK -> qualimat_carrier.id, ON DELETE SET NULL : transporteur conserve si la ligne QUALIMAT disparait.',
'name' => 'Raison sociale du transporteur (stockee en MAJUSCULES). Unique case-insensitive parmi les non-archives/non-supprimes (uq_carrier_name_active, RG-4.12 / § 2.6).',
'certification_type' => 'Type de certification : QUALIMAT (si lie, lecture seule) ou GMP_PLUS/OVOCOM/COMPTE_PROPRE/AUTRE. AUTRE declenche le champ Decharge (RG-4.02). Null en cas LIOT (RG-4.01).',
'is_chartered' => '« Affreter » coche : declenche indexation/benne-fond mouvant/volume, obligatoires (RG-4.03). Faux par defaut.',
'indexation_rate' => 'Taux d indexation en pourcentage (NUMERIC 5,2) — renseigne si affrete (RG-4.03).',
'container_type' => 'Type de contenant BENNE|FOND_MOUVANT (chk_carrier_container_type) — renseigne si affrete (RG-4.03).',
'volume_m3' => 'Volume en m3 (NUMERIC 10,2) — renseigne si affrete (RG-4.03).',
'discharge_document_id' => 'Document de Decharge (visible si certification_type = AUTRE, RG-4.02). FK -> uploaded_document.id (infra Shared § 2.7), ON DELETE SET NULL.',
'liot_plates' => 'Immatriculations LIOT separees par « ; » (cas special nom=LIOT, RG-4.01). Les autres champs sont masques dans ce cas.',
'is_archived' => 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission transport.carriers.archive (Admin seul).',
'archived_at' => 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.',
'deleted_at' => 'Horodatage du soft-delete technique — non expose par l API au M4. Null = ligne active.',
] + self::timestampableBlamableComments(),
'carrier_address' => [
'_table' => 'Adresses d un transporteur (1:n) — onglet Adresse (M4). Pre-remplie depuis QUALIMAT si applicable (RG-4.05).',
'id' => 'Identifiant interne auto-incremente.',
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire de l adresse.',
'country' => 'Pays de l adresse — defaut France.',
'postal_code' => 'Code postal (saisie assistee BAN cote front, RG-4.06).',
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front.',
'street' => 'Numero et voie de l adresse.',
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
'position' => 'Ordre d affichage de l adresse dans la liste du transporteur (croissant).',
] + self::timestampableBlamableComments(),
'carrier_contact' => [
'_table' => 'Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins un champ rempli (RG-4.08, chk_carrier_contact_filled), max 2 telephones.',
'id' => 'Identifiant interne auto-incremente.',
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du contact.',
'first_name' => 'Prenom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).',
'last_name' => 'Nom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).',
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
'phone_primary' => 'Telephone principal — chiffres uniquement (normalisation serveur).',
'phone_secondary' => 'Telephone secondaire — chiffres uniquement (max 2 telephones, RG-4.08).',
'email' => 'Email du contact (lowercase serveur).',
'position' => 'Ordre d affichage du contact dans la liste du transporteur (croissant).',
] + self::timestampableBlamableComments(),
'carrier_price' => [
'_table' => 'Prix d un transporteur (1:n) — onglet Prix (M4). Branche CLIENT ou FOURNISSEUR selon direction (RG-4.09->4.11, CHECK chk_carrier_price_*).',
'id' => 'Identifiant interne auto-incremente.',
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du prix.',
'direction' => 'Sens du prix : CLIENT ou FOURNISSEUR (RG-4.09). Pilote l affichage et l obligation des colonnes client_*/supplier_* (RG-4.10/4.11).',
'client_id' => 'Branche CLIENT (RG-4.10) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi direction = CLIENT.',
'client_delivery_address_id' => 'Branche CLIENT : adresse de livraison du client. FK -> client_address.id, ON DELETE RESTRICT.',
'departure_site_id' => 'Branche CLIENT : adresse de depart = un des 3 sites (86/17/82). FK -> site.id, ON DELETE RESTRICT.',
'supplier_id' => 'Branche FOURNISSEUR (RG-4.11) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi direction = FOURNISSEUR.',
'supplier_supply_address_id' => 'Branche FOURNISSEUR : adresse d approvisionnement du fournisseur. FK -> supplier_address.id, ON DELETE RESTRICT.',
'delivery_site_id' => 'Branche FOURNISSEUR : adresse de livraison = un des 3 sites (86/17/82). FK -> site.id, ON DELETE RESTRICT.',
'container_type' => 'Type de contenant BENNE|FOND_MOUVANT (chk_carrier_price_container).',
'pricing_unit' => 'Unite de tarification FORFAIT|TONNE (chk_carrier_price_unit).',
'price' => 'Montant du prix (NUMERIC 12,2).',
'price_state' => 'Etat du prix : EN_COURS, VALIDE ou NON_VALIDE (chk_carrier_price_state). Affiche dans le tableau Prix.',
'position' => 'Ordre d affichage du prix dans la liste du transporteur (croissant).',
] + self::timestampableBlamableComments(),
];
}