test(commercial) : cover RG-1.01..1.29 except role-gated (M1) + polish stack (#38)
Auto Tag Develop / tag (push) Successful in 7s
Auto Tag Develop / tag (push) Successful in 7s
Dernier wagon de la stack back M1. ERP-60 = polish stack + couverture de tests PHPUnit NON dépendante des rôles métier (cf. spec § 7 / § 8.1). ## Phase 0 — polish stack (déjà mergé dans les branches basses via rebase) - ERP-59 : route sidebar `/clients` (au lieu de `/commercial/clients`), cohérente avec `/suppliers`. - One-liner pagination Client abandonné : `pagination_client_enabled: true` est déjà le défaut global → `?pagination=false` marche déjà sur `/api/clients` (décision P7). ## Phase 1 — tests (combler les trous, zéro duplication) 8 nouvelles suites couvrant les RG non encore testées par ERP-55/56/57/58 : - `ClientFormulaireMainTest` — RG-1.02 (téléphone secondaire, max 2). - `ClientAddressTest` — RG-1.06/07/08 + RG-1.11 (CHECK BDD prospect/billing). - `ClientUniquenessTest` — RG-1.15/1.17 (Q4 : SIREN/email NON uniques). - `ClientArchiveTest` — **RG-1.23 : 409 restauration en conflit (gap P1)**. - `ClientAuditTest` — RG-1.27 (created* figés / updatedBy modificateur) + iban/bic présents dans le diff audité. - `ClientMigrationTest` — index partiel unique `uq_client_company_name_active` (1 seul) ; pas d'index siren/email. - `ClientSecurityTest` — 401 anonyme + 403 sans `commercial.clients.view`. - `ClientPatchStrictTest` — RG-1.28 (403 strict mix de groupes, fonctionnel). Cahier de test complet (mapping de TOUTES les RG → test) : `docs/specs/M1-clients/cahier-test-back-M1.md`. ## Délégué à ERP-74 (#493) Matrice RBAC différenciée (bureau/compta/commerciale/usine) + RG-1.04 fonctionnel — exigent les rôles métier seedés après le merge de la stack. ## Gaps documentés (cahier) - RG-1.29 validation écriture (catégorie type sur adresse → 422) non implémentée back (hors § 8.1, ticket test-only). - Violations CHECK adresse → rejet (≥400) sans mapping fin 422 (amélioration possible). ## Vérifs `make db-reset && make php-cs-fixer-allow-risky && make test` → **421 tests OK, 1386 assertions, 0 risky**. Nouveaux tests : 17, 71 assertions. --------- Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #38 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 #38.
This commit is contained in:
@@ -9,4 +9,36 @@ final class CommercialModule
|
||||
public const string ID = 'commercial';
|
||||
public const string LABEL = 'Commercial';
|
||||
public const bool REQUIRED = false;
|
||||
|
||||
/**
|
||||
* Liste declarative des permissions RBAC exposees par le module Commercial.
|
||||
*
|
||||
* Consommee par la commande `app:sync-permissions` (SyncPermissionsCommand)
|
||||
* qui se charge d'upserter ces entrees dans la table `permission`, de
|
||||
* reactiver les codes precedemment marques orphelins et de marquer comme
|
||||
* orphelins ceux qui ont disparu du code source.
|
||||
*
|
||||
* La cle `module` est auto-injectee par le sync command a partir de
|
||||
* `self::ID`, il est donc inutile de la repeter dans chaque entree.
|
||||
*
|
||||
* Convention de nommage des codes : `module.resource[.sub].action` en
|
||||
* snake_case, le prefixe module devant correspondre exactement a
|
||||
* `self::ID` (verifie par la commande de synchronisation).
|
||||
*
|
||||
* Granularite alignee sur Core/Catalog (view + manage), plus deux
|
||||
* permissions dediees a l'onglet Comptabilite et a l'archivage
|
||||
* (cf. spec-back M1 § 2.7).
|
||||
*
|
||||
* @return array<int, array{code: string, label: string}>
|
||||
*/
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'commercial.clients.view', 'label' => 'Voir les clients'],
|
||||
['code' => 'commercial.clients.manage', 'label' => 'Créer / modifier les clients (hors onglet Comptabilité)'],
|
||||
['code' => 'commercial.clients.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un client'],
|
||||
['code' => 'commercial.clients.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un client'],
|
||||
['code' => 'commercial.clients.archive', 'label' => 'Archiver / restaurer un client'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
@@ -18,6 +18,18 @@ interface ClientRepositoryInterface
|
||||
* - Exclut toujours les clients soft-deletes (deleted_at IS NOT NULL, RG-1.24).
|
||||
* - Exclut les archives sauf si $includeArchived = true (RG-1.25).
|
||||
* - Tri par defaut : companyName ASC (RG-1.26).
|
||||
* - $search : recherche fuzzy insensible a la casse sur companyName +
|
||||
* lastName + email (metacaracteres LIKE echappes). Ignore si null/vide.
|
||||
* - $categoryType : restreint aux clients possedant au moins une categorie
|
||||
* du type donne (code). Ignore si null/vide.
|
||||
*
|
||||
* Filtrage centralise ICI (et non dans les providers/controllers) pour que
|
||||
* la liste paginee (ClientProvider) et l'export (ClientExportController)
|
||||
* partagent strictement la meme logique de selection.
|
||||
*/
|
||||
public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder;
|
||||
public function createListQueryBuilder(
|
||||
bool $includeArchived = false,
|
||||
?string $search = null,
|
||||
?string $categoryType = null,
|
||||
): QueryBuilder;
|
||||
}
|
||||
|
||||
+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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,6 @@ use ApiPlatform\State\Pagination\Pagination;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
@@ -46,7 +44,6 @@ final class ClientProvider implements ProviderInterface
|
||||
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')]
|
||||
private readonly ClientRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Client|iterable|Paginator|null
|
||||
@@ -67,10 +64,15 @@ final class ClientProvider implements ProviderInterface
|
||||
{
|
||||
$filters = $context['filters'] ?? [];
|
||||
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
|
||||
$search = $filters['search'] ?? null;
|
||||
$categoryType = $filters['categoryType'] ?? null;
|
||||
|
||||
$qb = $this->repository->createListQueryBuilder($includeArchived);
|
||||
$this->applySearch($qb, $filters['search'] ?? null);
|
||||
$this->applyCategoryType($qb, $filters['categoryType'] ?? null);
|
||||
// Filtrage delegue au repository (logique partagee avec l'export XLSX).
|
||||
$qb = $this->repository->createListQueryBuilder(
|
||||
$includeArchived,
|
||||
is_string($search) ? $search : null,
|
||||
is_string($categoryType) ? $categoryType : null,
|
||||
);
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||
// (cf. convention ERP-72 — utile pour un <select> cote front).
|
||||
@@ -114,55 +116,6 @@ final class ClientProvider implements ProviderInterface
|
||||
return $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
|
||||
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
|
||||
* litteraux.
|
||||
*/
|
||||
private function applySearch(QueryBuilder $qb, mixed $search): void
|
||||
{
|
||||
if (!is_string($search) || '' === trim($search)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
||||
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
||||
|
||||
$qb->andWhere(
|
||||
'LOWER(c.companyName) LIKE :search '
|
||||
.'OR LOWER(c.lastName) LIKE :search '
|
||||
.'OR LOWER(c.email) LIKE :search',
|
||||
)->setParameter('search', $pattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restreint aux clients possedant au moins une categorie du type donne.
|
||||
* Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas
|
||||
* perturber le DISTINCT / ORDER BY de la requete paginee principale.
|
||||
*/
|
||||
private function applyCategoryType(QueryBuilder $qb, mixed $categoryType): void
|
||||
{
|
||||
if (!is_string($categoryType) || '' === trim($categoryType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sous-requete construite via l'EntityManager (et non
|
||||
// $repository->createQueryBuilder()) : createQueryBuilder() n'est pas
|
||||
// declaree sur ClientRepositoryInterface, l'appeler exposerait un detail
|
||||
// d'implementation Doctrine hors du contrat (fuite d'abstraction).
|
||||
$sub = $this->em->createQueryBuilder()
|
||||
->select('c2.id')
|
||||
->from(Client::class, 'c2')
|
||||
->join('c2.categories', 'cat2')
|
||||
->join('cat2.categoryType', 'ct2')
|
||||
->where('ct2.code = :categoryType')
|
||||
;
|
||||
|
||||
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
|
||||
->setParameter('categoryType', trim($categoryType))
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Export XLSX du repertoire clients (M1, spec-back § 4.6).
|
||||
*
|
||||
* Controller Symfony custom (et non operation API Platform) car il produit un
|
||||
* binaire de fichier, pas une representation Hydra. `priority: 1` est
|
||||
* OBLIGATOIRE sur la route : sans cela API Platform capterait
|
||||
* `/api/clients/export.xlsx` comme l'item `GET /api/clients/{id}.{_format}`
|
||||
* (id="export", _format="xlsx") — cf. CLAUDE.md « controller custom sous /api ».
|
||||
*
|
||||
* Separation des responsabilites :
|
||||
* - le COMMENT (generation du fichier) est delegue au service Shared
|
||||
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
|
||||
* - le QUOI vit ICI : selection des clients (memes filtres que
|
||||
* `GET /api/clients`, via {@see ClientRepositoryInterface::createListQueryBuilder()})
|
||||
* et mapping metier des colonnes.
|
||||
*
|
||||
* La colonne SIREN n'est ajoutee que si l'utilisateur a la permission
|
||||
* `commercial.clients.accounting.view` (gating identique a la lecture).
|
||||
*/
|
||||
#[AsController]
|
||||
final class ClientExportController
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')]
|
||||
private readonly ClientRepositoryInterface $repository,
|
||||
private readonly SpreadsheetExporterInterface $exporter,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
#[Route('/api/clients/export.xlsx', name: 'commercial_clients_export_xlsx', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('commercial.clients.view')]
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$includeArchived = $this->readBool($request->query->get('includeArchived'));
|
||||
$search = $request->query->getString('search') ?: null;
|
||||
$categoryType = $request->query->getString('categoryType') ?: null;
|
||||
|
||||
/** @var list<Client> $clients */
|
||||
$clients = $this->repository
|
||||
->createListQueryBuilder($includeArchived, $search, $categoryType)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
|
||||
$withSiren = $this->security->isGranted('commercial.clients.accounting.view');
|
||||
|
||||
$binary = $this->exporter->export(
|
||||
'Répertoire clients',
|
||||
$this->buildHeaders($withSiren),
|
||||
$this->buildRows($clients, $withSiren),
|
||||
);
|
||||
|
||||
return $this->buildResponse($binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes dans l'ordre impose par la spec § 4.6. SIREN inseree avant la
|
||||
* date de creation, uniquement si l'utilisateur a accounting.view.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildHeaders(bool $withSiren): array
|
||||
{
|
||||
$headers = [
|
||||
'Nom entreprise',
|
||||
'Nom contact principal',
|
||||
'Prénom',
|
||||
'Téléphone principal',
|
||||
'Téléphone secondaire',
|
||||
'Email',
|
||||
'Catégories',
|
||||
'Sites',
|
||||
];
|
||||
|
||||
if ($withSiren) {
|
||||
$headers[] = 'SIREN';
|
||||
}
|
||||
|
||||
$headers[] = 'Date de création';
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Client> $clients
|
||||
*
|
||||
* @return iterable<list<null|scalar>>
|
||||
*/
|
||||
private function buildRows(array $clients, bool $withSiren): iterable
|
||||
{
|
||||
foreach ($clients as $client) {
|
||||
$row = [
|
||||
$client->getCompanyName(),
|
||||
$client->getLastName(),
|
||||
$client->getFirstName(),
|
||||
$client->getPhonePrimary(),
|
||||
$client->getPhoneSecondary(),
|
||||
$client->getEmail(),
|
||||
$this->formatCategories($client),
|
||||
$this->formatSites($client),
|
||||
];
|
||||
|
||||
if ($withSiren) {
|
||||
$row[] = $client->getSiren();
|
||||
}
|
||||
|
||||
$row[] = $client->getCreatedAt()?->format('d/m/Y');
|
||||
|
||||
yield $row;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelles des categories du client, dedupliques, tries, joints par virgule.
|
||||
*/
|
||||
private function formatCategories(Client $client): string
|
||||
{
|
||||
$names = [];
|
||||
foreach ($client->getCategories() as $category) {
|
||||
// @var CategoryInterface $category
|
||||
$name = $category->getName();
|
||||
if (null !== $name && '' !== $name) {
|
||||
$names[$name] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->joinSorted($names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le Client ne porte pas de sites en propre : ils sont rattaches aux
|
||||
* adresses (RG-1.10). La colonne « Sites » agrege donc l'union distincte des
|
||||
* sites de toutes les adresses du client (decision validee 01/06).
|
||||
*/
|
||||
private function formatSites(Client $client): string
|
||||
{
|
||||
$names = [];
|
||||
foreach ($client->getAddresses() as $address) {
|
||||
foreach ($address->getSites() as $site) {
|
||||
// @var SiteInterface $site
|
||||
$name = $site->getName();
|
||||
if (null !== $name && '' !== $name) {
|
||||
$names[$name] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->joinSorted($names);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $names ensemble de libelles (cles)
|
||||
*/
|
||||
private function joinSorted(array $names): string
|
||||
{
|
||||
$list = array_keys($names);
|
||||
sort($list);
|
||||
|
||||
return implode(', ', $list);
|
||||
}
|
||||
|
||||
private function buildResponse(string $binary): Response
|
||||
{
|
||||
$filename = sprintf('repertoire-clients-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
|
||||
|
||||
$response = new Response($binary);
|
||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
||||
* Aligne sur ClientProvider pour un comportement identique a la liste.
|
||||
*/
|
||||
private function readBool(mixed $raw): bool
|
||||
{
|
||||
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,11 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder
|
||||
{
|
||||
public function createListQueryBuilder(
|
||||
bool $includeArchived = false,
|
||||
?string $search = null,
|
||||
?string $categoryType = null,
|
||||
): QueryBuilder {
|
||||
$qb = $this->createQueryBuilder('c')
|
||||
->andWhere('c.deletedAt IS NULL')
|
||||
->orderBy('c.companyName', 'ASC')
|
||||
@@ -42,6 +45,54 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
|
||||
$qb->andWhere('c.isArchived = false');
|
||||
}
|
||||
|
||||
$this->applySearch($qb, $search);
|
||||
$this->applyCategoryType($qb, $categoryType);
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
|
||||
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
|
||||
* litteraux.
|
||||
*/
|
||||
private function applySearch(QueryBuilder $qb, ?string $search): void
|
||||
{
|
||||
if (null === $search || '' === trim($search)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
||||
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
||||
|
||||
$qb->andWhere(
|
||||
'LOWER(c.companyName) LIKE :search '
|
||||
.'OR LOWER(c.lastName) LIKE :search '
|
||||
.'OR LOWER(c.email) LIKE :search',
|
||||
)->setParameter('search', $pattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restreint aux clients possedant au moins une categorie du type donne.
|
||||
* Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas
|
||||
* perturber le DISTINCT / ORDER BY de la requete principale.
|
||||
*/
|
||||
private function applyCategoryType(QueryBuilder $qb, ?string $categoryType): void
|
||||
{
|
||||
if (null === $categoryType || '' === trim($categoryType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sub = $this->getEntityManager()->createQueryBuilder()
|
||||
->select('c2.id')
|
||||
->from(Client::class, 'c2')
|
||||
->join('c2.categories', 'cat2')
|
||||
->join('cat2.categoryType', 'ct2')
|
||||
->where('ct2.code = :categoryType')
|
||||
;
|
||||
|
||||
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
|
||||
->setParameter('categoryType', trim($categoryType))
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +186,15 @@ final class SeedE2ECommand extends Command
|
||||
'sites.bypass_scope',
|
||||
'catalog.categories.view',
|
||||
'catalog.categories.manage',
|
||||
// Commercial — Repertoire clients (M1). Mappe ici sur le
|
||||
// persona "tout" en attendant les vrais roles metier
|
||||
// (bureau/compta/commerciale/usine) seedes par ERP-74.
|
||||
// Miroir de frontend/tests/e2e/_fixtures/personas.ts.
|
||||
'commercial.clients.view',
|
||||
'commercial.clients.manage',
|
||||
'commercial.clients.accounting.view',
|
||||
'commercial.clients.accounting.manage',
|
||||
'commercial.clients.archive',
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Contrat d'export d'une feuille de calcul tabulaire vers un binaire XLSX.
|
||||
*
|
||||
* Service GENERIQUE et reutilisable : il ne connait aucune entite metier. Le
|
||||
* module appelant decide QUOI exporter (en-tetes + lignes deja mappees) ; cette
|
||||
* interface decrit seulement COMMENT produire le fichier. Aucun module n'est
|
||||
* couple a une implementation concrete : on depend de ce contrat (dans Shared),
|
||||
* jamais l'inverse (regle ABSOLUE n°1).
|
||||
*
|
||||
* Implementee par App\Shared\Infrastructure\Export\PhpSpreadsheetExporter (on
|
||||
* ne la reference pas via @see pour ne pas creer un import Domain -> Infra).
|
||||
*/
|
||||
interface SpreadsheetExporterInterface
|
||||
{
|
||||
/**
|
||||
* Genere un classeur XLSX a une feuille et retourne son contenu binaire.
|
||||
*
|
||||
* @param string $sheetTitle titre de l'onglet (assaini / tronque par l'implementation si besoin)
|
||||
* @param list<string> $headers libelles de la ligne d'en-tete (ligne 1)
|
||||
* @param iterable<list<null|scalar>> $rows lignes de donnees ; chaque ligne est une liste de cellules alignee sur $headers
|
||||
*
|
||||
* @return string contenu binaire du fichier XLSX
|
||||
*/
|
||||
public function export(string $sheetTitle, array $headers, iterable $rows): string;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Export;
|
||||
|
||||
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Implementation XLSX du contrat d'export via la librairie PhpSpreadsheet.
|
||||
*
|
||||
* Strictement technique : ecrit la ligne d'en-tete puis les lignes de donnees
|
||||
* dans l'unique feuille du classeur, et retourne le binaire. Aucune logique
|
||||
* metier, aucune reference a une entite d'un module — le mapping des colonnes
|
||||
* est de la responsabilite de l'appelant.
|
||||
*/
|
||||
final class PhpSpreadsheetExporter implements SpreadsheetExporterInterface
|
||||
{
|
||||
// Excel limite le titre d'un onglet a 31 caracteres et interdit certains
|
||||
// caracteres ; on assainit pour ne jamais faire echouer setTitle().
|
||||
private const int MAX_SHEET_TITLE_LENGTH = 31;
|
||||
private const string INVALID_TITLE_CHARS = '*:/\?[]';
|
||||
|
||||
public function export(string $sheetTitle, array $headers, iterable $rows): string
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle($this->sanitizeSheetTitle($sheetTitle));
|
||||
|
||||
// Ligne 1 : en-tete.
|
||||
$sheet->fromArray($headers, null, 'A1');
|
||||
|
||||
// Lignes 2..n : donnees. Iteration manuelle pour supporter un iterable
|
||||
// paresseux (generator) sans tout materialiser en memoire.
|
||||
$rowNumber = 2;
|
||||
foreach ($rows as $row) {
|
||||
$sheet->fromArray($row, null, 'A'.$rowNumber);
|
||||
++$rowNumber;
|
||||
}
|
||||
|
||||
return $this->toBinary($spreadsheet);
|
||||
}
|
||||
|
||||
private function toBinary(Spreadsheet $spreadsheet): string
|
||||
{
|
||||
$writer = new Xlsx($spreadsheet);
|
||||
|
||||
// Le writer ecrit vers un chemin de fichier : on passe par un fichier
|
||||
// temporaire puis on lit son contenu binaire.
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'xlsx_export_');
|
||||
if (false === $tmpFile) {
|
||||
throw new RuntimeException('Impossible de creer un fichier temporaire pour l\'export XLSX.');
|
||||
}
|
||||
|
||||
try {
|
||||
$writer->save($tmpFile);
|
||||
$binary = file_get_contents($tmpFile);
|
||||
if (false === $binary) {
|
||||
throw new RuntimeException('Lecture du fichier XLSX temporaire impossible.');
|
||||
}
|
||||
|
||||
return $binary;
|
||||
} finally {
|
||||
// Libere les references internes de PhpSpreadsheet puis supprime le
|
||||
// fichier temporaire, meme en cas d'exception.
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
@unlink($tmpFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire les caracteres interdits et tronque a 31 caracteres ; renvoie un
|
||||
* titre par defaut si la chaine resultante est vide.
|
||||
*/
|
||||
private function sanitizeSheetTitle(string $title): string
|
||||
{
|
||||
$clean = str_replace(str_split(self::INVALID_TITLE_CHARS), '', $title);
|
||||
$clean = mb_substr($clean, 0, self::MAX_SHEET_TITLE_LENGTH);
|
||||
|
||||
return '' === $clean ? 'Export' : $clean;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user