feat(commercial) : entités + repositories M2 fournisseurs (ERP-86) (#65)
Auto Tag Develop / tag (push) Successful in 7s
Auto Tag Develop / tag (push) Successful in 7s
## ERP-86 — Entités + Repositories M2 Fournisseurs (étape 2/7) PR **empilée sur ERP-85** (#64) : ne contient que le commit ERP-86. À merger après #64 (la base rebascule automatiquement au fil des merges de la chaîne #63 → #64 → develop). Dépend de #64 (migration BDD). Bloque #87 (Provider + Processor) et suivants. ### Contenu 4 entités jumelles du M1 `Client*`, mapping ORM aligné **exactement** sur la migration ERP-85 (noms, types, longueurs, FK, M2M, index), **sans contact inline** (ERP-106) : - **`Supplier`** — `#[Auditable]` + Timestampable/Blamable. Formulaire principal, onglet Information (+ `volumeForecast`, spécifique fournisseur), onglet Comptabilité (FK référentiels M1 partagés), archivage (`isArchived`/`archivedAt`), soft-delete préparé. Catégories M2M via `CategoryInterface` (règle n°1, pas d'import inter-module). Pas de `distributor`/`broker`. - **`SupplierContact`** — onglet Contacts (RG-2.04 : `firstName` OU `lastName`). - **`SupplierAddress`** — enum `addressType` (`PROSPECT`/`DEPART`/`RENDU` via `Assert\Choice`), `bennes`, `triageProvider` ; M2M sites/contacts/categories. - **`SupplierRib`** — RIB, embed gaté comptable. - **Repositories** : interfaces `Domain/Repository/` + impls `Infrastructure/Doctrine/`. ### Points clés - **Contrat de sérialisation (RETEX M1, 3 maillons posés sur l'entité)** : read-groups sur les propriétés ; getters `isArchived()` / `isTriageProvider()` avec `#[Groups]` + `#[SerializedName('isX')]` (parade piège booléen n°3) ; embed `contacts`/`addresses` (`supplier:item:read`) et `ribs` (`supplier:read:accounting`). `getSites()` agrège/dédoublonne les `Site` des adresses (`name`/`postalCode`, pas de `code`). - **Fetch-joins anti-N+1** dans le **repository de liste** : `hydrateListCollections()` en 2 passes (`categories`, puis `addresses.sites`) — évite le produit cartésien (pattern ERP-100). Filtres : recherche `companyName` + contacts liés (D1), `categoryCode`, `siteId`, archivage. - **Pas d'`#[ApiResource]`** : Provider/Processor (gating accounting, archivage, mode strict) sont au ticket **ERP-87**. L'ajouter ici référencerait des classes inexistantes → boot/tests cassés. Les groupes de lecture/écriture sont déjà en place ; le `normalizationContext` viendra avec #87. - **Validation FR (ERP-107)** : messages FR sur toutes les contraintes ; `Assert\Length(max)` calé sur les colonnes. Garde-fou `EntityConstraintsHaveFrenchMessageTest` étendu : `Assert\Choice` ajouté au mapping ; `addressType` et `postalCode` whitelistés du miroir Length (déjà bornés par Choice / Regex). - Clés i18n `audit.entity.commercial_supplier*` ajoutées (garde-fou `AuditableEntitiesHaveI18nLabelTest`). ### Vérifications - `make test` : **483/483 OK** (1965 assertions). - `make php-cs-fixer-allow-risky` : 0 correction. - `doctrine:schema:validate` : mapping correct (bruit d'index FK cosmétique identique au M1 `client`). --------- Co-authored-by: admin malio <malio@yuno.malio.fr> Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #65 Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #65.
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineSupplierAddressRepository;
|
||||
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\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
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;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Adresse d'un fournisseur (1:n) — onglet Adresse. Le type d'adresse est un enum
|
||||
* exclusif PROSPECT | DEPART | RENDU (radio cote front — RG-2.09), qui remplace
|
||||
* les 3 booleens prospect/livraison/facturation du Client (M1) ; pas d'email de
|
||||
* facturation au M2. Ajoute deux champs specifiques fournisseur : `bennes`
|
||||
* (entier nullable) et `triageProvider` (prestataire de triage, booleen).
|
||||
*
|
||||
* Relations M2M :
|
||||
* - sites : SiteInterface (module Sites) via resolve_target_entities — au moins
|
||||
* un site obligatoire (RG-2.06, Assert\Count). Site n'a pas de `code`.
|
||||
* - contacts : SupplierContact (meme module).
|
||||
* - categories : CategoryInterface (module Catalog) via resolve_target_entities —
|
||||
* type FOURNISSEUR attendu (RG-2.10, controle au Processor/Validator ERP-89).
|
||||
*
|
||||
* Embarquee sous `supplier.addresses` au detail (groupe supplier:item:read,
|
||||
* maillon (a)). Edition via la sous-ressource (POST /api/suppliers/{id}/addresses,
|
||||
* PATCH/DELETE /api/supplier_addresses/{id}), branchee a ERP-88.
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable / Blamable.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DoctrineSupplierAddressRepository::class)]
|
||||
#[ORM\Table(name: 'supplier_address')]
|
||||
#[ORM\Index(name: 'idx_supplier_address_supplier', columns: ['supplier_id'])]
|
||||
#[Auditable]
|
||||
class SupplierAddress implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
/**
|
||||
* Valeurs autorisees de address_type (RG-2.09). Miroir applicatif du CHECK BDD
|
||||
* chk_supplier_address_type : alimente l'Assert\Choice (422 propre rattachee
|
||||
* au champ avant la base) et reste la source des options cote front.
|
||||
*/
|
||||
public const array ADDRESS_TYPES = ['PROSPECT', 'DEPART', 'RENDU'];
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['supplier:item:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Supplier::class, inversedBy: 'addresses')]
|
||||
#[ORM\JoinColumn(name: 'supplier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Supplier $supplier = null;
|
||||
|
||||
// RG-2.09 : enum exclusif. La valeur est bornee par Assert\Choice (longueur de
|
||||
// fait <= 8), d'ou la whitelist du miroir Assert\Length == ORM length (ERP-107,
|
||||
// EntityConstraintsHaveFrenchMessageTest::EXCLUDED_LENGTH_MIRROR).
|
||||
#[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'])]
|
||||
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'])]
|
||||
private string $country = 'France';
|
||||
|
||||
// RG-2.05 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur).
|
||||
// Le Regex borne deja la longueur (<= 5) : pas de Length redondant (whitelist).
|
||||
#[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'])]
|
||||
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'])]
|
||||
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'])]
|
||||
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'])]
|
||||
private ?string $streetComplement = null;
|
||||
|
||||
// Specifique fournisseur : nombre de bennes sur le site.
|
||||
#[ORM\Column(nullable: true)]
|
||||
#[Assert\PositiveOrZero(message: 'Le nombre de bennes doit être un nombre positif ou nul.')]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
||||
private ?int $bennes = null;
|
||||
|
||||
// Specifique fournisseur : prestataire de triage sur cette adresse. Groupe
|
||||
// d'ECRITURE uniquement sur la propriete ; le groupe de LECTURE est porte par
|
||||
// le getter isTriageProvider() avec SerializedName('triageProvider') — sinon
|
||||
// Symfony strip le prefixe "is" et droppe la cle (piege n°3 du M1).
|
||||
#[ORM\Column(name: 'triage_provider', options: ['default' => false])]
|
||||
#[Groups(['supplier:write:addresses'])]
|
||||
private bool $triageProvider = false;
|
||||
|
||||
// Ordre d'affichage de l'adresse (gere serveur, non expose au M2).
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
// RG-2.06 : au moins un site rattache a chaque adresse.
|
||||
/** @var Collection<int, SiteInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: SiteInterface::class)]
|
||||
#[ORM\JoinTable(name: 'supplier_address_site')]
|
||||
#[ORM\JoinColumn(name: 'supplier_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
||||
private Collection $sites;
|
||||
|
||||
/** @var Collection<int, SupplierContact> */
|
||||
#[ORM\ManyToMany(targetEntity: SupplierContact::class)]
|
||||
#[ORM\JoinTable(name: 'supplier_address_contact')]
|
||||
#[ORM\JoinColumn(name: 'supplier_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'supplier_contact_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
||||
private Collection $contacts;
|
||||
|
||||
// RG-2.10 : categories d'adresse de type FOURNISSEUR (controle au Processor).
|
||||
/** @var Collection<int, CategoryInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
||||
#[ORM\JoinTable(name: 'supplier_address_category')]
|
||||
#[ORM\JoinColumn(name: 'supplier_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
||||
private Collection $categories;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->sites = new ArrayCollection();
|
||||
$this->contacts = new ArrayCollection();
|
||||
$this->categories = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getSupplier(): ?Supplier
|
||||
{
|
||||
return $this->supplier;
|
||||
}
|
||||
|
||||
public function setSupplier(?Supplier $supplier): static
|
||||
{
|
||||
$this->supplier = $supplier;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAddressType(): ?string
|
||||
{
|
||||
return $this->addressType;
|
||||
}
|
||||
|
||||
public function setAddressType(?string $addressType): static
|
||||
{
|
||||
$this->addressType = $addressType;
|
||||
|
||||
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 getBennes(): ?int
|
||||
{
|
||||
return $this->bennes;
|
||||
}
|
||||
|
||||
public function setBennes(?int $bennes): static
|
||||
{
|
||||
$this->bennes = $bennes;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
// Groupe de lecture + nom serialise explicite (cf. note sur la propriete) :
|
||||
// sans SerializedName, Symfony exposerait la cle "triage" (strip du prefixe
|
||||
// "is") et, le groupe etant sur la propriete `triageProvider`, droppait
|
||||
// silencieusement la cle du JSON.
|
||||
#[Groups(['supplier:item:read'])]
|
||||
#[SerializedName('triageProvider')]
|
||||
public function isTriageProvider(): bool
|
||||
{
|
||||
return $this->triageProvider;
|
||||
}
|
||||
|
||||
public function setTriageProvider(bool $triageProvider): static
|
||||
{
|
||||
$this->triageProvider = $triageProvider;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, SiteInterface> */
|
||||
public function getSites(): Collection
|
||||
{
|
||||
return $this->sites;
|
||||
}
|
||||
|
||||
public function addSite(SiteInterface $site): static
|
||||
{
|
||||
if (!$this->sites->contains($site)) {
|
||||
$this->sites->add($site);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeSite(SiteInterface $site): static
|
||||
{
|
||||
$this->sites->removeElement($site);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, SupplierContact> */
|
||||
public function getContacts(): Collection
|
||||
{
|
||||
return $this->contacts;
|
||||
}
|
||||
|
||||
public function addContact(SupplierContact $contact): static
|
||||
{
|
||||
if (!$this->contacts->contains($contact)) {
|
||||
$this->contacts->add($contact);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeContact(SupplierContact $contact): static
|
||||
{
|
||||
$this->contacts->removeElement($contact);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, CategoryInterface> */
|
||||
public function getCategories(): Collection
|
||||
{
|
||||
return $this->categories;
|
||||
}
|
||||
|
||||
public function addCategory(CategoryInterface $category): static
|
||||
{
|
||||
if (!$this->categories->contains($category)) {
|
||||
$this->categories->add($category);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeCategory(CategoryInterface $category): static
|
||||
{
|
||||
$this->categories->removeElement($category);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user