Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c21bfea7f6 | |||
| f29587f113 |
@@ -23,6 +23,7 @@
|
||||
"symfony/expression-language": "8.0.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "8.0.*",
|
||||
"symfony/intl": "8.0.*",
|
||||
"symfony/mime": "8.0.*",
|
||||
"symfony/monolog-bundle": "^4.0",
|
||||
"symfony/property-access": "8.0.*",
|
||||
|
||||
Generated
+90
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "d65a546151abb6b977fbf7f1c86d14fe",
|
||||
"content-hash": "2410dcfdb94553f520e1186a73fa98c5",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/doctrine-common",
|
||||
@@ -5172,6 +5172,95 @@
|
||||
],
|
||||
"time": "2026-03-31T21:14:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/intl",
|
||||
"version": "v8.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/intl.git",
|
||||
"reference": "604a1dbbd67471e885e93274379cadd80dc33535"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/intl/zipball/604a1dbbd67471e885e93274379cadd80dc33535",
|
||||
"reference": "604a1dbbd67471e885e93274379cadd80dc33535",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/string": "<7.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/filesystem": "^7.4|^8.0",
|
||||
"symfony/var-exporter": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Intl\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/",
|
||||
"/Resources/data/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Bernhard Schussek",
|
||||
"email": "bschussek@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Eriksen Costa",
|
||||
"email": "eriksen.costa@infranology.com.br"
|
||||
},
|
||||
{
|
||||
"name": "Igor Wiedler",
|
||||
"email": "igor@wiedler.ch"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides access to the localization data of the ICU library",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"i18n",
|
||||
"icu",
|
||||
"internationalization",
|
||||
"intl",
|
||||
"l10n",
|
||||
"localization"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/intl/tree/v8.0.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/mime",
|
||||
"version": "v8.0.8",
|
||||
|
||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineBankRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
@@ -13,10 +16,29 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
* CIC, Credit Agricole) : referentiel statique seede par la migration M1 et
|
||||
* re-seede en dev/test par CommercialReferentialFixtures.
|
||||
*
|
||||
* Lecture seule au M1 (HP-M2-2). Pas de Timestampable/Blamable (referentiel
|
||||
* statique whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED). Le
|
||||
* groupe `client:read:accounting` permet l'embarquement dans la reponse Client.
|
||||
* Lecture seule au M1 (HP-M2-2) : GetCollection + Get uniquement (ERP-56),
|
||||
* permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de
|
||||
* Timestampable/Blamable (referentiel statique whiteliste dans
|
||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
|
||||
* `client:read:accounting` permet l'embarquement dans la reponse Client.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
normalizationContext: ['groups' => ['bank:read']],
|
||||
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC.
|
||||
order: ['position' => 'ASC', 'label' => 'ASC'],
|
||||
// ERP-72 : pagination serveur + toggle ?pagination=false (cf. TvaMode).
|
||||
paginationClientEnabled: true,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
normalizationContext: ['groups' => ['bank:read']],
|
||||
),
|
||||
],
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineBankRepository::class)]
|
||||
#[ORM\Table(name: 'bank')]
|
||||
#[ORM\UniqueConstraint(name: 'uq_bank_code', columns: ['code'])]
|
||||
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\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\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientAddressProcessor;
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientAddressRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
@@ -28,11 +35,46 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
* - sites : SiteInterface (module Sites) via resolve_target_entities
|
||||
* - contacts : ClientContact (meme module)
|
||||
* - categories : CategoryInterface (module Catalog) via resolve_target_entities
|
||||
* — limitees aux types SECTEUR/AUTRE cote validation (RG-1.29, futur Processor)
|
||||
* — limitees aux types SECTEUR/AUTRE cote validation (RG-1.29, hors ERP-57)
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable/Blamable. Aucun ApiResource au M1.1
|
||||
* (sous-ressources branchees a un ticket dedie).
|
||||
* Audite (#[Auditable]) + Timestampable/Blamable.
|
||||
*
|
||||
* Sous-ressource API (ERP-57, spec § 4.5) :
|
||||
* - POST /api/clients/{clientId}/addresses : creation rattachee au client parent
|
||||
* (Link toProperty 'client'), security commercial.clients.manage.
|
||||
* - PATCH / DELETE /api/client_addresses/{id} : security commercial.clients.manage.
|
||||
* - GET /api/client_addresses/{id} : lecture unitaire (security view) — la
|
||||
* lecture courante reste via le parent. Pas de GET collection autonome.
|
||||
* Tout passe par le ClientAddressProcessor (normalisation RG-1.21 billingEmail).
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
normalizationContext: ['groups' => ['client_address:read']],
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/clients/{clientId}/addresses',
|
||||
uriVariables: [
|
||||
'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
|
||||
],
|
||||
security: "is_granted('commercial.clients.manage')",
|
||||
normalizationContext: ['groups' => ['client_address:read']],
|
||||
denormalizationContext: ['groups' => ['client_address:write']],
|
||||
processor: ClientAddressProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('commercial.clients.manage')",
|
||||
normalizationContext: ['groups' => ['client_address:read']],
|
||||
denormalizationContext: ['groups' => ['client_address:write']],
|
||||
processor: ClientAddressProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('commercial.clients.manage')",
|
||||
processor: ClientAddressProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineClientAddressRepository::class)]
|
||||
#[ORM\Table(name: 'client_address')]
|
||||
#[ORM\Index(name: 'idx_client_address_client', columns: ['client_id'])]
|
||||
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\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\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientContactProcessor;
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientContactRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
@@ -16,13 +23,50 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
/**
|
||||
* Contact d'un client (1:n) — onglet Contact. Au moins firstName OU lastName
|
||||
* doit etre renseigne (RG-1.05) : la contrainte est portee par un CHECK BDD
|
||||
* (chk_client_contact_name) et validee dans le futur ClientContactProcessor ;
|
||||
* (chk_client_contact_name) et validee dans le ClientContactProcessor ;
|
||||
* l'entite reste permissive (les deux champs sont nullable).
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable/Blamable (pattern Shared standard).
|
||||
* Les operations CRUD (sous-ressources POST/PATCH/DELETE) sont branchees au
|
||||
* ticket dedie des sous-ressources — aucun ApiResource au M1.1 (ERP-54).
|
||||
*
|
||||
* Sous-ressource API (ERP-57, spec § 4.5) :
|
||||
* - POST /api/clients/{clientId}/contacts : creation rattachee au client parent
|
||||
* (Link toProperty 'client'), security commercial.clients.manage.
|
||||
* - PATCH / DELETE /api/client_contacts/{id} : security commercial.clients.manage.
|
||||
* Le DELETE est physique (sous-collection, pas le client) ; le processor
|
||||
* refuse la suppression du dernier contact (RG-1.14, 409).
|
||||
* - GET /api/client_contacts/{id} : lecture unitaire (security view) — la
|
||||
* lecture courante reste via le parent (client embarque ses contacts). Pas de
|
||||
* GET collection autonome : non concernee par la pagination ERP-72.
|
||||
* Tout passe par le ClientContactProcessor (normalisation RG-1.19/1.20/1.21).
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
normalizationContext: ['groups' => ['client_contact:read']],
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/clients/{clientId}/contacts',
|
||||
uriVariables: [
|
||||
'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
|
||||
],
|
||||
security: "is_granted('commercial.clients.manage')",
|
||||
normalizationContext: ['groups' => ['client_contact:read']],
|
||||
denormalizationContext: ['groups' => ['client_contact:write']],
|
||||
processor: ClientContactProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('commercial.clients.manage')",
|
||||
normalizationContext: ['groups' => ['client_contact:read']],
|
||||
denormalizationContext: ['groups' => ['client_contact:write']],
|
||||
processor: ClientContactProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('commercial.clients.manage')",
|
||||
processor: ClientContactProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineClientContactRepository::class)]
|
||||
#[ORM\Table(name: 'client_contact')]
|
||||
#[ORM\Index(name: 'idx_client_contact_client', columns: ['client_id'])]
|
||||
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\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\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientRibProcessor;
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRibRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
@@ -16,7 +23,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
/**
|
||||
* Coordonnees bancaires d'un client (1:n) — onglet Comptabilite. Au moins un
|
||||
* RIB est obligatoire si le type de reglement du client est LCR (RG-1.13,
|
||||
* verifie au futur Processor).
|
||||
* verifie au ClientRibProcessor : refus du DELETE du dernier RIB sous LCR).
|
||||
*
|
||||
* Audit (#[Auditable]) : TOUS les champs sont audites, y compris `iban` et
|
||||
* `bic` — AUCUN #[AuditIgnore] (decision Matthieu en revue MR 29/05/2026 :
|
||||
@@ -25,8 +32,45 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
*
|
||||
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony au M1
|
||||
* (HP-M2-14 : pas de controle externe banque reelle). Timestampable/Blamable
|
||||
* standard. Aucun ApiResource au M1.1 (sous-ressource branchee ulterieurement).
|
||||
* standard.
|
||||
*
|
||||
* Sous-ressource API (ERP-57, spec § 4.5) — gating comptable renforce :
|
||||
* - POST /api/clients/{clientId}/ribs : creation rattachee au client parent
|
||||
* (Link toProperty 'client'), security commercial.clients.accounting.manage.
|
||||
* - PATCH / DELETE /api/client_ribs/{id} : security commercial.clients.accounting.manage.
|
||||
* - GET /api/client_ribs/{id} : lecture unitaire, security
|
||||
* commercial.clients.accounting.view (donnees bancaires sensibles). Pas de
|
||||
* GET collection autonome.
|
||||
* Tout passe par le ClientRibProcessor (RG-1.13 sur DELETE).
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('commercial.clients.accounting.view')",
|
||||
normalizationContext: ['groups' => ['client_rib:read']],
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/clients/{clientId}/ribs',
|
||||
uriVariables: [
|
||||
'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
|
||||
],
|
||||
security: "is_granted('commercial.clients.accounting.manage')",
|
||||
normalizationContext: ['groups' => ['client_rib:read']],
|
||||
denormalizationContext: ['groups' => ['client_rib:write']],
|
||||
processor: ClientRibProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('commercial.clients.accounting.manage')",
|
||||
normalizationContext: ['groups' => ['client_rib:read']],
|
||||
denormalizationContext: ['groups' => ['client_rib:write']],
|
||||
processor: ClientRibProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('commercial.clients.accounting.manage')",
|
||||
processor: ClientRibProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineClientRibRepository::class)]
|
||||
#[ORM\Table(name: 'client_rib')]
|
||||
#[ORM\Index(name: 'idx_client_rib_client', columns: ['client_id'])]
|
||||
|
||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrinePaymentDelayRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
@@ -13,10 +16,29 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
* referentiel statique seede par la migration M1 et re-seede en dev/test par
|
||||
* CommercialReferentialFixtures.
|
||||
*
|
||||
* Lecture seule au M1 (HP-M2-2). Pas de Timestampable/Blamable (referentiel
|
||||
* statique whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED). Le
|
||||
* groupe `client:read:accounting` permet l'embarquement dans la reponse Client.
|
||||
* Lecture seule au M1 (HP-M2-2) : GetCollection + Get uniquement (ERP-56),
|
||||
* permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de
|
||||
* Timestampable/Blamable (referentiel statique whiteliste dans
|
||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
|
||||
* `client:read:accounting` permet l'embarquement dans la reponse Client.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
normalizationContext: ['groups' => ['payment_delay:read']],
|
||||
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC.
|
||||
order: ['position' => 'ASC', 'label' => 'ASC'],
|
||||
// ERP-72 : pagination serveur + toggle ?pagination=false (cf. TvaMode).
|
||||
paginationClientEnabled: true,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
normalizationContext: ['groups' => ['payment_delay:read']],
|
||||
),
|
||||
],
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrinePaymentDelayRepository::class)]
|
||||
#[ORM\Table(name: 'payment_delay')]
|
||||
#[ORM\UniqueConstraint(name: 'uq_payment_delay_code', columns: ['code'])]
|
||||
|
||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrinePaymentTypeRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
@@ -16,10 +19,29 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
* Le `code` porte une semantique metier : VIREMENT impose une banque (RG-1.12),
|
||||
* LCR impose au moins un RIB (RG-1.13).
|
||||
*
|
||||
* Lecture seule au M1 (HP-M2-2). Pas de Timestampable/Blamable (referentiel
|
||||
* statique whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED). Le
|
||||
* groupe `client:read:accounting` permet l'embarquement dans la reponse Client.
|
||||
* Lecture seule au M1 (HP-M2-2) : GetCollection + Get uniquement (ERP-56),
|
||||
* permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de
|
||||
* Timestampable/Blamable (referentiel statique whiteliste dans
|
||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
|
||||
* `client:read:accounting` permet l'embarquement dans la reponse Client.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
normalizationContext: ['groups' => ['payment_type:read']],
|
||||
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC.
|
||||
order: ['position' => 'ASC', 'label' => 'ASC'],
|
||||
// ERP-72 : pagination serveur + toggle ?pagination=false (cf. TvaMode).
|
||||
paginationClientEnabled: true,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
normalizationContext: ['groups' => ['payment_type:read']],
|
||||
),
|
||||
],
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrinePaymentTypeRepository::class)]
|
||||
#[ORM\Table(name: 'payment_type')]
|
||||
#[ORM\UniqueConstraint(name: 'uq_payment_type_code', columns: ['code'])]
|
||||
|
||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineTvaModeRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
@@ -13,15 +16,35 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
* referentiel statique seede par la migration M1 (Version20260601000000) et
|
||||
* re-seede en dev/test par CommercialReferentialFixtures.
|
||||
*
|
||||
* Lecture seule au M1 : pas de POST/PATCH/DELETE (HP-M2-2). L'ApiResource
|
||||
* (GetCollection + Get, tri position ASC) est branche au ticket dedie des
|
||||
* referentiels lecture seule.
|
||||
* Lecture seule au M1 (HP-M2-2) : seules GetCollection et Get sont exposees
|
||||
* (ERP-56), sous la permission commercial.clients.view ; aucune ecriture
|
||||
* declaree -> POST/PATCH/DELETE renvoient 405.
|
||||
*
|
||||
* Referentiel statique : pas de Timestampable/Blamable (whiteliste dans
|
||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le
|
||||
* groupe `client:read:accounting` permet d'embarquer le mode dans la reponse
|
||||
* d'un Client (onglet Comptabilite) au lieu d'un IRI.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
normalizationContext: ['groups' => ['tva_mode:read']],
|
||||
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC
|
||||
// (ordre des selecteurs comptables) — provider Doctrine par defaut.
|
||||
order: ['position' => 'ASC', 'label' => 'ASC'],
|
||||
// ERP-72 : pagination serveur sur toute collection autonome. Le
|
||||
// toggle client est desactive globalement, on l'active ici pour
|
||||
// permettre ?pagination=false (alimenter un <MalioSelect> entier).
|
||||
paginationClientEnabled: true,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
normalizationContext: ['groups' => ['tva_mode:read']],
|
||||
),
|
||||
],
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineTvaModeRepository::class)]
|
||||
#[ORM\Table(name: 'tva_mode')]
|
||||
#[ORM\UniqueConstraint(name: 'uq_tva_mode_code', columns: ['code'])]
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\ApiPlatform\Serializer;
|
||||
|
||||
use ApiPlatform\Metadata\IriConverterInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
|
||||
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
|
||||
|
||||
/**
|
||||
* Denormalise un IRI (`/api/sites/{id}`) vers le Site concret quand la propriete
|
||||
* cible est type-hintee par le contrat SiteInterface (ClientAddress::$sites).
|
||||
*
|
||||
* Meme mecanisme que CategoryReferenceDenormalizer : API Platform deduit le type
|
||||
* d'element de collection depuis le phpdoc `@var Collection<int, SiteInterface>`,
|
||||
* donc l'INTERFACE. Le serializer ne sait pas denormaliser un IRI vers une
|
||||
* interface (« Could not denormalize object of type SiteInterface[] ») ; on
|
||||
* resout l'IRI via l'IriConverter (qui retourne le Site mappe a la route) sans
|
||||
* importer la classe Site du module Sites — la regle ABSOLUE n°1 (pas d'import
|
||||
* cross-module) reste respectee : dependance au seul contrat Shared + API Platform.
|
||||
*
|
||||
* En lecture (normalisation), aucun probleme : l'objet reel EST un Site,
|
||||
* ressource a part entiere, serialise en IRI par le normalizer standard.
|
||||
*/
|
||||
final class SiteReferenceDenormalizer implements DenormalizerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly IriConverterInterface $iriConverter,
|
||||
) {}
|
||||
|
||||
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): ?SiteInterface
|
||||
{
|
||||
if (!is_string($data) || '' === $data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// getResourceFromIri leve une exception sur IRI invalide -> 400, ce qui
|
||||
// est le comportement attendu pour une reference cassee.
|
||||
$resource = $this->iriConverter->getResourceFromIri($data);
|
||||
|
||||
// IRI syntaxiquement valide mais pointant sur une autre ressource : on
|
||||
// refuse explicitement plutot que de retourner null silencieusement.
|
||||
if (!$resource instanceof SiteInterface) {
|
||||
throw new UnexpectedValueException(sprintf(
|
||||
'L\'IRI "%s" ne référence pas un site.',
|
||||
$data,
|
||||
));
|
||||
}
|
||||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
|
||||
{
|
||||
// Support base sur le seul type cible : l'ArrayDenormalizer (collection
|
||||
// `SiteInterface[]`) interroge le support en passant le TABLEAU complet
|
||||
// comme $data avant de deleguer element par element. Tester
|
||||
// is_string($data) ici casserait la chaine pour les collections.
|
||||
return SiteInterface::class === $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<class-string|string, bool>
|
||||
*/
|
||||
public function getSupportedTypes(?string $format): array
|
||||
{
|
||||
return [SiteInterface::class => true];
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource Adresse d'un client (M1, § 4.5).
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : normalisation serveur du billingEmail en lowercase (RG-1.21)
|
||||
* via le ClientFieldNormalizer partage. Les autres regles de l'onglet Adresse
|
||||
* sont deja garanties en amont : RG-1.09 (code postal) et RG-1.10 (>= 1 site)
|
||||
* par des contraintes Assert sur l'entite, RG-1.06/07/08/11 par des CHECK BDD.
|
||||
* - DELETE : aucune regle metier specifique (suppression physique directe).
|
||||
*
|
||||
* La security de l'operation (commercial.clients.manage) est deja appliquee par
|
||||
* API Platform, de meme que la validation Symfony des contraintes d'attribut.
|
||||
*
|
||||
* @implements ProcessorInterface<ClientAddress, null|ClientAddress>
|
||||
*/
|
||||
final class ClientAddressProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly ClientFieldNormalizer $normalizer,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof ClientAddress) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
$this->linkParent($data, $uriVariables);
|
||||
$this->normalize($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattache l'adresse au client parent de la sous-ressource POST
|
||||
* (/clients/{clientId}/addresses) : la relation n'est pas peuplee
|
||||
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
|
||||
*/
|
||||
private function linkParent(ClientAddress $address, array $uriVariables): void
|
||||
{
|
||||
if (null !== $address->getClient()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$clientId = $uriVariables['clientId'] ?? null;
|
||||
if (null === $clientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$client = $clientId instanceof Client
|
||||
? $clientId
|
||||
: $this->em->getRepository(Client::class)->find($clientId);
|
||||
|
||||
if ($client instanceof Client) {
|
||||
$address->setClient($client);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisation serveur (RG-1.21) : email de facturation en minuscules. La
|
||||
* methode est null-safe — une adresse non facturable (billingEmail null)
|
||||
* reste null.
|
||||
*/
|
||||
private function normalize(ClientAddress $address): void
|
||||
{
|
||||
$address->setBillingEmail($this->normalizer->normalizeEmail($address->getBillingEmail()));
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Entity\ClientContact;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource Contact d'un client (M1, § 4.5).
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : normalisation serveur (RG-1.19 prenom/nom capitalize,
|
||||
* RG-1.20 telephones reduits aux chiffres, RG-1.21 email lowercase) via le
|
||||
* ClientFieldNormalizer partage (reutilise d'ERP-55), puis validation RG-1.05
|
||||
* (au moins prenom OU nom) avant persistance.
|
||||
* - DELETE : RG-1.14 — la suppression du DERNIER contact d'un client est
|
||||
* refusee (409). Au M1, la completude de l'onglet Contact est purement front
|
||||
* (pas de state machine back) : on garantit seulement qu'un client deja dote
|
||||
* d'un contact n'en soit jamais vide via l'API.
|
||||
*
|
||||
* La security de l'operation (commercial.clients.manage) est deja appliquee par
|
||||
* API Platform en amont. La validation Symfony des contraintes d'attribut
|
||||
* (Assert\Email, Assert\Length...) est jouee avant ce processor.
|
||||
*
|
||||
* @implements ProcessorInterface<ClientContact, null|ClientContact>
|
||||
*/
|
||||
final class ClientContactProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly ClientFieldNormalizer $normalizer,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof ClientContact) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
$this->guardLastContactDeletion($data);
|
||||
|
||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
$this->linkParent($data, $uriVariables);
|
||||
$this->normalize($data);
|
||||
$this->validateName($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattache le contact au client parent de la sous-ressource POST
|
||||
* (/clients/{clientId}/contacts). La relation n'est pas peuplee
|
||||
* automatiquement par le Link sur une operation d'ecriture : on resout donc
|
||||
* le parent depuis l'uri variable. Sur PATCH (entite existante), le client
|
||||
* est deja present -> no-op.
|
||||
*/
|
||||
private function linkParent(ClientContact $contact, array $uriVariables): void
|
||||
{
|
||||
if (null !== $contact->getClient()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$clientId = $uriVariables['clientId'] ?? null;
|
||||
if (null === $clientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$client = $clientId instanceof Client
|
||||
? $clientId
|
||||
: $this->em->getRepository(Client::class)->find($clientId);
|
||||
|
||||
if ($client instanceof Client) {
|
||||
$contact->setClient($client);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisation serveur (RG-1.19 / 1.20 / 1.21). Toutes les methodes du
|
||||
* normalizer sont null-safe : une chaine vide apres trim devient null.
|
||||
*/
|
||||
private function normalize(ClientContact $contact): void
|
||||
{
|
||||
$contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
|
||||
$contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
|
||||
$contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary()));
|
||||
$contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary()));
|
||||
$contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.05 : au moins le prenom OU le nom est obligatoire (double garde avec
|
||||
* le CHECK BDD chk_client_contact_name — leve un 422 propre plutot qu'une
|
||||
* erreur SQL). Joue apres normalisation, donc les chaines vides sont deja
|
||||
* ramenees a null.
|
||||
*/
|
||||
private function validateName(ClientContact $contact): void
|
||||
{
|
||||
if (null === $contact->getFirstName() && null === $contact->getLastName()) {
|
||||
$violations = new ConstraintViolationList();
|
||||
$violations->add(new ConstraintViolation(
|
||||
'Le prénom ou le nom du contact est obligatoire.',
|
||||
null,
|
||||
[],
|
||||
$contact,
|
||||
'firstName',
|
||||
null,
|
||||
));
|
||||
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.14 : refuse la suppression du dernier contact d'un client (409). La
|
||||
* collection inclut le contact en cours de suppression : un effectif <= 1
|
||||
* signifie qu'il ne resterait aucun contact. Sans client rattache (cas
|
||||
* theorique), on laisse passer.
|
||||
*/
|
||||
private function guardLastContactDeletion(ClientContact $contact): void
|
||||
{
|
||||
$client = $contact->getClient();
|
||||
if (null === $client) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($client->getContacts()->count() <= 1) {
|
||||
throw new ConflictHttpException(
|
||||
'Impossible de supprimer le dernier contact du client : au moins un contact est requis.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Entity\ClientRib;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource RIB d'un client (M1, § 4.5).
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : aucune normalisation specifique. La validite de l'IBAN et du
|
||||
* BIC est garantie par Assert\Iban / Assert\Bic sur l'entite (jouees en amont
|
||||
* par API Platform). Aucun #[AuditIgnore] sur iban/bic : la tracabilite
|
||||
* comptable est volontaire (decision Matthieu 29/05, spec § 6.1).
|
||||
* - DELETE : RG-1.13 — si le client est en reglement LCR, la suppression de son
|
||||
* DERNIER RIB est refusee (409), car LCR exige au moins un RIB.
|
||||
*
|
||||
* La security de l'operation (commercial.clients.accounting.manage) est deja
|
||||
* appliquee par API Platform en amont : un utilisateur sans cette permission
|
||||
* recoit 403 sur POST/PATCH/DELETE avant d'atteindre ce processor.
|
||||
*
|
||||
* @implements ProcessorInterface<ClientRib, null|ClientRib>
|
||||
*/
|
||||
final class ClientRibProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof ClientRib) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
$this->guardLastRibDeletionUnderLcr($data);
|
||||
|
||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
$this->linkParent($data, $uriVariables);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattache le RIB au client parent de la sous-ressource POST
|
||||
* (/clients/{clientId}/ribs) : la relation n'est pas peuplee automatiquement
|
||||
* par le Link sur une ecriture. Sur PATCH, no-op.
|
||||
*/
|
||||
private function linkParent(ClientRib $rib, array $uriVariables): void
|
||||
{
|
||||
if (null !== $rib->getClient()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$clientId = $uriVariables['clientId'] ?? null;
|
||||
if (null === $clientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$client = $clientId instanceof Client
|
||||
? $clientId
|
||||
: $this->em->getRepository(Client::class)->find($clientId);
|
||||
|
||||
if ($client instanceof Client) {
|
||||
$rib->setClient($client);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.13 : un client dont le type de reglement est LCR doit conserver au
|
||||
* moins un RIB. La collection inclut le RIB en cours de suppression : un
|
||||
* effectif <= 1 signifie qu'il ne resterait aucun RIB -> 409. Pour tout autre
|
||||
* type de reglement, les RIBs sont optionnels (suppression libre).
|
||||
*/
|
||||
private function guardLastRibDeletionUnderLcr(ClientRib $rib): void
|
||||
{
|
||||
$client = $rib->getClient();
|
||||
if (null === $client) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('LCR' === $client->getPaymentType()?->getCode() && $client->getRibs()->count() <= 1) {
|
||||
throw new ConflictHttpException(
|
||||
'Impossible de supprimer le dernier RIB : le type de règlement LCR exige au moins un RIB.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||
use App\Module\Commercial\Domain\Entity\ClientContact;
|
||||
use App\Module\Commercial\Domain\Entity\ClientRib;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB (ERP-57,
|
||||
* spec § 4.5). Couvrent : CRUD via admin, normalisation serveur
|
||||
* (RG-1.19/1.20/1.21), validations (Assert\Count sites RG-1.10, Assert\Iban/Bic),
|
||||
* regles metier RG-1.13 (DELETE dernier RIB sous LCR -> 409) et RG-1.14 (DELETE
|
||||
* dernier contact -> 409), plus le gating comptable (POST/PATCH/DELETE de
|
||||
* client_ribs sans accounting.manage -> 403).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string LD = 'application/ld+json';
|
||||
private const string MERGE = 'application/merge-patch+json';
|
||||
private const string VALID_IBAN = 'FR1420041010050500013M02606';
|
||||
private const string VALID_BIC = 'BNPAFRPPXXX';
|
||||
|
||||
// === Contacts ===
|
||||
|
||||
public function testPostContactNormalizesFields(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Contact Host');
|
||||
|
||||
$data = $client->request('POST', '/api/clients/'.$seed->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'firstName' => 'JEAN',
|
||||
'lastName' => 'dupont',
|
||||
'phonePrimary' => '06.12.34.56.78',
|
||||
'email' => 'Jean.DUPONT@ACME.FR',
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
// RG-1.19 / 1.20 / 1.21
|
||||
self::assertSame('Jean', $data['firstName']);
|
||||
self::assertSame('Dupont', $data['lastName']);
|
||||
self::assertSame('0612345678', $data['phonePrimary']);
|
||||
self::assertSame('jean.dupont@acme.fr', $data['email']);
|
||||
}
|
||||
|
||||
public function testPostContactWithoutNameReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Contact No Name');
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['jobTitle' => 'Directeur'],
|
||||
]);
|
||||
|
||||
// RG-1.05
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testPatchContactNormalizes(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Contact Patch');
|
||||
$contact = $this->seedContact($seed, 'Paul');
|
||||
|
||||
$data = $client->request('PATCH', '/api/client_contacts/'.$contact->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['lastName' => 'martin'],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
self::assertSame('Martin', $data['lastName']);
|
||||
}
|
||||
|
||||
public function testDeleteContactWhenSeveralReturns204(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Contact Multi');
|
||||
$this->seedContact($seed, 'Premier');
|
||||
$second = $this->seedContact($seed, 'Second');
|
||||
|
||||
$client->request('DELETE', '/api/client_contacts/'.$second->getId());
|
||||
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
}
|
||||
|
||||
public function testDeleteLastContactReturns409(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Contact Solo');
|
||||
$only = $this->seedContact($seed, 'Unique');
|
||||
|
||||
$client->request('DELETE', '/api/client_contacts/'.$only->getId());
|
||||
|
||||
// RG-1.14
|
||||
self::assertResponseStatusCodeSame(409);
|
||||
}
|
||||
|
||||
// === Adresses ===
|
||||
|
||||
public function testPostAddressNormalizesBillingEmail(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Address Host');
|
||||
$siteIri = $this->firstSiteIri();
|
||||
|
||||
$data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'isBilling' => true,
|
||||
'billingEmail' => 'Facturation@ACME.FR',
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$siteIri],
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
// RG-1.21
|
||||
self::assertSame('facturation@acme.fr', $data['billingEmail']);
|
||||
}
|
||||
|
||||
public function testPostAddressWithoutSiteReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Address No Site');
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-1.10 (Assert\Count min 1)
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testPostAddressWithInvalidPostalCodeReturns422(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Address Bad CP');
|
||||
$siteIri = $this->firstSiteIri();
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '123',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$siteIri],
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-1.09 (Assert\Regex ^[0-9]{4,5}$)
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
// === RIBs ===
|
||||
|
||||
public function testPostRibByAdminReturns201(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Rib Host');
|
||||
|
||||
$data = $client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'label' => 'Compte principal',
|
||||
'bic' => self::VALID_BIC,
|
||||
'iban' => self::VALID_IBAN,
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertSame('Compte principal', $data['label']);
|
||||
}
|
||||
|
||||
public function testPostRibWithInvalidIbanReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Rib Bad Iban');
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'label' => 'Compte invalide',
|
||||
'bic' => self::VALID_BIC,
|
||||
'iban' => 'INVALID-IBAN',
|
||||
],
|
||||
]);
|
||||
|
||||
// Assert\Iban
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testDeleteRibNonLcrReturns204(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Rib Non LCR');
|
||||
$rib = $this->seedRib($seed);
|
||||
|
||||
$client->request('DELETE', '/api/client_ribs/'.$rib->getId());
|
||||
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
}
|
||||
|
||||
public function testDeleteLastRibUnderLcrReturns409(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Rib LCR Solo');
|
||||
$this->setPaymentType($seed, 'LCR');
|
||||
$rib = $this->seedRib($seed);
|
||||
|
||||
$client->request('DELETE', '/api/client_ribs/'.$rib->getId());
|
||||
|
||||
// RG-1.13
|
||||
self::assertResponseStatusCodeSame(409);
|
||||
}
|
||||
|
||||
public function testRibWriteWithoutAccountingManageReturns403(): void
|
||||
{
|
||||
// Un utilisateur portant seulement commercial.clients.manage (sans
|
||||
// accounting.manage) ne peut ni creer, ni modifier, ni supprimer un RIB.
|
||||
$seed = $this->seedClient('Rib Forbidden');
|
||||
$rib = $this->seedRib($seed);
|
||||
$credentials = $this->createUserWithPermission('commercial.clients.manage');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['label' => 'X', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('PATCH', '/api/client_ribs/'.$rib->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['label' => 'Y'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('DELETE', '/api/client_ribs/'.$rib->getId());
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
/**
|
||||
* Seede un ClientContact rattache a un client (sans passer par l'API).
|
||||
*/
|
||||
private function seedContact(ClientEntity $client, string $firstName): ClientContact
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$contact = new ClientContact();
|
||||
$contact->setFirstName($firstName);
|
||||
$contact->setClient($client);
|
||||
$em->persist($contact);
|
||||
$em->flush();
|
||||
|
||||
return $contact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un ClientRib valide rattache a un client (sans passer par l'API).
|
||||
*/
|
||||
private function seedRib(ClientEntity $client): ClientRib
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$rib = new ClientRib();
|
||||
$rib->setLabel('Seed RIB');
|
||||
$rib->setBic(self::VALID_BIC);
|
||||
$rib->setIban(self::VALID_IBAN);
|
||||
$rib->setClient($client);
|
||||
$em->persist($rib);
|
||||
$em->flush();
|
||||
|
||||
return $rib;
|
||||
}
|
||||
|
||||
/**
|
||||
* Affecte un type de reglement (par code) au client seede.
|
||||
*/
|
||||
private function setPaymentType(ClientEntity $client, string $code): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$type = $em->getRepository(PaymentType::class)->findOneBy(['code' => $code]);
|
||||
self::assertNotNull($type, sprintf('PaymentType "%s" introuvable (fixtures).', $code));
|
||||
|
||||
$managed = $em->getRepository(ClientEntity::class)->find($client->getId());
|
||||
$managed->setPaymentType($type);
|
||||
$em->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'IRI du premier site seede (fixtures Sites). Skip en amont si le
|
||||
* module Sites est desactive.
|
||||
*/
|
||||
private function firstSiteIri(): string
|
||||
{
|
||||
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
|
||||
self::assertNotNull($site, 'Aucun site seede : impossible de tester les adresses.');
|
||||
|
||||
return '/api/sites/'.$site->getId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\TvaMode;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels des 4 referentiels comptables lecture seule (ERP-56) :
|
||||
* tva_mode, payment_delay, payment_type, bank. Cf. spec-back M1 § 4.7.
|
||||
*
|
||||
* Couvre les criteres d'acceptation du ticket :
|
||||
* - les 4 GetCollection repondent 200 avec le seed (CommercialReferentialFixtures) ;
|
||||
* - tri par defaut position ASC puis label ASC ;
|
||||
* - POST / PATCH / DELETE -> 405 (aucune operation d'ecriture declaree) ;
|
||||
* - user authentifie sans commercial.clients.view -> 403 ;
|
||||
* - anonyme -> 401 ;
|
||||
* - pagination serveur active (ERP-72) + echappatoire ?pagination=false.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ReferentialApiTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string LD = 'application/ld+json';
|
||||
|
||||
/**
|
||||
* Endpoint => codes attendus dans le seed (sous-ensemble verifie present).
|
||||
*
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
private const SEED = [
|
||||
'/api/tva_modes' => ['FRANCE_VENTES', 'EXPORT_VENTES', 'INTRACOM_VENTES'],
|
||||
'/api/payment_delays' => ['J15', 'J30', 'A_RECEPTION'],
|
||||
'/api/payment_types' => ['VIREMENT', 'LCR', 'NON_SOUMISE', 'CHEQUE'],
|
||||
'/api/banks' => ['SG', 'CIC', 'CA'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Purge les eventuelles lignes de test inserees dans tva_mode (tri label).
|
||||
* Les codes du seed ne commencent jamais par TEST_, donc cette purge ne
|
||||
* touche pas les referentiels metier.
|
||||
*/
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->getEm()
|
||||
->createQuery('DELETE FROM '.TvaMode::class.' t WHERE t.code LIKE :prefix')
|
||||
->setParameter('prefix', 'TEST\_%')
|
||||
->execute()
|
||||
;
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Critere : chaque endpoint repond 200 et expose le seed (id, code, label,
|
||||
* position) sous le groupe de lecture du referentiel.
|
||||
*
|
||||
* @param list<string> $expectedCodes
|
||||
*/
|
||||
#[DataProvider('endpointProvider')]
|
||||
public function testCollectionReturns200WithSeed(string $endpoint, array $expectedCodes): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', $endpoint.'?pagination=false', ['headers' => ['Accept' => self::LD]]);
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
$members = $response->toArray()['member'];
|
||||
$codes = array_map(static fn (array $m): string => $m['code'], $members);
|
||||
|
||||
foreach ($expectedCodes as $expected) {
|
||||
self::assertContains($expected, $codes, $endpoint.' doit exposer le code seede '.$expected);
|
||||
}
|
||||
|
||||
// Le DTO de lecture expose bien id / code / label / position.
|
||||
$first = $members[0];
|
||||
self::assertArrayHasKey('id', $first);
|
||||
self::assertArrayHasKey('label', $first);
|
||||
self::assertArrayHasKey('position', $first);
|
||||
}
|
||||
|
||||
/**
|
||||
* Critere : GET item repond 200 (recupere via un id reel de la collection).
|
||||
*/
|
||||
public function testGetItemReturns200(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$first = $client->request('GET', '/api/tva_modes?pagination=false', ['headers' => ['Accept' => self::LD]])
|
||||
->toArray()['member'][0]
|
||||
;
|
||||
|
||||
$client->request('GET', '/api/tva_modes/'.$first['id'], ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Critere : tri par defaut position ASC. Le seed tva_mode est ordonne
|
||||
* FRANCE_VENTES (10) < EXPORT_VENTES (20) < INTRACOM_VENTES (30).
|
||||
*/
|
||||
public function testDefaultSortByPositionAsc(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$codes = array_map(
|
||||
static fn (array $m): string => $m['code'],
|
||||
$client->request('GET', '/api/tva_modes?pagination=false', ['headers' => ['Accept' => self::LD]])->toArray()['member'],
|
||||
);
|
||||
|
||||
$expectedOrder = ['FRANCE_VENTES', 'EXPORT_VENTES', 'INTRACOM_VENTES'];
|
||||
$filtered = array_values(array_intersect($codes, $expectedOrder));
|
||||
|
||||
self::assertSame(
|
||||
$expectedOrder,
|
||||
$filtered,
|
||||
'Les modes de TVA doivent etre tries position ASC (§ 4.7).',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Critere : a position egale, tri label ASC (departage). On insere deux
|
||||
* lignes de test partageant la meme position, labels volontairement dans le
|
||||
* desordre alphabetique ; le tearDown les purge ensuite.
|
||||
*/
|
||||
public function testTieBreakSortByLabelAsc(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
foreach ([['TEST_TIE_Z', 'ZZZ Tie'], ['TEST_TIE_A', 'AAA Tie']] as [$code, $label]) {
|
||||
$mode = new TvaMode();
|
||||
$mode->setCode($code);
|
||||
$mode->setLabel($label);
|
||||
$mode->setPosition(9000);
|
||||
$em->persist($mode);
|
||||
}
|
||||
$em->flush();
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$codes = array_map(
|
||||
static fn (array $m): string => $m['code'],
|
||||
$client->request('GET', '/api/tva_modes?pagination=false', ['headers' => ['Accept' => self::LD]])->toArray()['member'],
|
||||
);
|
||||
|
||||
$tie = array_values(array_intersect($codes, ['TEST_TIE_A', 'TEST_TIE_Z']));
|
||||
self::assertSame(
|
||||
['TEST_TIE_A', 'TEST_TIE_Z'],
|
||||
$tie,
|
||||
'A position egale, le tri secondaire doit etre label ASC (§ 4.7).',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Critere ERP-72 : la collection est paginee par defaut. Preuve : une page
|
||||
* au-dela des donnees est vide (un provider non pagine ignorerait `page`).
|
||||
* Avec ?pagination=false, le parametre `page` est ignore -> tout revient.
|
||||
*/
|
||||
public function testPaginationActiveAndClientToggle(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
// Page 2 d'un referentiel tenant sur une page : vide -> pagination active.
|
||||
$page2 = $client->request('GET', '/api/tva_modes?page=2', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertArrayHasKey('totalItems', $page2);
|
||||
self::assertSame([], $page2['member'], 'La page 2 doit etre vide : pagination serveur active.');
|
||||
|
||||
// ?pagination=false : `page` ignore, le seed complet est renvoye.
|
||||
$all = $client->request('GET', '/api/tva_modes?pagination=false&page=2', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertNotEmpty($all['member'], '?pagination=false doit desactiver la pagination (page ignoree).');
|
||||
}
|
||||
|
||||
/**
|
||||
* Critere : aucune operation d'ecriture n'est declaree -> POST sur la
|
||||
* collection renvoie 405 Method Not Allowed sur les 4 referentiels.
|
||||
*
|
||||
* @param list<string> $expectedCodes
|
||||
*/
|
||||
#[DataProvider('endpointProvider')]
|
||||
public function testPostReturns405(string $endpoint, array $expectedCodes): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', $endpoint, [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['code' => 'X', 'label' => 'X', 'position' => 1],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(405);
|
||||
}
|
||||
|
||||
/**
|
||||
* Critere : PATCH et DELETE sur un item renvoient 405 (lecture seule).
|
||||
*/
|
||||
public function testPatchAndDeleteReturn405(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$first = $client->request('GET', '/api/tva_modes?pagination=false', ['headers' => ['Accept' => self::LD]])
|
||||
->toArray()['member'][0]
|
||||
;
|
||||
$iri = '/api/tva_modes/'.$first['id'];
|
||||
|
||||
$client->request('PATCH', $iri, [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['label' => 'Renamed'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(405);
|
||||
|
||||
$client->request('DELETE', $iri);
|
||||
self::assertResponseStatusCodeSame(405);
|
||||
}
|
||||
|
||||
/**
|
||||
* Critere : un utilisateur authentifie sans la permission
|
||||
* commercial.clients.view obtient 403 sur les 4 endpoints.
|
||||
*
|
||||
* @param list<string> $expectedCodes
|
||||
*/
|
||||
#[DataProvider('endpointProvider')]
|
||||
public function testForbiddenWithoutPermission(string $endpoint, array $expectedCodes): void
|
||||
{
|
||||
// User jetable portant une permission SANS rapport (existe en base mais
|
||||
// ne donne pas commercial.clients.view).
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$client->request('GET', $endpoint, ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Critere : un appel anonyme (non authentifie) obtient 401 sur les 4
|
||||
* endpoints.
|
||||
*
|
||||
* @param list<string> $expectedCodes
|
||||
*/
|
||||
#[DataProvider('endpointProvider')]
|
||||
public function testUnauthorizedWhenAnonymous(string $endpoint, array $expectedCodes): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', $endpoint, ['headers' => ['Accept' => self::LD]]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string, list<string>}>
|
||||
*/
|
||||
public static function endpointProvider(): iterable
|
||||
{
|
||||
foreach (self::SEED as $endpoint => $codes) {
|
||||
yield $endpoint => [$endpoint, $codes];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user