Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e917c413dc | |||
| 48d1904d03 | |||
| b580bb6576 |
+5
-4
@@ -53,10 +53,11 @@ return [
|
||||
'permission' => 'commercial.clients.view',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.commercial.suppliers',
|
||||
'to' => '/suppliers',
|
||||
'icon' => 'mdi:account-arrow-left-outline',
|
||||
'module' => 'commercial',
|
||||
'label' => 'sidebar.commercial.suppliers',
|
||||
'to' => '/suppliers',
|
||||
'icon' => 'mdi:account-arrow-left-outline',
|
||||
'module' => 'commercial',
|
||||
'permission' => 'commercial.suppliers.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -75,6 +75,15 @@ export const personas: Record<PersonaKey, Persona> = {
|
||||
'commercial.clients.accounting.view',
|
||||
'commercial.clients.accounting.manage',
|
||||
'commercial.clients.archive',
|
||||
// Commercial — Repertoire fournisseurs (M2, ERP-90). Meme logique que
|
||||
// les clients : mappe sur le persona "tout", pas de nouveau persona
|
||||
// (regle ABSOLUE n°7). commercial.suppliers.view n'ajoute pas de lien
|
||||
// dans la section Administration, donc expectedAdminLinks reste inchange.
|
||||
'commercial.suppliers.view',
|
||||
'commercial.suppliers.manage',
|
||||
'commercial.suppliers.accounting.view',
|
||||
'commercial.suppliers.accounting.manage',
|
||||
'commercial.suppliers.archive',
|
||||
],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||
},
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Application\Validator;
|
||||
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Validator metier RG-2.03 (jumeau du ClientInformationCompletenessValidator M1) :
|
||||
* pour un utilisateur portant le role metier Commerciale, TOUS les champs de
|
||||
* l'onglet Information sont obligatoires sur POST comme sur tout PATCH,
|
||||
* independamment des champs reellement envoyes.
|
||||
*
|
||||
* Invoque par le SupplierProcessor des que l'utilisateur courant porte le role
|
||||
* Commerciale (detection du role cote back). Pour les autres roles, ces champs
|
||||
* restent optionnels — le validator n'est pas appele.
|
||||
*
|
||||
* NEW vs Client : ajoute le champ `volumeForecast` (volume previsionnel),
|
||||
* specifique fournisseur.
|
||||
*
|
||||
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, chaque
|
||||
* violation portant son propertyPath (consommable par extractApiViolations,
|
||||
* ERP-101), par coherence avec les violations Symfony rendues par API Platform.
|
||||
*/
|
||||
final class SupplierInformationCompletenessValidator
|
||||
{
|
||||
public function validate(Supplier $supplier): void
|
||||
{
|
||||
// Map champ -> valeur courante de l'onglet Information.
|
||||
$fields = [
|
||||
'description' => $supplier->getDescription(),
|
||||
'competitors' => $supplier->getCompetitors(),
|
||||
'foundedAt' => $supplier->getFoundedAt(),
|
||||
'employeesCount' => $supplier->getEmployeesCount(),
|
||||
'revenueAmount' => $supplier->getRevenueAmount(),
|
||||
'directorName' => $supplier->getDirectorName(),
|
||||
'profitAmount' => $supplier->getProfitAmount(),
|
||||
'volumeForecast' => $supplier->getVolumeForecast(),
|
||||
];
|
||||
|
||||
$violations = new ConstraintViolationList();
|
||||
|
||||
foreach ($fields as $property => $value) {
|
||||
if ($this->isMissing($value)) {
|
||||
$violations->add(new ConstraintViolation(
|
||||
sprintf('Ce champ est obligatoire pour le rôle Commerciale (champ "%s").', $property),
|
||||
null,
|
||||
[],
|
||||
$supplier,
|
||||
$property,
|
||||
$value,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (count($violations) > 0) {
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les
|
||||
* zeros numeriques (employeesCount = 0, profitAmount = "0.00",
|
||||
* volumeForecast = 0) sont des valeurs valides : on ne les considere pas
|
||||
* manquants.
|
||||
*/
|
||||
private function isMissing(mixed $value): bool
|
||||
{
|
||||
if (null === $value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_string($value) && '' === trim($value);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,11 @@ final class CommercialModule
|
||||
['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'],
|
||||
['code' => 'commercial.suppliers.view', 'label' => 'Voir les fournisseurs'],
|
||||
['code' => 'commercial.suppliers.manage', 'label' => 'Créer / modifier les fournisseurs (hors onglet Comptabilité)'],
|
||||
['code' => 'commercial.suppliers.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un fournisseur'],
|
||||
['code' => 'commercial.suppliers.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un fournisseur'],
|
||||
['code' => 'commercial.suppliers.archive', 'label' => 'Archiver / restaurer un fournisseur'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
|
||||
normalizationContext: ['groups' => ['bank:read']],
|
||||
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC.
|
||||
order: ['position' => 'ASC', 'label' => 'ASC'],
|
||||
@@ -33,11 +33,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
paginationClientEnabled: true,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
|
||||
normalizationContext: ['groups' => ['bank:read']],
|
||||
),
|
||||
],
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineBankRepository::class)]
|
||||
#[ORM\Table(name: 'bank')]
|
||||
|
||||
@@ -25,7 +25,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
|
||||
normalizationContext: ['groups' => ['payment_delay:read']],
|
||||
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC.
|
||||
order: ['position' => 'ASC', 'label' => 'ASC'],
|
||||
@@ -33,11 +33,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
paginationClientEnabled: true,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
|
||||
normalizationContext: ['groups' => ['payment_delay:read']],
|
||||
),
|
||||
],
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrinePaymentDelayRepository::class)]
|
||||
#[ORM\Table(name: 'payment_delay')]
|
||||
|
||||
@@ -28,7 +28,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
|
||||
normalizationContext: ['groups' => ['payment_type:read']],
|
||||
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC.
|
||||
order: ['position' => 'ASC', 'label' => 'ASC'],
|
||||
@@ -36,11 +36,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
paginationClientEnabled: true,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
|
||||
normalizationContext: ['groups' => ['payment_type:read']],
|
||||
),
|
||||
],
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrinePaymentTypeRepository::class)]
|
||||
#[ORM\Table(name: 'payment_type')]
|
||||
|
||||
@@ -25,6 +25,7 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* Fournisseur (M2 Commercial) — entite racine du repertoire fournisseurs,
|
||||
@@ -133,6 +134,20 @@ class Supplier implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
/**
|
||||
* RG-2.10 : seules les categories de ce type sont autorisees sur le
|
||||
* fournisseur (entite principale). Miroir de SupplierAddress (ERP-88).
|
||||
* S'appuie sur CategoryInterface::getCategoryTypeCode() (pas d'import du
|
||||
* module Catalog — regle ABSOLUE n°1).
|
||||
*/
|
||||
private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
|
||||
|
||||
/** RG-2.07 : code du type de reglement imposant une banque. */
|
||||
private const string PAYMENT_TYPE_VIREMENT = 'VIREMENT';
|
||||
|
||||
/** RG-2.08 : code du type de reglement imposant au moins un RIB. */
|
||||
private const string PAYMENT_TYPE_LCR = 'LCR';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
@@ -280,6 +295,65 @@ class Supplier implements TimestampableInterface, BlamableInterface
|
||||
$this->ribs = new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-2.10 : toute categorie posee sur le fournisseur doit etre de type
|
||||
* FOURNISSEUR -> sinon 422 avec violation sur le champ `categories`
|
||||
* (propertyPath aligne ERP-101, message FR ERP-107). Miroir de
|
||||
* SupplierAddress::validateCategoryType (ERP-88). S'appuie sur
|
||||
* CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog —
|
||||
* regle ABSOLUE n°1). Joue avant la base via la validation API Platform, sur
|
||||
* POST (categories ∈ supplier:write:main) comme sur PATCH.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateCategoryType(ExecutionContextInterface $context): void
|
||||
{
|
||||
foreach ($this->categories as $category) {
|
||||
if ($category instanceof CategoryInterface
|
||||
&& self::REQUIRED_CATEGORY_TYPE_CODE !== $category->getCategoryTypeCode()) {
|
||||
$context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).')
|
||||
->atPath('categories')
|
||||
->addViolation()
|
||||
;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-2.07 / RG-2.08 : coherence du type de reglement comptable. Decision
|
||||
* figee ERP-89 : ces RG inter-champs passent par une contrainte d'entite
|
||||
* (Assert\Callback + ->atPath()) et NON par le SupplierProcessor, afin que
|
||||
* chaque 422 porte un propertyPath exploitable par extractApiViolations
|
||||
* (mapping inline sous le champ, pas un toast — convention ERP-101).
|
||||
* - RG-2.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`.
|
||||
* - RG-2.08 : paymentType = LCR impose au moins un RIB -> violation sur `ribs`
|
||||
* (le 409 sur DELETE du dernier RIB en LCR est porte par ERP-88).
|
||||
*
|
||||
* Ces champs vivant dans le groupe d'ecriture comptable (absent du POST, qui
|
||||
* n'expose que supplier:write:main), la contrainte ne mord en pratique que
|
||||
* sur le PATCH de l'onglet Comptabilite.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validatePaymentTypeConsistency(ExecutionContextInterface $context): void
|
||||
{
|
||||
$paymentCode = $this->paymentType?->getCode();
|
||||
|
||||
if (self::PAYMENT_TYPE_VIREMENT === $paymentCode && null === $this->bank) {
|
||||
$context->buildViolation('La banque est obligatoire pour le type de règlement Virement.')
|
||||
->atPath('bank')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) {
|
||||
$context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.')
|
||||
->atPath('ribs')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
|
||||
@@ -17,7 +17,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
* re-seede en dev/test par CommercialReferentialFixtures.
|
||||
*
|
||||
* Lecture seule au M1 (HP-M2-2) : seules GetCollection et Get sont exposees
|
||||
* (ERP-56), sous la permission commercial.clients.view ; aucune ecriture
|
||||
* (ERP-56), sous la permission commercial.clients.view (elargie aux roles
|
||||
* fournisseurs au M2 via commercial.suppliers.view, ERP-90) ; aucune ecriture
|
||||
* declaree -> POST/PATCH/DELETE renvoient 405.
|
||||
*
|
||||
* Referentiel statique : pas de Timestampable/Blamable (whiteliste dans
|
||||
@@ -28,7 +29,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.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.
|
||||
@@ -39,11 +40,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
paginationClientEnabled: true,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
|
||||
normalizationContext: ['groups' => ['tva_mode:read']],
|
||||
),
|
||||
],
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineTvaModeRepository::class)]
|
||||
#[ORM\Table(name: 'tva_mode')]
|
||||
|
||||
@@ -65,4 +65,16 @@ interface SupplierRepositoryInterface
|
||||
* @param list<Supplier> $suppliers
|
||||
*/
|
||||
public function hydrateListCollections(array $suppliers): void;
|
||||
|
||||
/**
|
||||
* Hydrate en lot la collection `contacts` sur un jeu de fournisseurs DEJA
|
||||
* charges (memes instances via l'identity map). Reservee a l'export XLSX
|
||||
* (§ 4.6) qui a besoin du contact principal : la LISTE paginee n'embarque
|
||||
* pas les contacts (§ 2.12), d'ou une methode dediee plutot qu'une passe
|
||||
* supplementaire dans {@see self::hydrateListCollections()} — on n'impose pas
|
||||
* le cout du chargement des contacts au chemin liste.
|
||||
*
|
||||
* @param list<Supplier> $suppliers
|
||||
*/
|
||||
public function hydrateContacts(array $suppliers): void;
|
||||
}
|
||||
|
||||
+50
-7
@@ -7,7 +7,10 @@ namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Commercial\Application\Service\SupplierFieldNormalizer;
|
||||
use App\Module\Commercial\Application\Validator\SupplierInformationCompletenessValidator;
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
|
||||
use App\Shared\Domain\Security\BusinessRoles;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -40,14 +43,19 @@ use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
* collisions d'unicite en 409 (RG-2.11 doublon de nom ; RG-2.15 conflit de
|
||||
* restauration).
|
||||
*
|
||||
* Hors perimetre ERP-87 (ticket #5 « Validators ») : RG-2.03 (completude
|
||||
* Information pour la Commerciale), RG-2.07 (Virement -> banque), RG-2.08 (LCR ->
|
||||
* RIB), RG-2.10 (categorie de type FOURNISSEUR). Ces regles metier seront
|
||||
* branchees ici via des validators dedies au ticket suivant.
|
||||
* Validators metier (ERP-89). Decision figee : ce processor ne porte QUE
|
||||
* RG-2.03 (completude Information exigee pour le role Commerciale — detection du
|
||||
* role cote back, non exprimable en contrainte d'entite). Les RG inter-champs
|
||||
* RG-2.07 (Virement -> banque), RG-2.08 (LCR -> >= 1 RIB) et RG-2.10 (categorie
|
||||
* de type FOURNISSEUR) sont portees par des Assert\Callback + ->atPath() sur
|
||||
* l'entite Supplier (jouees par API Platform AVANT ce processor), pour que
|
||||
* chaque 422 porte un propertyPath consommable par extractApiViolations
|
||||
* (mapping inline, pas un toast — convention ERP-101).
|
||||
*
|
||||
* Note : la validation Symfony (Assert\NotBlank, Assert\Count sur categories...)
|
||||
* est jouee par API Platform AVANT ce processor ; on n'y traite donc que les
|
||||
* regles non exprimables en simples contraintes d'attribut.
|
||||
* Note : la validation Symfony (Assert\NotBlank, Assert\Count sur categories,
|
||||
* les Callback RG-2.07/2.08/2.10...) est jouee par API Platform AVANT ce
|
||||
* processor ; on n'y traite donc que les regles non exprimables en simples
|
||||
* contraintes d'entite (RG-2.03, qui depend du role de l'utilisateur courant).
|
||||
*
|
||||
* @implements ProcessorInterface<Supplier, Supplier>
|
||||
*/
|
||||
@@ -94,6 +102,7 @@ final class SupplierProcessor implements ProcessorInterface
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
private readonly SupplierFieldNormalizer $normalizer,
|
||||
private readonly SupplierInformationCompletenessValidator $informationValidator,
|
||||
private readonly Security $security,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly EntityManagerInterface $em,
|
||||
@@ -117,6 +126,8 @@ final class SupplierProcessor implements ProcessorInterface
|
||||
// normalisees des deux cotes (l'etat persiste l'a deja ete).
|
||||
$this->guardManage($data);
|
||||
|
||||
$this->validateInformationCompleteness($data);
|
||||
|
||||
try {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
@@ -244,6 +255,38 @@ final class SupplierProcessor implements ProcessorInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-2.03 : si l'utilisateur porte le role metier Commerciale, TOUS les
|
||||
* champs de l'onglet Information sont obligatoires sur POST comme sur TOUT
|
||||
* PATCH — independamment des champs reellement envoyes. Garantit qu'un
|
||||
* fournisseur cree/edite par une Commerciale ne reste jamais avec un onglet
|
||||
* Information incomplet. Pour les autres roles, ces champs restent optionnels.
|
||||
*
|
||||
* Consequence (cf. spec § 7, miroir RG-1.04) : le POST n'exposant que
|
||||
* supplier:write:main, une Commerciale obtient 422 sur tout POST tant que
|
||||
* l'Information n'est pas complete -> la completude se fait via les PATCH
|
||||
* supplier:write:information.
|
||||
*/
|
||||
private function validateInformationCompleteness(Supplier $data): void
|
||||
{
|
||||
if ($this->currentUserIsCommerciale()) {
|
||||
$this->informationValidator->validate($data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detection du role metier Commerciale cote back (jamais front), via le
|
||||
* contrat BusinessRoleAwareInterface (pas d'import de User — regle ABSOLUE
|
||||
* n°1). Identique au ClientProcessor (M1).
|
||||
*/
|
||||
private function currentUserIsCommerciale(): bool
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
|
||||
return $user instanceof BusinessRoleAwareInterface
|
||||
&& $user->hasBusinessRole(BusinessRoles::COMMERCIALE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Champs « metier » (onglets principal + Information, hors comptabilite et
|
||||
* archivage) dont la valeur courante differe de l'etat persiste. Memes
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
use App\Module\Commercial\Domain\Entity\SupplierContact;
|
||||
use App\Module\Commercial\Domain\Repository\SupplierRepositoryInterface;
|
||||
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 fournisseurs (M2, spec-back § 4.6). Jumeau du
|
||||
* {@see ClientExportController} (M1).
|
||||
*
|
||||
* 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/suppliers/export.xlsx` comme l'item `GET /api/suppliers/{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 fournisseurs (memes filtres que
|
||||
* `GET /api/suppliers`, via {@see SupplierRepositoryInterface::createListQueryBuilder()})
|
||||
* et mapping metier des colonnes.
|
||||
*
|
||||
* Colonnes de contact : depuis la suppression du contact inline (ERP-106), elles
|
||||
* sont alimentees par le CONTACT PRINCIPAL du fournisseur — le SupplierContact de
|
||||
* plus petit `position` (decision D2, spec § 4.6).
|
||||
*
|
||||
* La colonne SIREN n'est ajoutee que si l'utilisateur a la permission
|
||||
* `commercial.suppliers.accounting.view` (gating identique a la lecture).
|
||||
*/
|
||||
#[AsController]
|
||||
final class SupplierExportController
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineSupplierRepository')]
|
||||
private readonly SupplierRepositoryInterface $repository,
|
||||
private readonly SpreadsheetExporterInterface $exporter,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
#[Route('/api/suppliers/export.xlsx', name: 'commercial_suppliers_export_xlsx', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('commercial.suppliers.view')]
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$includeArchived = $this->readBool($request->query->get('includeArchived'));
|
||||
$archivedOnly = $this->readBool($request->query->get('archivedOnly'));
|
||||
$search = $request->query->getString('search') ?: null;
|
||||
|
||||
// Memes filtres que la vue liste : categoryCode/siteId tolerent une valeur
|
||||
// unique ou une liste (?categoryCode[]=A&siteId[]=1). On lit via all() pour
|
||||
// ne pas lever d'exception sur une valeur scalaire.
|
||||
$query = $request->query->all();
|
||||
$categoryCodes = $this->readStringList($query['categoryCode'] ?? []);
|
||||
$siteIds = $this->readIntList($query['siteId'] ?? []);
|
||||
|
||||
/** @var list<Supplier> $suppliers */
|
||||
$suppliers = $this->repository
|
||||
->createListQueryBuilder($includeArchived, $search, $categoryCodes, $siteIds, $archivedOnly)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
|
||||
// Hydratation batchee des collections affichees (§ 2.12) : le QB de
|
||||
// selection ne fetch-join pas les to-many. On remplit categories + sites en
|
||||
// lot (colonnes « Catégories » / « Sites »), puis les contacts (colonnes du
|
||||
// contact principal) — chacune en requetes IN bornees, anti N+1.
|
||||
$this->repository->hydrateListCollections($suppliers);
|
||||
$this->repository->hydrateContacts($suppliers);
|
||||
|
||||
$withSiren = $this->security->isGranted('commercial.suppliers.accounting.view');
|
||||
|
||||
$binary = $this->exporter->export(
|
||||
'Répertoire fournisseurs',
|
||||
$this->buildHeaders($withSiren),
|
||||
$this->buildRows($suppliers, $withSiren),
|
||||
);
|
||||
|
||||
return $this->buildResponse($binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes de l'export (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 fournisseur',
|
||||
'Contact principal',
|
||||
'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<Supplier> $suppliers
|
||||
*
|
||||
* @return iterable<list<null|scalar>>
|
||||
*/
|
||||
private function buildRows(array $suppliers, bool $withSiren): iterable
|
||||
{
|
||||
foreach ($suppliers as $supplier) {
|
||||
$contact = $this->principalContact($supplier);
|
||||
|
||||
$row = [
|
||||
$supplier->getCompanyName(),
|
||||
null !== $contact ? $this->formatContactName($contact) : '',
|
||||
$contact?->getPhonePrimary() ?? '',
|
||||
$contact?->getPhoneSecondary() ?? '',
|
||||
$contact?->getEmail() ?? '',
|
||||
$this->formatCategories($supplier),
|
||||
$this->formatSites($supplier),
|
||||
];
|
||||
|
||||
if ($withSiren) {
|
||||
$row[] = $supplier->getSiren();
|
||||
}
|
||||
|
||||
$row[] = $supplier->getCreatedAt()?->format('d/m/Y');
|
||||
|
||||
yield $row;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact principal du fournisseur : le SupplierContact de plus petit
|
||||
* `position` (decision D2, spec § 4.6). Null si le fournisseur n'a aucun
|
||||
* contact (les colonnes contact restent vides).
|
||||
*/
|
||||
private function principalContact(Supplier $supplier): ?SupplierContact
|
||||
{
|
||||
$contacts = $supplier->getContacts()->toArray();
|
||||
if ([] === $contacts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort(
|
||||
$contacts,
|
||||
static fn (SupplierContact $a, SupplierContact $b): int => $a->getPosition() <=> $b->getPosition(),
|
||||
);
|
||||
|
||||
return $contacts[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelle du contact principal « Nom Prénom » (spec § 4.6). Les deux parties
|
||||
* sont optionnelles (RG-2.04 : au moins l'une des deux), d'ou le trim final.
|
||||
*/
|
||||
private function formatContactName(SupplierContact $contact): string
|
||||
{
|
||||
return trim(sprintf('%s %s', $contact->getLastName() ?? '', $contact->getFirstName() ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelles des categories du fournisseur, dedupliques, tries, joints par
|
||||
* virgule.
|
||||
*/
|
||||
private function formatCategories(Supplier $supplier): string
|
||||
{
|
||||
$names = [];
|
||||
foreach ($supplier->getCategories() as $category) {
|
||||
// @var CategoryInterface $category
|
||||
$name = $category->getName();
|
||||
if (null !== $name && '' !== $name) {
|
||||
$names[$name] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->joinSorted($names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le fournisseur ne porte pas de sites en propre : ils sont rattaches aux
|
||||
* adresses (RG-2.06). La colonne « Sites » agrege donc l'union distincte des
|
||||
* sites de toutes les adresses du fournisseur.
|
||||
*/
|
||||
private function formatSites(Supplier $supplier): string
|
||||
{
|
||||
$names = [];
|
||||
foreach ($supplier->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-fournisseurs-%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 SupplierProvider pour un comportement identique a la liste.
|
||||
*/
|
||||
private function readBool(mixed $raw): bool
|
||||
{
|
||||
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste de chaines (valeur unique ou liste).
|
||||
* Aligne sur SupplierProvider pour un comportement identique a la liste.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function readStringList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_string($value) && '' !== trim($value)) {
|
||||
$out[] = trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste d'identifiants entiers positifs (valeur unique
|
||||
* ou liste). Aligne sur SupplierProvider.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function readIntList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
|
||||
$out[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,31 @@ class DoctrineSupplierRepository extends ServiceEntityRepository implements Supp
|
||||
;
|
||||
}
|
||||
|
||||
public function hydrateContacts(array $suppliers): void
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($suppliers as $supplier) {
|
||||
$id = $supplier->getId();
|
||||
if (null !== $id) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
if ([] === $ids) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Une seule requete IN bornee : remplit la collection `contacts` des MEMES
|
||||
// instances Supplier (identity map). Tri par position pour que le « contact
|
||||
// principal » (plus petit position) soit deterministe a l'export (§ 4.6).
|
||||
$this->createQueryBuilder('s')
|
||||
->leftJoin('s.contacts', 'sc')->addSelect('sc')
|
||||
->where('s.id IN (:ids)')->setParameter('ids', $ids)
|
||||
->orderBy('sc.position', 'ASC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche fuzzy insensible a la casse sur companyName ET sur les contacts
|
||||
* lies (firstName / lastName / email) — decision D1, refonte-contact (§ 4.1).
|
||||
|
||||
@@ -51,8 +51,9 @@ final class RbacSeeder
|
||||
* Definition unique des 4 roles + matrice § 2.7. La cle est le code du role,
|
||||
* `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
|
||||
* attacher (vide pour usine : aucun acces ; admin n'apparait pas car il
|
||||
* bypass tout via isAdmin ; `commercial.clients.archive` n'est attache a
|
||||
* aucun role metier — admin seul).
|
||||
* bypass tout via isAdmin ; `commercial.clients.archive` et
|
||||
* `commercial.suppliers.archive` ne sont attaches a aucun role metier —
|
||||
* admin seul).
|
||||
*
|
||||
* @var array<string, array{label: string, permissions: list<string>}>
|
||||
*/
|
||||
@@ -62,6 +63,9 @@ final class RbacSeeder
|
||||
'permissions' => [
|
||||
'commercial.clients.view',
|
||||
'commercial.clients.manage',
|
||||
// Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite).
|
||||
'commercial.suppliers.view',
|
||||
'commercial.suppliers.manage',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
'catalog.categories.read_ref',
|
||||
'sites.read_ref',
|
||||
@@ -73,6 +77,11 @@ final class RbacSeeder
|
||||
'commercial.clients.view',
|
||||
'commercial.clients.accounting.view',
|
||||
'commercial.clients.accounting.manage',
|
||||
// Fournisseurs (M2 § 2.9, ERP-90) : view + onglet Comptabilite uniquement
|
||||
// (pas de manage global -> ne peut pas creer un fournisseur).
|
||||
'commercial.suppliers.view',
|
||||
'commercial.suppliers.accounting.view',
|
||||
'commercial.suppliers.accounting.manage',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
'catalog.categories.read_ref',
|
||||
'sites.read_ref',
|
||||
@@ -83,6 +92,10 @@ final class RbacSeeder
|
||||
'permissions' => [
|
||||
'commercial.clients.view',
|
||||
'commercial.clients.manage',
|
||||
// Fournisseurs (M2 § 2.9, ERP-90) : view + manage, sans accounting
|
||||
// (onglet Comptabilite masque/filtre pour la Commerciale).
|
||||
'commercial.suppliers.view',
|
||||
'commercial.suppliers.manage',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
'catalog.categories.read_ref',
|
||||
'sites.read_ref',
|
||||
|
||||
@@ -195,6 +195,14 @@ final class SeedE2ECommand extends Command
|
||||
'commercial.clients.accounting.view',
|
||||
'commercial.clients.accounting.manage',
|
||||
'commercial.clients.archive',
|
||||
// Commercial — Repertoire fournisseurs (M2, ERP-90). Meme
|
||||
// logique que les clients : mappe sur le persona "tout".
|
||||
// Miroir de frontend/tests/e2e/_fixtures/personas.ts.
|
||||
'commercial.suppliers.view',
|
||||
'commercial.suppliers.manage',
|
||||
'commercial.suppliers.accounting.view',
|
||||
'commercial.suppliers.accounting.manage',
|
||||
'commercial.suppliers.archive',
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -256,6 +256,95 @@ final class ColumnCommentsCatalog
|
||||
'iban' => 'IBAN du compte (≤ 34 caracteres).',
|
||||
'position' => 'Ordre d affichage du RIB dans la liste du client (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
// === M2 Commercial (ERP-85) — miroir des COMMENT de la migration
|
||||
// Version20260605130000 pour le chemin schema:update (dev/test). ===
|
||||
|
||||
'supplier' => [
|
||||
'_table' => 'Repertoire fournisseurs (M2 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M3).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'company_name' => 'Raison sociale du fournisseur (stockee en MAJUSCULES). Unique case-insensitive parmi les actifs non archives/non supprimes (uq_supplier_company_name_active, § 2.6).',
|
||||
'description' => 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-2.03), optionnel sinon.',
|
||||
'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-2.03).',
|
||||
'founded_at' => 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-2.03).',
|
||||
'employees_count' => 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-2.03).',
|
||||
'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-2.03).',
|
||||
'director_name' => 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-2.03).',
|
||||
'profit_amount' => 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-2.03).',
|
||||
'volume_forecast' => 'Onglet Information : volume previsionnel (entier >= 0) — specifique fournisseur. Obligatoire role Commerciale (RG-2.03).',
|
||||
'siren' => 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (§ 2.6).',
|
||||
'account_number' => 'Onglet Comptabilite : numero de compte comptable du fournisseur.',
|
||||
'tva_mode_id' => 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id (referentiel partage M1), ON DELETE RESTRICT.',
|
||||
'n_tva' => 'Onglet Comptabilite : numero de TVA intracommunautaire.',
|
||||
'payment_delay_id' => 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id (M1), ON DELETE RESTRICT.',
|
||||
'payment_type_id' => 'Onglet Comptabilite : type de reglement — FK -> payment_type.id (M1), ON DELETE RESTRICT. Pilote RG-2.07 (Banque si VIREMENT) et RG-2.08 (RIB).',
|
||||
'bank_id' => 'Onglet Comptabilite : banque — FK -> bank.id (M1), ON DELETE RESTRICT. Obligatoire ssi payment_type = VIREMENT (RG-2.07), null sinon.',
|
||||
'is_archived' => 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.suppliers.archive.',
|
||||
'archived_at' => 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.',
|
||||
'deleted_at' => 'Horodatage du soft-delete technique (HP M3) — non expose par l API au M2. Null = ligne active.',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'supplier_category' => [
|
||||
'_table' => 'Jointure M2M supplier <-> category (Catalog) — categories de type FOURNISSEUR du fournisseur, au moins une obligatoire (RG-2.10).',
|
||||
'supplier_id' => 'FK -> supplier.id, ON DELETE CASCADE — fournisseur porteur de la categorie.',
|
||||
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie de type FOURNISSEUR rattachee au fournisseur (RG-2.10).',
|
||||
],
|
||||
|
||||
'supplier_contact' => [
|
||||
'_table' => 'Contacts d un fournisseur (1:n) — au moins firstName OU lastName par contact (RG-2.04).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'supplier_id' => 'FK -> supplier.id, ON DELETE CASCADE — fournisseur proprietaire du contact.',
|
||||
'first_name' => 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-2.04, chk_supplier_contact_name).',
|
||||
'last_name' => 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-2.04, chk_supplier_contact_name).',
|
||||
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
|
||||
'phone_primary' => 'Telephone principal du contact — chiffres uniquement (normalisation serveur).',
|
||||
'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).',
|
||||
'email' => 'Email du contact (lowercase serveur).',
|
||||
'position' => 'Ordre d affichage du contact dans la liste du fournisseur (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'supplier_address' => [
|
||||
'_table' => 'Adresses d un fournisseur (1:n) — type PROSPECT/DEPART/RENDU exclusif (RG-2.09), >= 1 site rattache (RG-2.06).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'supplier_id' => 'FK -> supplier.id, ON DELETE CASCADE — fournisseur proprietaire de l adresse.',
|
||||
'address_type' => 'Type d adresse : PROSPECT | DEPART | RENDU (radio exclusif par construction — RG-2.09, chk_supplier_address_type).',
|
||||
'country' => 'Pays de l adresse — defaut France.',
|
||||
'postal_code' => 'Code postal (4-5 chiffres attendus).',
|
||||
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front.',
|
||||
'street' => 'Numero et voie de l adresse.',
|
||||
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
|
||||
'bennes' => 'Nombre de bennes sur le site fournisseur (entier nullable) — specifique fournisseur.',
|
||||
'triage_provider' => 'Le fournisseur est prestataire de triage sur cette adresse. Faux par defaut.',
|
||||
'position' => 'Ordre d affichage de l adresse dans la liste du fournisseur (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'supplier_address_site' => [
|
||||
'_table' => 'Jointure M2M supplier_address <-> site (Sites) — sites rattaches a l adresse (>= 1 obligatoire, RG-2.06).',
|
||||
'supplier_address_id' => 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.',
|
||||
'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.',
|
||||
],
|
||||
|
||||
'supplier_address_contact' => [
|
||||
'_table' => 'Jointure M2M supplier_address <-> supplier_contact — contacts associes a une adresse.',
|
||||
'supplier_address_id' => 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.',
|
||||
'supplier_contact_id' => 'FK -> supplier_contact.id, ON DELETE CASCADE — contact associe a l adresse.',
|
||||
],
|
||||
|
||||
'supplier_address_category' => [
|
||||
'_table' => 'Jointure M2M supplier_address <-> category — categories d adresse de type FOURNISSEUR (RG-2.10).',
|
||||
'supplier_address_id' => 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.',
|
||||
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type FOURNISSEUR (RG-2.10).',
|
||||
],
|
||||
|
||||
'supplier_rib' => [
|
||||
'_table' => 'Coordonnees bancaires d un fournisseur (1:n) — >= 1 RIB attendu selon le type de reglement (RG-2.08). Tous les champs audites (pas d AuditIgnore).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'supplier_id' => 'FK -> supplier.id, ON DELETE CASCADE — fournisseur proprietaire du RIB.',
|
||||
'label' => 'Libelle du RIB (ex: compte principal).',
|
||||
'bic' => 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).',
|
||||
'iban' => 'IBAN du compte (≤ 34 caracteres).',
|
||||
'position' => 'Ordre d affichage du RIB dans la liste du fournisseur (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
use App\Module\Commercial\Domain\Entity\SupplierAddress;
|
||||
use App\Module\Commercial\Domain\Entity\SupplierContact;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use DateTimeImmutable;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'export XLSX du repertoire fournisseurs (M2, § 4.6).
|
||||
* Jumeau du {@see ClientExportControllerTest} (M1).
|
||||
*
|
||||
* Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
|
||||
* archives par defaut, respect du filtre ?search, peuplement des colonnes
|
||||
* contact principal / categories / sites, gating de la colonne SIREN selon
|
||||
* commercial.suppliers.accounting.view, 403 sans commercial.suppliers.view,
|
||||
* 401 anonyme.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
private const string EXPORT_URL = '/api/suppliers/export.xlsx';
|
||||
|
||||
/**
|
||||
* Les fournisseurs doivent etre purges AVANT les categories de test (le parent
|
||||
* supprime les categories `test_cli_cat_*`) : la jointure supplier_category est
|
||||
* en ON DELETE CASCADE cote supplier mais RESTRICT cote category. Le DELETE DQL
|
||||
* sur Supplier declenche le cascade BDD sur supplier_category / _contact /
|
||||
* _address (et leurs sous-jointures), liberant les categories pour le parent.
|
||||
*/
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->getEm()->createQuery('DELETE FROM '.Supplier::class)->execute();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedSupplier('Export Alpha');
|
||||
|
||||
$response = $client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$headers = $response->getHeaders(false);
|
||||
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
|
||||
|
||||
$disposition = $headers['content-disposition'][0] ?? '';
|
||||
self::assertStringContainsString('attachment; filename="repertoire-fournisseurs-', $disposition);
|
||||
self::assertMatchesRegularExpression(
|
||||
'/filename="repertoire-fournisseurs-\d{8}\.xlsx"/',
|
||||
$disposition,
|
||||
);
|
||||
|
||||
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
|
||||
$grid = $this->gridFromResponse($response->getContent());
|
||||
$headers = $grid[0];
|
||||
self::assertSame('Nom fournisseur', $headers[0]);
|
||||
self::assertContains('Contact principal', $headers);
|
||||
self::assertContains('Téléphone principal', $headers);
|
||||
self::assertContains('Téléphone secondaire', $headers);
|
||||
self::assertContains('Email', $headers);
|
||||
self::assertContains('Catégories', $headers);
|
||||
self::assertContains('Sites', $headers);
|
||||
self::assertContains('Date de création', $headers);
|
||||
}
|
||||
|
||||
public function testExportExcludesArchivedByDefault(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedSupplier('Active One');
|
||||
$this->seedSupplier('Archived One', true);
|
||||
|
||||
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains('ACTIVE ONE', $names);
|
||||
self::assertNotContains('ARCHIVED ONE', $names);
|
||||
}
|
||||
|
||||
public function testExportRespectsSearchFilter(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedSupplier('Searchable Alpha');
|
||||
$this->seedSupplier('Other Beta');
|
||||
|
||||
$names = $this->companyNames(
|
||||
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
|
||||
);
|
||||
|
||||
self::assertContains('SEARCHABLE ALPHA', $names);
|
||||
self::assertNotContains('OTHER BETA', $names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les colonnes contact sont alimentees par le CONTACT PRINCIPAL : le contact
|
||||
* de plus petit `position` (decision D2, § 4.6). On seede deux contacts en
|
||||
* ordre de position inverse pour garantir que c'est bien le principal (et non
|
||||
* le premier insere) qui alimente la ligne.
|
||||
*/
|
||||
public function testExportUsesPrincipalContactColumns(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$supplier = $this->seedSupplier('Contact Co');
|
||||
|
||||
// position 1 (secondaire) insere en premier...
|
||||
$this->addContact($supplier, 'Secondaire', 'Bob', 1, '0600000001', '0600000002', 'bob@contact.co');
|
||||
// ...position 0 (principal) insere ensuite : c'est lui qui doit gagner.
|
||||
$this->addContact($supplier, 'Principal', 'Alice', 0, '0612345678', '0698765432', 'alice@contact.co');
|
||||
|
||||
$row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO');
|
||||
|
||||
self::assertNotNull($row, 'Ligne « CONTACT CO » introuvable dans l\'export.');
|
||||
self::assertSame('Principal Alice', $row[1]);
|
||||
self::assertSame('0612345678', $row[2]);
|
||||
self::assertSame('0698765432', $row[3]);
|
||||
self::assertSame('alice@contact.co', $row[4]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes « Catégories » et « Sites » : un oubli d'hydratation les rendrait
|
||||
* vides sans erreur (cf. ERP-100 cote client). Le site est porte par l'adresse
|
||||
* (RG-2.06).
|
||||
*/
|
||||
public function testExportPopulatesCategoryAndSiteColumns(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$supplier = $this->seedSupplier('Hydrate Co', false, 'NEGOCIANT');
|
||||
|
||||
$em = $this->getEm();
|
||||
$site = $em->getRepository(Site::class)->findOneBy([]);
|
||||
self::assertNotNull($site, 'Aucun site seede : impossible de tester la colonne Sites.');
|
||||
|
||||
$address = new SupplierAddress();
|
||||
$address->setSupplier($supplier);
|
||||
$address->setAddressType('DEPART');
|
||||
$address->setPostalCode('86100');
|
||||
$address->setCity('Châtellerault');
|
||||
$address->setStreet('1 rue du Test');
|
||||
$address->addSite($site);
|
||||
$em->persist($address);
|
||||
$em->flush();
|
||||
|
||||
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
|
||||
|
||||
// Colonne « Catégories » : libelle de la categorie du fournisseur (getName()).
|
||||
self::assertStringContainsString('test_cli_cat_negociant', $flat);
|
||||
// Colonne « Sites » : site agrege depuis l'adresse (RG-2.06).
|
||||
self::assertStringContainsString((string) $site->getName(), $flat);
|
||||
}
|
||||
|
||||
public function testSirenColumnPresentWithAccountingView(): void
|
||||
{
|
||||
// L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
|
||||
$client = $this->createAdminClient();
|
||||
$supplier = $this->seedSupplier('Siren Co');
|
||||
$em = $this->getEm();
|
||||
$supplier->setSiren('123456789');
|
||||
$em->flush();
|
||||
|
||||
$grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains('SIREN', $grid[0]);
|
||||
self::assertStringContainsString('123456789', $this->flatten($grid));
|
||||
}
|
||||
|
||||
public function testSirenColumnAbsentWithoutAccountingView(): void
|
||||
{
|
||||
// Seed via admin, puis relecture par un user qui n'a QUE suppliers.view.
|
||||
$admin = $this->createAdminClient();
|
||||
$supplier = $this->seedSupplier('No Siren Co');
|
||||
$em = $this->getEm();
|
||||
$supplier->setSiren('987654321');
|
||||
$em->flush();
|
||||
|
||||
$creds = $this->createUserWithPermission('commercial.suppliers.view');
|
||||
$viewer = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertNotContains('SIREN', $grid[0]);
|
||||
self::assertStringNotContainsString('987654321', $this->flatten($grid));
|
||||
}
|
||||
|
||||
public function testForbiddenWithoutSuppliersViewPermission(): void
|
||||
{
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testUnauthorizedWhenAnonymous(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede directement un Supplier en base (sans passer par l'API), pour les
|
||||
* tests de liste / archivage. Stocke le nom en MAJUSCULES pour refleter l'etat
|
||||
* normalise (RG-2.12) qu'aurait produit le SupplierProcessor via l'API.
|
||||
*/
|
||||
private function seedSupplier(string $companyName, bool $isArchived = false, string $categoryCode = 'SECTEUR'): Supplier
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$supplier = new Supplier();
|
||||
$supplier->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
|
||||
$supplier->addCategory($this->createCategory($categoryCode));
|
||||
$supplier->setIsArchived($isArchived);
|
||||
if ($isArchived) {
|
||||
$supplier->setArchivedAt(new DateTimeImmutable());
|
||||
}
|
||||
$em->persist($supplier);
|
||||
$em->flush();
|
||||
|
||||
return $supplier;
|
||||
}
|
||||
|
||||
private function addContact(
|
||||
Supplier $supplier,
|
||||
string $lastName,
|
||||
string $firstName,
|
||||
int $position,
|
||||
?string $phonePrimary = null,
|
||||
?string $phoneSecondary = null,
|
||||
?string $email = null,
|
||||
): void {
|
||||
$contact = new SupplierContact();
|
||||
$contact->setSupplier($supplier);
|
||||
$contact->setLastName($lastName);
|
||||
$contact->setFirstName($firstName);
|
||||
$contact->setPosition($position);
|
||||
$contact->setPhonePrimary($phonePrimary);
|
||||
$contact->setPhoneSecondary($phoneSecondary);
|
||||
$contact->setEmail($email);
|
||||
|
||||
$supplier->addContact($contact);
|
||||
$this->getEm()->persist($contact);
|
||||
$this->getEm()->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
|
||||
*
|
||||
* @return array<int, array<int, mixed>>
|
||||
*/
|
||||
private function gridFromResponse(string $binary): array
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_');
|
||||
self::assertIsString($tmp);
|
||||
file_put_contents($tmp, $binary);
|
||||
|
||||
try {
|
||||
return IOFactory::load($tmp)->getActiveSheet()->toArray();
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait la colonne « Nom fournisseur » (1re colonne) des lignes de donnees.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function companyNames(string $binary): array
|
||||
{
|
||||
$grid = $this->gridFromResponse($binary);
|
||||
$rows = array_slice($grid, 1); // saute l'en-tete
|
||||
|
||||
return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName.
|
||||
*
|
||||
* @return array<int, mixed>|null
|
||||
*/
|
||||
private function rowFor(string $binary, string $companyName): ?array
|
||||
{
|
||||
foreach (array_slice($this->gridFromResponse($binary), 1) as $row) {
|
||||
if ((string) ($row[0] ?? '') === $companyName) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplatit toute la grille en une chaine, pour les assertions de presence.
|
||||
*
|
||||
* @param array<int, array<int, mixed>> $grid
|
||||
*/
|
||||
private function flatten(array $grid): string
|
||||
{
|
||||
return implode('|', array_map(
|
||||
static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)),
|
||||
$grid,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Domain\Entity;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Bank;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
use App\Module\Commercial\Domain\Entity\SupplierRib;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Validator\Validation;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
/**
|
||||
* Tests des contraintes inter-champs de l'entite Supplier portees par
|
||||
* Assert\Callback (decision figee ERP-89) : RG-2.10 (categorie de type
|
||||
* FOURNISSEUR), RG-2.07 (Virement -> banque), RG-2.08 (LCR -> >= 1 RIB).
|
||||
*
|
||||
* On valide l'entite avec le validator Symfony (mapping par attributs) et on
|
||||
* assert le propertyPath exact de chaque violation (contrat ERP-101 :
|
||||
* exploitable par extractApiViolations). Pas de base : les Callback ne touchent
|
||||
* que des champs en memoire (categories via un double CategoryInterface).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SupplierValidationTest extends TestCase
|
||||
{
|
||||
private ValidatorInterface $validator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->validator = Validation::createValidatorBuilder()
|
||||
->enableAttributeMapping()
|
||||
->getValidator()
|
||||
;
|
||||
}
|
||||
|
||||
// === RG-2.10 : categories de type FOURNISSEUR ===
|
||||
|
||||
public function testFournisseurCategoryIsAccepted(): void
|
||||
{
|
||||
$supplier = $this->validSupplier();
|
||||
|
||||
self::assertSame([], $this->violationPaths($supplier));
|
||||
}
|
||||
|
||||
public function testNonFournisseurCategoryIsRejectedOnCategoriesPath(): void
|
||||
{
|
||||
$supplier = new Supplier();
|
||||
$supplier->setCompanyName('Recycla SAS');
|
||||
$supplier->addCategory($this->category('CLIENT'));
|
||||
|
||||
self::assertContains('categories', $this->violationPaths($supplier));
|
||||
}
|
||||
|
||||
// === RG-2.07 : Virement impose une banque ===
|
||||
|
||||
public function testVirementWithoutBankIsRejectedOnBankPath(): void
|
||||
{
|
||||
$supplier = $this->validSupplier();
|
||||
$supplier->setPaymentType($this->paymentType('VIREMENT'));
|
||||
|
||||
self::assertContains('bank', $this->violationPaths($supplier));
|
||||
}
|
||||
|
||||
public function testVirementWithBankPasses(): void
|
||||
{
|
||||
$supplier = $this->validSupplier();
|
||||
$supplier->setPaymentType($this->paymentType('VIREMENT'));
|
||||
$supplier->setBank(new Bank());
|
||||
|
||||
self::assertNotContains('bank', $this->violationPaths($supplier));
|
||||
}
|
||||
|
||||
// === RG-2.08 : LCR impose au moins un RIB ===
|
||||
|
||||
public function testLcrWithoutRibIsRejectedOnRibsPath(): void
|
||||
{
|
||||
$supplier = $this->validSupplier();
|
||||
$supplier->setPaymentType($this->paymentType('LCR'));
|
||||
|
||||
self::assertContains('ribs', $this->violationPaths($supplier));
|
||||
}
|
||||
|
||||
public function testLcrWithRibPasses(): void
|
||||
{
|
||||
$supplier = $this->validSupplier();
|
||||
$supplier->setPaymentType($this->paymentType('LCR'));
|
||||
$supplier->addRib(new SupplierRib());
|
||||
|
||||
self::assertNotContains('ribs', $this->violationPaths($supplier));
|
||||
}
|
||||
|
||||
public function testNeutralPaymentTypeRequiresNeitherBankNorRib(): void
|
||||
{
|
||||
// Un type de reglement neutre (ni VIREMENT ni LCR) n'exige ni banque ni RIB.
|
||||
$supplier = $this->validSupplier();
|
||||
$supplier->setPaymentType($this->paymentType('CHEQUE'));
|
||||
|
||||
$paths = $this->violationPaths($supplier);
|
||||
self::assertNotContains('bank', $paths);
|
||||
self::assertNotContains('ribs', $paths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fournisseur valide (nom + 1 categorie FOURNISSEUR), sans onglet
|
||||
* Comptabilite renseigne : sert de base aux tests RG-2.07/2.08.
|
||||
*/
|
||||
private function validSupplier(): Supplier
|
||||
{
|
||||
$supplier = new Supplier();
|
||||
$supplier->setCompanyName('Recycla SAS');
|
||||
$supplier->addCategory($this->category('FOURNISSEUR'));
|
||||
|
||||
return $supplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string> propertyPaths des violations levees par le validator
|
||||
*/
|
||||
private function violationPaths(Supplier $supplier): array
|
||||
{
|
||||
$paths = [];
|
||||
foreach ($this->validator->validate($supplier) as $violation) {
|
||||
$paths[] = $violation->getPropertyPath();
|
||||
}
|
||||
|
||||
return $paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Double minimal de CategoryInterface (pas d'acces base) renvoyant le code de
|
||||
* type de categorie voulu — seul element regarde par validateCategoryType.
|
||||
*/
|
||||
private function category(string $typeCode): CategoryInterface
|
||||
{
|
||||
return new class($typeCode) implements CategoryInterface {
|
||||
public function __construct(private readonly string $typeCode) {}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return 'Categorie test';
|
||||
}
|
||||
|
||||
public function getCode(): ?string
|
||||
{
|
||||
return 'TEST';
|
||||
}
|
||||
|
||||
public function getCategoryTypeCode(): ?string
|
||||
{
|
||||
return $this->typeCode;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function paymentType(string $code): PaymentType
|
||||
{
|
||||
$type = new PaymentType();
|
||||
$type->setCode($code);
|
||||
$type->setLabel($code);
|
||||
|
||||
return $type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Unit;
|
||||
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Commercial\Application\Validator\SupplierInformationCompletenessValidator;
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires du SupplierInformationCompletenessValidator (RG-2.03) : pour le
|
||||
* role Commerciale, TOUS les champs de l'onglet Information sont obligatoires.
|
||||
* Chaque champ manquant produit une violation portant son propertyPath (ERP-101).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SupplierInformationCompletenessValidatorTest extends TestCase
|
||||
{
|
||||
public function testCompleteInformationPasses(): void
|
||||
{
|
||||
$supplier = $this->completeSupplier();
|
||||
|
||||
$this->validator()->validate($supplier);
|
||||
|
||||
// Aucune exception levee : la completude est satisfaite.
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function testEmptyInformationListsEveryMissingField(): void
|
||||
{
|
||||
$supplier = new Supplier();
|
||||
$supplier->setCompanyName('Recycla SAS'); // onglet principal, hors Information
|
||||
|
||||
try {
|
||||
$this->validator()->validate($supplier);
|
||||
self::fail('Une ValidationException etait attendue (onglet Information vide).');
|
||||
} catch (ValidationException $e) {
|
||||
$paths = [];
|
||||
foreach ($e->getConstraintViolationList() as $violation) {
|
||||
$paths[] = $violation->getPropertyPath();
|
||||
}
|
||||
|
||||
// Les 8 champs Information (dont volumeForecast, NEW vs Client) sont
|
||||
// tous signales d'un coup, chacun sous son propre propertyPath.
|
||||
sort($paths);
|
||||
self::assertSame([
|
||||
'competitors',
|
||||
'description',
|
||||
'directorName',
|
||||
'employeesCount',
|
||||
'foundedAt',
|
||||
'profitAmount',
|
||||
'revenueAmount',
|
||||
'volumeForecast',
|
||||
], $paths);
|
||||
}
|
||||
}
|
||||
|
||||
public function testPartialInformationReportsOnlyMissingFields(): void
|
||||
{
|
||||
$supplier = $this->completeSupplier();
|
||||
$supplier->setDirectorName(null);
|
||||
$supplier->setVolumeForecast(null);
|
||||
|
||||
try {
|
||||
$this->validator()->validate($supplier);
|
||||
self::fail('Une ValidationException etait attendue (2 champs manquants).');
|
||||
} catch (ValidationException $e) {
|
||||
$paths = [];
|
||||
foreach ($e->getConstraintViolationList() as $violation) {
|
||||
$paths[] = $violation->getPropertyPath();
|
||||
}
|
||||
|
||||
sort($paths);
|
||||
self::assertSame(['directorName', 'volumeForecast'], $paths);
|
||||
}
|
||||
}
|
||||
|
||||
public function testZeroNumericValuesAreNotMissing(): void
|
||||
{
|
||||
// employeesCount = 0, profitAmount = "0.00", volumeForecast = 0 sont des
|
||||
// valeurs valides (un zero n'est pas une absence) -> pas de violation.
|
||||
$supplier = $this->completeSupplier();
|
||||
$supplier->setEmployeesCount(0);
|
||||
$supplier->setProfitAmount('0.00');
|
||||
$supplier->setVolumeForecast(0);
|
||||
|
||||
$this->validator()->validate($supplier);
|
||||
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function testBlankStringIsMissing(): void
|
||||
{
|
||||
// Une chaine vide apres trim compte comme manquante.
|
||||
$supplier = $this->completeSupplier();
|
||||
$supplier->setDescription(' ');
|
||||
|
||||
$this->expectException(ValidationException::class);
|
||||
$this->validator()->validate($supplier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fournisseur dont l'onglet Information est entierement renseigne.
|
||||
*/
|
||||
private function completeSupplier(): Supplier
|
||||
{
|
||||
$supplier = new Supplier();
|
||||
$supplier->setCompanyName('Recycla SAS');
|
||||
$supplier->setDescription('Specialiste du recyclage');
|
||||
$supplier->setCompetitors('Concurrent A, Concurrent B');
|
||||
$supplier->setFoundedAt(new DateTimeImmutable('2010-01-01'));
|
||||
$supplier->setEmployeesCount(42);
|
||||
$supplier->setRevenueAmount('1000000.00');
|
||||
$supplier->setDirectorName('Marie Durand');
|
||||
$supplier->setProfitAmount('150000.00');
|
||||
$supplier->setVolumeForecast(5000);
|
||||
|
||||
return $supplier;
|
||||
}
|
||||
|
||||
private function validator(): SupplierInformationCompletenessValidator
|
||||
{
|
||||
return new SupplierInformationCompletenessValidator();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Unit;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Commercial\Application\Service\SupplierFieldNormalizer;
|
||||
use App\Module\Commercial\Application\Validator\SupplierInformationCompletenessValidator;
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\SupplierProcessor;
|
||||
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
|
||||
use App\Shared\Domain\Security\BusinessRoles;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\UnitOfWork;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Tests unitaires du SupplierProcessor — perimetre ERP-89 : detection du role
|
||||
* Commerciale cote back (RG-2.03). Les autres responsabilites du processor
|
||||
* (gating accounting / archive / mode strict) sont heritees d'ERP-87 et testees
|
||||
* a leur niveau ; les RG inter-champs (RG-2.07/2.08/2.10) sont des contraintes
|
||||
* d'entite (cf. SupplierValidationTest), non portees ici.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SupplierProcessorTest extends TestCase
|
||||
{
|
||||
public function testCommercialeIncompleteInformationIsUnprocessable(): void
|
||||
{
|
||||
// RG-2.03 : role Commerciale + onglet Information incomplet -> 422, meme
|
||||
// sur un POST (les champs Information n'y sont pas renseignables).
|
||||
$supplier = $this->minimalSupplier();
|
||||
$supplier->setDescription('Une description'); // les autres champs Information restent null
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
payload: ['description' => 'Une description'],
|
||||
user: $this->commercialeUser(),
|
||||
);
|
||||
|
||||
$this->expectException(ValidationException::class);
|
||||
$processor->process($supplier, $this->operation());
|
||||
}
|
||||
|
||||
public function testCommercialeIncompleteInformationOnMainOnlyPatchIsUnprocessable(): void
|
||||
{
|
||||
// RG-2.03 : pour une Commerciale, la completude Information est exigee
|
||||
// meme quand le payload ne touche PAS l'onglet Information (ici
|
||||
// companyName seul) -> 422.
|
||||
$supplier = $this->minimalSupplier();
|
||||
$supplier->setCompanyName('Renamed Co');
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: ['commercial.suppliers.manage'],
|
||||
payload: ['companyName' => 'Renamed Co'],
|
||||
user: $this->commercialeUser(),
|
||||
managed: true,
|
||||
originalData: [
|
||||
'companyName' => 'TEST CO',
|
||||
'isArchived' => false,
|
||||
],
|
||||
);
|
||||
|
||||
$this->expectException(ValidationException::class);
|
||||
$processor->process($supplier, $this->operation());
|
||||
}
|
||||
|
||||
public function testCommercialeCompleteInformationPasses(): void
|
||||
{
|
||||
// RG-2.03 satisfaite : tous les champs Information renseignes -> 200.
|
||||
$supplier = $this->completeInformationSupplier();
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: ['commercial.suppliers.manage'],
|
||||
payload: ['description' => 'desc'],
|
||||
user: $this->commercialeUser(),
|
||||
);
|
||||
|
||||
self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation()));
|
||||
}
|
||||
|
||||
public function testNonCommercialeSkipsInformationCompleteness(): void
|
||||
{
|
||||
// Meme onglet Information incomplet, mais user non-Commerciale -> aucun
|
||||
// blocage (la completude est specifique a la Commerciale).
|
||||
$supplier = $this->minimalSupplier();
|
||||
$supplier->setDescription('Une description');
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
payload: ['description' => 'Une description'],
|
||||
user: null,
|
||||
);
|
||||
|
||||
self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $granted Permissions accordees a l'utilisateur courant
|
||||
* @param array<string, mixed> $payload Corps JSON simule de la requete
|
||||
* @param bool $managed true = entite geree par l'ORM (PATCH), false = creation (POST)
|
||||
* @param array<string, mixed> $originalData Etat persiste simule (getOriginalEntityData)
|
||||
*/
|
||||
private function makeProcessor(
|
||||
array $granted = [],
|
||||
array $payload = [],
|
||||
?UserInterface $user = null,
|
||||
bool $managed = false,
|
||||
array $originalData = [],
|
||||
): SupplierProcessor {
|
||||
$persist = new class implements ProcessorInterface {
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
return $data;
|
||||
}
|
||||
};
|
||||
|
||||
$security = $this->createStub(Security::class);
|
||||
$security->method('isGranted')->willReturnCallback(
|
||||
static fn (mixed $attribute): bool => is_string($attribute) && in_array($attribute, $granted, true),
|
||||
);
|
||||
$security->method('getUser')->willReturn($user);
|
||||
|
||||
$requestStack = new RequestStack();
|
||||
$requestStack->push(new Request([], [], [], [], [], [], json_encode($payload, JSON_THROW_ON_ERROR)));
|
||||
|
||||
$uow = $this->createMock(UnitOfWork::class);
|
||||
$uow->method('getOriginalEntityData')->willReturn($originalData);
|
||||
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$em->method('contains')->willReturn($managed);
|
||||
$em->method('getUnitOfWork')->willReturn($uow);
|
||||
|
||||
return new SupplierProcessor(
|
||||
$persist,
|
||||
new SupplierFieldNormalizer(),
|
||||
new SupplierInformationCompletenessValidator(),
|
||||
$security,
|
||||
$requestStack,
|
||||
$em,
|
||||
);
|
||||
}
|
||||
|
||||
private function minimalSupplier(): Supplier
|
||||
{
|
||||
$supplier = new Supplier();
|
||||
$supplier->setCompanyName('Test Co');
|
||||
|
||||
return $supplier;
|
||||
}
|
||||
|
||||
private function completeInformationSupplier(): Supplier
|
||||
{
|
||||
$supplier = $this->minimalSupplier();
|
||||
$supplier->setDescription('desc');
|
||||
$supplier->setCompetitors('concurrents');
|
||||
$supplier->setFoundedAt(new DateTimeImmutable('2010-01-01'));
|
||||
$supplier->setEmployeesCount(10);
|
||||
$supplier->setRevenueAmount('1000.00');
|
||||
$supplier->setDirectorName('Marie Durand');
|
||||
$supplier->setProfitAmount('100.00');
|
||||
$supplier->setVolumeForecast(500);
|
||||
|
||||
return $supplier;
|
||||
}
|
||||
|
||||
private function operation(): Operation
|
||||
{
|
||||
return $this->createStub(Operation::class);
|
||||
}
|
||||
|
||||
private function commercialeUser(): UserInterface
|
||||
{
|
||||
return new class implements UserInterface, BusinessRoleAwareInterface {
|
||||
public function hasBusinessRole(string $roleCode): bool
|
||||
{
|
||||
return BusinessRoles::COMMERCIALE === $roleCode;
|
||||
}
|
||||
|
||||
public function getRoles(): array
|
||||
{
|
||||
return ['ROLE_USER'];
|
||||
}
|
||||
|
||||
public function eraseCredentials(): void {}
|
||||
|
||||
public function getUserIdentifier(): string
|
||||
{
|
||||
return 'commerciale-test';
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user