[ERP-55] ClientProvider + ClientProcessor + RG métier (M1) — stackée sur ERP-54 (#31)
Auto Tag Develop / tag (push) Successful in 9s
Auto Tag Develop / tag (push) Successful in 9s
**MR stackée sur ERP-54** — cible = `feature/ERP-54-creer-entites-client-m1` (PAS `develop`). Tristan validera le stack en fin de chaîne. Branche l'API REST du répertoire clients (M1) sur l'entité `Client` d'ERP-54. ## Périmètre - **ClientProvider** : liste paginée (Paginator ORM aligné ERP-72, `?pagination=false`), exclusion archives+soft-delete par défaut (RG-1.24), `?includeArchived=true` (RG-1.25), tri `companyName ASC` (RG-1.26), filtres `?search` (fuzzy) + `?categoryType`, détail 404 si soft-deleted + embarque contacts/adresses/ribs. - **ClientProcessor** : normalisation (RG-1.18→1.21), 409 doublon nom (RG-1.16) + 409 restauration (RG-1.23), gating par onglet `accounting.manage`/`archive` + mode strict 403 (RG-1.28), archivage exclusif + `archivedAt` (RG-1.22), RG-1.01 / RG-1.03 (mutex + type catégorie) / RG-1.12 / RG-1.13 / RG-1.04. - **ClientReadGroupContextBuilder** : ajout conditionnel du groupe `client:read:accounting` selon `commercial.clients.accounting.view`. - **CategoryReferenceDenormalizer** : résout les IRI catégorie vers `Category` (dénormalisation impossible sur l'interface sinon). - **Contrats Shared** : `CategoryInterface::getCategoryTypeCode()`, `BusinessRoleAwareInterface` + `BusinessRoles::COMMERCIALE`. ## Coordination stack - Permissions `commercial.clients.*` **référencées** ici, déclarées en **ERP-59** (tests RBAC en **ERP-60**). - Rôle métier `commerciale` seedé par **ERP-74** (RG-1.04 dormante d'ici là). - Config globale pagination (itemsPerPage client / max 50) portée par **ERP-72**. - Référentiels comptables (PaymentType/Bank/...) exposés en **ERP-56** → RG-1.12/1.13 testées en unitaire ici (pas d'IRI référentiel disponible avant ERP-56). ## Tests 31 tests Commercial (intégration admin sur les RG métier + unitaires sur le gating / RG-1.04 / RG-1.12 / RG-1.13 / context builder). Suite complète verte (343 tests). Règle n°1 respectée (aucun import inter-modules dans Commercial). --------- Co-authored-by: tristan <tristan@yuno.malio.fr> Co-authored-by: Matthieu <contact@malio.fr> Co-authored-by: Matthieu <mtholot19@gmail.com> Reviewed-on: #31 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 #31.
This commit is contained in:
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientProcessor;
|
||||
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Provider\ClientProvider;
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
@@ -15,6 +22,7 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -36,9 +44,62 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
* - categories : M2M vers Category (module Catalog) via le contrat
|
||||
* CategoryInterface + resolve_target_entities (regle n°1, pas d'import direct).
|
||||
*
|
||||
* Aucun ApiResource au M1.1 (ERP-54) : les operations API (Provider + Processor,
|
||||
* normalisation, archivage, accounting conditionnel) sont branchees en ERP-55.
|
||||
* Operations API (Provider + Processor) branchees en ERP-55 :
|
||||
* - GetCollection / Get : security commercial.clients.view. La liste expose le
|
||||
* groupe client:read ; le detail embarque en plus contacts/adresses/ribs
|
||||
* (groupe client:item:read). Les champs comptables (client:read:accounting)
|
||||
* sont ajoutes DYNAMIQUEMENT par ClientReadGroupContextBuilder si l'user a
|
||||
* la permission accounting.view (§ 2.7 / § 4.1 / § 4.2).
|
||||
* - Post / Patch : security commercial.clients.manage ; le ClientProcessor
|
||||
* applique normalisation, gating accounting/archive et regles metier.
|
||||
* - Pas de Delete au M1 (HP-M2-1) : l'archivage passe par PATCH isArchived.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
normalizationContext: ['groups' => ['client:read', 'default:read']],
|
||||
provider: ClientProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
// Detail : client + sous-collections embarquees. Le groupe
|
||||
// client:read:accounting est ajoute par le context builder selon la
|
||||
// permission, donc absent ici volontairement.
|
||||
normalizationContext: ['groups' => [
|
||||
'client:read',
|
||||
'client:item:read',
|
||||
'client_contact:read',
|
||||
'client_address:read',
|
||||
'client_rib:read',
|
||||
'default:read',
|
||||
]],
|
||||
provider: ClientProvider::class,
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('commercial.clients.manage')",
|
||||
normalizationContext: ['groups' => ['client:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['client:write:main']],
|
||||
processor: ClientProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('commercial.clients.manage')",
|
||||
// Le ClientProcessor inspecte les champs reellement envoyes pour
|
||||
// autoriser/refuser onglet par onglet (RG-1.22 / RG-1.28) : les
|
||||
// champs accounting exigent accounting.manage, isArchived exige
|
||||
// archive.
|
||||
normalizationContext: ['groups' => ['client:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => [
|
||||
'client:write:main',
|
||||
'client:write:information',
|
||||
'client:write:accounting',
|
||||
'client:write:archive',
|
||||
]],
|
||||
provider: ClientProvider::class,
|
||||
processor: ClientProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineClientRepository::class)]
|
||||
#[ORM\Table(name: 'client')]
|
||||
// Index nommes pour matcher la migration (Version20260601000000). L'index
|
||||
@@ -202,8 +263,13 @@ class Client implements TimestampableInterface, BlamableInterface
|
||||
private Collection $ribs;
|
||||
|
||||
// === Archive / Soft delete ===
|
||||
// Groupe d'ECRITURE uniquement sur la propriete (denormalisation PATCH
|
||||
// archive). Le groupe de LECTURE est declare sur le getter isArchived()
|
||||
// avec SerializedName('isArchived') : sans cela, Symfony strip le prefixe
|
||||
// "is" et exposerait la cle JSON "archived" (meme pattern que User::isAdmin
|
||||
// et Role::isSystem).
|
||||
#[ORM\Column(name: 'is_archived', options: ['default' => false])]
|
||||
#[Groups(['client:read', 'client:write:archive'])]
|
||||
#[Groups(['client:write:archive'])]
|
||||
private bool $isArchived = false;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
@@ -526,6 +592,7 @@ class Client implements TimestampableInterface, BlamableInterface
|
||||
}
|
||||
|
||||
/** @return Collection<int, ClientContact> */
|
||||
#[Groups(['client:item:read'])]
|
||||
public function getContacts(): Collection
|
||||
{
|
||||
return $this->contacts;
|
||||
@@ -551,6 +618,7 @@ class Client implements TimestampableInterface, BlamableInterface
|
||||
}
|
||||
|
||||
/** @return Collection<int, ClientAddress> */
|
||||
#[Groups(['client:item:read'])]
|
||||
public function getAddresses(): Collection
|
||||
{
|
||||
return $this->addresses;
|
||||
@@ -576,6 +644,7 @@ class Client implements TimestampableInterface, BlamableInterface
|
||||
}
|
||||
|
||||
/** @return Collection<int, ClientRib> */
|
||||
#[Groups(['client:item:read'])]
|
||||
public function getRibs(): Collection
|
||||
{
|
||||
return $this->ribs;
|
||||
@@ -600,6 +669,10 @@ class Client implements TimestampableInterface, BlamableInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
// Groupe de lecture + nom serialise explicite : sans SerializedName, Symfony
|
||||
// exposerait la cle "archived" (strip du prefixe "is" sur les getters).
|
||||
#[Groups(['client:read'])]
|
||||
#[SerializedName('isArchived')]
|
||||
public function isArchived(): bool
|
||||
{
|
||||
return $this->isArchived;
|
||||
|
||||
Reference in New Issue
Block a user