7f3bc708a4
Expose les sous-collections du prestataire en #[ApiResource] (POST sur le
parent + PATCH/DELETE/GET unitaires), edition complete par onglet (pas de
POST-only, RETEX M1/M2) :
- ProviderContact : POST /providers/{id}/contacts, PATCH/DELETE
/provider_contacts/{id} (security technique.providers.manage).
ProviderContactProcessor : normalisation RG-3.11 (nom/prenom Title Case,
telephones chiffres, email lowercase) + RG-3.04 (au moins un champ parmi
prenom/nom/telephone/email, miroir du CHECK chk_provider_contact_name -> 422).
- ProviderAddress : POST /providers/{id}/addresses, PATCH/DELETE
/provider_addresses/{id} (security technique.providers.manage).
ProviderAddressProcessor : rattachement parent + cloisonnement d'ecriture des
sites de l'adresse (RG-3.05 / § 2.13 : site hors user_site -> 422 sur sites).
- ProviderRib : POST /providers/{id}/ribs, PATCH/DELETE /provider_ribs/{id}
(security technique.providers.accounting.manage). ProviderRibProcessor :
RG-3.08 (DELETE du dernier RIB sous LCR -> 409).
Tests : ProviderSubResourceApiTest (19 cas) — CRUD chaque sous-ressource, 403
selon permission (Contacts/Adresses=manage, RIB=accounting.manage), 409 dernier
RIB LCR, 422 cloisonnement site adresse. Helpers addContact/addRib/paymentType
ajoutes a AbstractProviderApiTestCase.
366 lines
13 KiB
PHP
366 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Module\Technique\Domain\Entity;
|
|
|
|
use ApiPlatform\Metadata\ApiResource;
|
|
use ApiPlatform\Metadata\Delete;
|
|
use ApiPlatform\Metadata\Get;
|
|
use ApiPlatform\Metadata\Link;
|
|
use ApiPlatform\Metadata\Patch;
|
|
use ApiPlatform\Metadata\Post;
|
|
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderAddressProcessor;
|
|
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\Validator\Constraints as Assert;
|
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|
|
|
/**
|
|
* Adresse d'un prestataire (1:n) — onglet Adresse. Version SIMPLIFIEE de
|
|
* SupplierAddress : PAS de address_type (PROSPECT/DEPART/RENDU), PAS de bennes,
|
|
* PAS de triage_provider (champs specifiques fournisseur). Champs : country /
|
|
* postal_code / city / street / street_complement + M2M sites / contacts /
|
|
* categories.
|
|
*
|
|
* Relations M2M :
|
|
* - sites : SiteInterface (module Sites) via resolve_target_entities — au moins
|
|
* un site obligatoire (RG-3.05, Assert\Count). Site n'a pas de `code`.
|
|
* - contacts : ProviderContact (meme module).
|
|
* - categories : CategoryInterface (module Catalog) via resolve_target_entities —
|
|
* type PRESTATAIRE attendu (RG-3.09, Assert\Callback validateCategoryType).
|
|
*
|
|
* Embarquee sous `provider.addresses` au detail (groupe provider:item:read,
|
|
* maillon (a)).
|
|
*
|
|
* Sous-ressource API (ERP-135, spec § 4.5) :
|
|
* - POST /api/providers/{providerId}/addresses : creation rattachee au prestataire
|
|
* parent (Link toProperty 'provider'), security technique.providers.manage.
|
|
* - PATCH / DELETE /api/provider_addresses/{id} : security technique.providers.manage.
|
|
* - GET /api/provider_addresses/{id} : lecture unitaire (security view) — la lecture
|
|
* courante reste via le parent. Pas de GET collection autonome.
|
|
* Tout passe par le ProviderAddressProcessor (rattachement parent + cloisonnement
|
|
* d'ecriture des sites, § 2.13). Les regles RG-3.05/3.06/3.09 sont portees par les
|
|
* contraintes de l'entite (jouees avant le processor).
|
|
*
|
|
* Audite (#[Auditable]) + Timestampable / Blamable.
|
|
*/
|
|
#[ApiResource(
|
|
operations: [
|
|
new Get(
|
|
security: "is_granted('technique.providers.view')",
|
|
// site:read + category:read : embarquent les Site / Category lies
|
|
// (maillon (c)) plutot que des IRI nus dans le retour.
|
|
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
|
),
|
|
new Post(
|
|
uriTemplate: '/providers/{providerId}/addresses',
|
|
uriVariables: [
|
|
'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'),
|
|
],
|
|
// read:false : pas de stade lecture du parent. Le Link toProperty
|
|
// resoudrait l'enfant (SELECT ProviderAddress ... WHERE provider = :id)
|
|
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
|
|
// manuellement par ProviderAddressProcessor::linkParent (404 si absent).
|
|
read: false,
|
|
security: "is_granted('technique.providers.manage')",
|
|
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
|
denormalizationContext: ['groups' => ['provider:write:addresses']],
|
|
processor: ProviderAddressProcessor::class,
|
|
),
|
|
new Patch(
|
|
security: "is_granted('technique.providers.manage')",
|
|
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
|
denormalizationContext: ['groups' => ['provider:write:addresses']],
|
|
processor: ProviderAddressProcessor::class,
|
|
),
|
|
new Delete(
|
|
security: "is_granted('technique.providers.manage')",
|
|
processor: ProviderAddressProcessor::class,
|
|
),
|
|
],
|
|
)]
|
|
#[ORM\Entity]
|
|
#[ORM\Table(name: 'provider_address')]
|
|
#[ORM\Index(name: 'idx_provider_address_provider', columns: ['provider_id'])]
|
|
#[Auditable]
|
|
class ProviderAddress implements TimestampableInterface, BlamableInterface
|
|
{
|
|
use TimestampableBlamableTrait;
|
|
|
|
/**
|
|
* RG-3.09 : seules les categories PORTANT ce type sont autorisees sur une
|
|
* adresse prestataire. S'appuie sur CategoryInterface::getCategoryTypeCodes()
|
|
* (pas d'import du module Catalog — regle ABSOLUE n°1).
|
|
*/
|
|
private const string REQUIRED_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
|
|
|
|
#[ORM\Id]
|
|
#[ORM\GeneratedValue]
|
|
#[ORM\Column]
|
|
#[Groups(['provider:item:read'])]
|
|
private ?int $id = null;
|
|
|
|
#[ORM\ManyToOne(targetEntity: Provider::class, inversedBy: 'addresses')]
|
|
#[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
|
private ?Provider $provider = 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(['provider:item:read', 'provider:write:addresses'])]
|
|
private string $country = 'France';
|
|
|
|
// RG-3.06 : 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
|
|
// ERP-107).
|
|
#[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(['provider:item:read', 'provider: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(['provider:item:read', 'provider: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(['provider:item:read', 'provider: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(['provider:item:read', 'provider:write:addresses'])]
|
|
private ?string $streetComplement = null;
|
|
|
|
// Ordre d'affichage de l'adresse (gere serveur, non expose au M3).
|
|
#[ORM\Column(options: ['default' => 0])]
|
|
private int $position = 0;
|
|
|
|
// RG-3.05 : au moins un site rattache a chaque adresse.
|
|
/** @var Collection<int, SiteInterface> */
|
|
#[ORM\ManyToMany(targetEntity: SiteInterface::class)]
|
|
#[ORM\JoinTable(name: 'provider_address_site')]
|
|
#[ORM\JoinColumn(name: 'provider_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(['provider:item:read', 'provider:write:addresses'])]
|
|
private Collection $sites;
|
|
|
|
/** @var Collection<int, ProviderContact> */
|
|
#[ORM\ManyToMany(targetEntity: ProviderContact::class)]
|
|
#[ORM\JoinTable(name: 'provider_address_contact')]
|
|
#[ORM\JoinColumn(name: 'provider_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
|
#[ORM\InverseJoinColumn(name: 'provider_contact_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
|
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
|
private Collection $contacts;
|
|
|
|
// RG-3.09 : au moins une categorie de type PRESTATAIRE par adresse (le type est
|
|
// controle par validateCategoryType ; le minimum par Assert\Count, miroir sites).
|
|
/** @var Collection<int, CategoryInterface> */
|
|
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
|
#[ORM\JoinTable(name: 'provider_address_category')]
|
|
#[ORM\JoinColumn(name: 'provider_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
|
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
|
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
|
|
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
|
private Collection $categories;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->sites = new ArrayCollection();
|
|
$this->contacts = new ArrayCollection();
|
|
$this->categories = new ArrayCollection();
|
|
}
|
|
|
|
/**
|
|
* RG-3.09 : toute categorie posee sur une adresse prestataire doit etre de
|
|
* type PRESTATAIRE -> sinon 422 avec violation sur le champ `categories`
|
|
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur
|
|
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
|
|
* acceptee des qu'elle PORTE le type PRESTATAIRE ; pas d'import du module
|
|
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
|
|
*/
|
|
#[Assert\Callback]
|
|
public function validateCategoryType(ExecutionContextInterface $context): void
|
|
{
|
|
foreach ($this->categories as $category) {
|
|
if ($category instanceof CategoryInterface
|
|
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
|
|
$context->buildViolation('Type de catégorie non autorisé (PRESTATAIRE attendu).')
|
|
->atPath('categories')
|
|
->addViolation()
|
|
;
|
|
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
public function getId(): ?int
|
|
{
|
|
return $this->id;
|
|
}
|
|
|
|
public function getProvider(): ?Provider
|
|
{
|
|
return $this->provider;
|
|
}
|
|
|
|
public function setProvider(?Provider $provider): static
|
|
{
|
|
$this->provider = $provider;
|
|
|
|
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;
|
|
}
|
|
|
|
/** @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, ProviderContact> */
|
|
public function getContacts(): Collection
|
|
{
|
|
return $this->contacts;
|
|
}
|
|
|
|
public function addContact(ProviderContact $contact): static
|
|
{
|
|
if (!$this->contacts->contains($contact)) {
|
|
$this->contacts->add($contact);
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function removeContact(ProviderContact $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;
|
|
}
|
|
}
|