fix(commercial) : corrections ajout fournisseur — addressType en select, 422 inline (addressType/catégorie/compta complète/LCR sur paymentType), Information facultative (RG-2.03 retirée, miroir client) (ERP-94)
This commit is contained in:
@@ -10,26 +10,19 @@
|
|||||||
@click="$emit('remove')"
|
@click="$emit('remove')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Type d'adresse : radio exclusif Prospect / Depart / Rendu (RG-2.09).
|
<!-- Type d'adresse : Prospect / Depart / Rendu (RG-2.09). Select en attendant
|
||||||
Une seule colonne (radios empiles), sans label de groupe ; le caractere
|
l'arbitrage metier (radio vs select) ; l'erreur 422 (propertyPath
|
||||||
obligatoire est porte par chaque radio (prop `required`). L'erreur 422
|
`addressType`) s'affiche via la prop native :error de MalioSelect. -->
|
||||||
(propertyPath `addressType`) s'affiche sous le groupe. -->
|
<MalioSelect
|
||||||
<div class="flex flex-col gap-2">
|
:model-value="model.addressType"
|
||||||
<MalioRadioButton
|
:options="addressTypeOptions"
|
||||||
v-for="opt in addressTypeOptions"
|
:label="t('commercial.suppliers.form.address.addressType')"
|
||||||
:key="opt.value"
|
:readonly="readonly"
|
||||||
:name="radioName"
|
empty-option-label=""
|
||||||
:model-value="model.addressType"
|
:required="true"
|
||||||
:value="opt.value"
|
:error="errors?.addressType"
|
||||||
:label="opt.label"
|
@update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))"
|
||||||
:required="true"
|
/>
|
||||||
:readonly="readonly"
|
|
||||||
:disabled="readonly"
|
|
||||||
group-class="mt-0"
|
|
||||||
@update:model-value="() => update('addressType', opt.value)"
|
|
||||||
/>
|
|
||||||
<span v-if="errors?.addressType" class="text-sm text-red-600">{{ errors.addressType }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-2.06). -->
|
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-2.06). -->
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox
|
||||||
@@ -210,10 +203,6 @@ const autocomplete = useAddressAutocomplete()
|
|||||||
|
|
||||||
const model = computed(() => props.modelValue)
|
const model = computed(() => props.modelValue)
|
||||||
|
|
||||||
// Nom de groupe radio unique par bloc (sinon les radios de blocs differents se
|
|
||||||
// partageraient la selection). Derive du titre (« Adresse 1 », « Adresse 2 »...).
|
|
||||||
const radioName = computed(() => `supplier-address-type-${props.title.replace(/\s+/g, '-')}`)
|
|
||||||
|
|
||||||
const addressTypeOptions = computed<{ value: SupplierAddressType, label: string }[]>(() => [
|
const addressTypeOptions = computed<{ value: SupplierAddressType, label: string }[]>(() => [
|
||||||
{ value: 'PROSPECT', label: t('commercial.suppliers.form.address.addressTypeProspect') },
|
{ value: 'PROSPECT', label: t('commercial.suppliers.form.address.addressTypeProspect') },
|
||||||
{ value: 'DEPART', label: t('commercial.suppliers.form.address.addressTypeDepart') },
|
{ value: 'DEPART', label: t('commercial.suppliers.form.address.addressTypeDepart') },
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<str
|
|||||||
stubs: {
|
stubs: {
|
||||||
MalioButtonIcon: true,
|
MalioButtonIcon: true,
|
||||||
MalioCheckbox: true,
|
MalioCheckbox: true,
|
||||||
MalioRadioButton: true,
|
|
||||||
MalioInputNumber: true,
|
MalioInputNumber: true,
|
||||||
MalioSelect: true,
|
MalioSelect: true,
|
||||||
MalioSelectCheckbox: true,
|
MalioSelectCheckbox: true,
|
||||||
@@ -68,10 +67,13 @@ function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<str
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('SupplierAddressBlock — specificites M2 (radio type, bennes, triage)', () => {
|
describe('SupplierAddressBlock — specificites M2 (type, bennes, triage)', () => {
|
||||||
it('rend les 3 options de type d\'adresse (Prospect / Départ / Rendu)', () => {
|
it('rend un select de type d\'adresse (en attendant l\'arbitrage metier)', () => {
|
||||||
const wrapper = mountBlock()
|
const wrapper = mountBlock()
|
||||||
expect(wrapper.findAll('malio-radio-button-stub')).toHaveLength(3)
|
const addressTypeSelect = wrapper.findAll('malio-select-stub').find(
|
||||||
|
el => el.attributes('label') === 'commercial.suppliers.form.address.addressType',
|
||||||
|
)
|
||||||
|
expect(addressTypeSelect).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rend le stepper Bennes et la case Prestation de triage (champs specifiques fournisseur)', () => {
|
it('rend le stepper Bennes et la case Prestation de triage (champs specifiques fournisseur)', () => {
|
||||||
@@ -88,9 +90,12 @@ describe('SupplierAddressBlock — specificites M2 (radio type, bennes, triage)'
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('SupplierAddressBlock — mapping erreur par champ (ERP-101)', () => {
|
describe('SupplierAddressBlock — mapping erreur par champ (ERP-101)', () => {
|
||||||
it('affiche l\'erreur serveur du type d\'adresse (propertyPath addressType)', () => {
|
it('affiche l\'erreur serveur du type d\'adresse (propertyPath addressType) sur le select', () => {
|
||||||
const wrapper = mountBlock({}, { addressType: 'Le type d\'adresse doit être Prospect, Départ ou Rendu.' })
|
const wrapper = mountBlock({}, { addressType: 'Le type d\'adresse est obligatoire.' })
|
||||||
expect(wrapper.text()).toContain('Le type d\'adresse doit être Prospect, Départ ou Rendu.')
|
const addressTypeSelect = wrapper.findAll('malio-select-stub').find(
|
||||||
|
el => el.attributes('label') === 'commercial.suppliers.form.address.addressType',
|
||||||
|
)
|
||||||
|
expect(addressTypeSelect?.attributes('error')).toBe('Le type d\'adresse est obligatoire.')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('affiche les erreurs serveur sur sites et categories', () => {
|
it('affiche les erreurs serveur sur sites et categories', () => {
|
||||||
|
|||||||
@@ -79,6 +79,13 @@ describe('buildAddressPayload (sous-ressource supplier_address — specificites
|
|||||||
expect(payload.addressType).toBe('PROSPECT')
|
expect(payload.addressType).toBe('PROSPECT')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('omet addressType quand aucun radio n\'est choisi (-> 422 NotBlank au lieu d\'un 400 de type)', () => {
|
||||||
|
// emptyAddress() laisse addressType a null : la cle doit etre absente du
|
||||||
|
// payload pour que le back renvoie une 422 propertyPath addressType.
|
||||||
|
const payload = buildAddressPayload(emptyAddress())
|
||||||
|
expect('addressType' in payload).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('n\'expose jamais d\'email de facturation (difference M1)', () => {
|
it('n\'expose jamais d\'email de facturation (difference M1)', () => {
|
||||||
const payload = buildAddressPayload({ ...emptyAddress(), addressType: 'DEPART' })
|
const payload = buildAddressPayload({ ...emptyAddress(), addressType: 'DEPART' })
|
||||||
expect('billingEmail' in payload).toBe(false)
|
expect('billingEmail' in payload).toBe(false)
|
||||||
|
|||||||
@@ -192,7 +192,11 @@ export function isRibRequiredForPaymentType(code: string | null | undefined): bo
|
|||||||
// OMETTRE la cle du payload quand elle est vide -> le back produit une 422 NotBlank
|
// OMETTRE la cle du payload quand elle est vide -> le back produit une 422 NotBlank
|
||||||
// avec propertyPath, mappee en rouge sous le champ.
|
// avec propertyPath, mappee en rouge sous le champ.
|
||||||
export const MAIN_REQUIRED_NON_NULLABLE_KEYS = ['companyName'] as const
|
export const MAIN_REQUIRED_NON_NULLABLE_KEYS = ['companyName'] as const
|
||||||
export const ADDRESS_REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const
|
// addressType : colonne non-nullable + NotBlank cote back. Envoyer `null` (radio
|
||||||
|
// non choisi) provoque un 400 de TYPE a la deserialisation AVANT le Validator
|
||||||
|
// (« must be string, NULL given ») -> pas de violation, pas d'erreur inline. On
|
||||||
|
// omet donc la cle quand elle est vide pour obtenir une 422 NotBlank propertyPath.
|
||||||
|
export const ADDRESS_REQUIRED_NON_NULLABLE_KEYS = ['addressType', 'postalCode', 'city', 'street'] as const
|
||||||
export const RIB_REQUIRED_NON_NULLABLE_KEYS = ['label', 'bic', 'iban'] as const
|
export const RIB_REQUIRED_NON_NULLABLE_KEYS = ['label', 'bic', 'iban'] as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
<?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 (spec-front M2 § Onglet Comptabilite) : a la soumission
|
||||||
|
* complete de l'onglet Comptabilite, les six champs scalaires obligatoires
|
||||||
|
* doivent etre renseignes (SIREN, Numero de compte, Mode de TVA, N de TVA, Delai
|
||||||
|
* de reglement, Type de reglement). La banque reste conditionnelle (RG-2.07) et
|
||||||
|
* les RIB aussi (RG-2.08) : ils ne sont pas couverts ici (Assert\Callback sur
|
||||||
|
* l'entite Supplier — validatePaymentTypeConsistency).
|
||||||
|
*
|
||||||
|
* Parti pris (miroir ClientAccountingCompletenessValidator M1) : colonnes nullable
|
||||||
|
* en base + validateur contextuel, plutot qu'un Assert\NotBlank sur l'entite (qui
|
||||||
|
* casserait le POST de l'onglet principal, lequel n'envoie aucun champ comptable).
|
||||||
|
*
|
||||||
|
* Invoque par le SupplierProcessor uniquement quand le payload porte les six
|
||||||
|
* champs (= une validation d'onglet), jamais sur un PATCH ciblant un seul champ.
|
||||||
|
*
|
||||||
|
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
|
||||||
|
* coherence avec les violations Symfony rendues par API Platform (mapping inline
|
||||||
|
* front via useFormErrors, ERP-101).
|
||||||
|
*/
|
||||||
|
final class SupplierAccountingCompletenessValidator
|
||||||
|
{
|
||||||
|
public function validate(Supplier $supplier): void
|
||||||
|
{
|
||||||
|
// Map champ -> valeur courante des champs obligatoires de l'onglet.
|
||||||
|
$fields = [
|
||||||
|
'siren' => $supplier->getSiren(),
|
||||||
|
'accountNumber' => $supplier->getAccountNumber(),
|
||||||
|
'tvaMode' => $supplier->getTvaMode(),
|
||||||
|
'nTva' => $supplier->getNTva(),
|
||||||
|
'paymentDelay' => $supplier->getPaymentDelay(),
|
||||||
|
'paymentType' => $supplier->getPaymentType(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$violations = new ConstraintViolationList();
|
||||||
|
|
||||||
|
foreach ($fields as $property => $value) {
|
||||||
|
if ($this->isMissing($value)) {
|
||||||
|
$violations->add(new ConstraintViolation(
|
||||||
|
'Ce champ est obligatoire.',
|
||||||
|
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
|
||||||
|
* references (TvaMode / PaymentDelay / PaymentType) ne sont manquantes que
|
||||||
|
* lorsqu'elles valent null.
|
||||||
|
*/
|
||||||
|
private function isMissing(mixed $value): bool
|
||||||
|
{
|
||||||
|
if (null === $value) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_string($value) && '' === trim($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
-82
@@ -1,82 +0,0 @@
|
|||||||
<?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 (completude Information cote fournisseur) :
|
|
||||||
* 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(
|
|
||||||
// Pas de nom de champ technique dans le message : la violation est
|
|
||||||
// deja rattachee au bon champ via son propertyPath (mappe inline
|
|
||||||
// cote front par useFormErrors).
|
|
||||||
'Ce champ est obligatoire pour le rôle Commerciale.',
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -328,8 +328,11 @@ class Supplier implements TimestampableInterface, BlamableInterface
|
|||||||
* chaque 422 porte un propertyPath exploitable par extractApiViolations
|
* chaque 422 porte un propertyPath exploitable par extractApiViolations
|
||||||
* (mapping inline sous le champ, pas un toast — convention ERP-101).
|
* (mapping inline sous le champ, pas un toast — convention ERP-101).
|
||||||
* - RG-2.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`.
|
* - RG-2.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`.
|
||||||
* - RG-2.08 : paymentType = LCR impose au moins un RIB -> violation sur `ribs`
|
* - RG-2.08 : paymentType = LCR impose au moins un RIB -> violation sur
|
||||||
* (le 409 sur DELETE du dernier RIB en LCR est porte par ERP-88).
|
* `paymentType` (miroir client : `ribs` n'a pas de champ de formulaire ou
|
||||||
|
* s'ancrer quand la liste est vide ; l'erreur s'affiche donc sous le select
|
||||||
|
* « Type de règlement », bindé cote front). 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
|
* 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
|
* n'expose que supplier:write:main), la contrainte ne mord en pratique que
|
||||||
@@ -349,7 +352,7 @@ class Supplier implements TimestampableInterface, BlamableInterface
|
|||||||
|
|
||||||
if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) {
|
if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) {
|
||||||
$context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.')
|
$context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.')
|
||||||
->atPath('ribs')
|
->atPath('paymentType')
|
||||||
->addViolation()
|
->addViolation()
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,12 +199,14 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
|
|||||||
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
||||||
private Collection $contacts;
|
private Collection $contacts;
|
||||||
|
|
||||||
// RG-2.10 : categories d'adresse de type FOURNISSEUR (controle au Processor).
|
// RG-2.10 : au moins une categorie de type FOURNISSEUR par adresse (le type est
|
||||||
|
// controle par validateCategoryType ; le minimum par Assert\Count, miroir sites).
|
||||||
/** @var Collection<int, CategoryInterface> */
|
/** @var Collection<int, CategoryInterface> */
|
||||||
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
||||||
#[ORM\JoinTable(name: 'supplier_address_category')]
|
#[ORM\JoinTable(name: 'supplier_address_category')]
|
||||||
#[ORM\JoinColumn(name: 'supplier_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
#[ORM\JoinColumn(name: 'supplier_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||||
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||||
|
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
|
||||||
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
||||||
private Collection $categories;
|
private Collection $categories;
|
||||||
|
|
||||||
|
|||||||
+34
-42
@@ -7,10 +7,8 @@ namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
|
|||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\Module\Commercial\Application\Service\SupplierFieldNormalizer;
|
use App\Module\Commercial\Application\Service\SupplierFieldNormalizer;
|
||||||
use App\Module\Commercial\Application\Validator\SupplierInformationCompletenessValidator;
|
use App\Module\Commercial\Application\Validator\SupplierAccountingCompletenessValidator;
|
||||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||||
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
|
|
||||||
use App\Shared\Domain\Security\BusinessRoles;
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
@@ -43,19 +41,17 @@ use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
|||||||
* collisions d'unicite en 409 (RG-2.11 doublon de nom ; RG-2.15 conflit de
|
* collisions d'unicite en 409 (RG-2.11 doublon de nom ; RG-2.15 conflit de
|
||||||
* restauration).
|
* restauration).
|
||||||
*
|
*
|
||||||
* Validators metier (ERP-89). Decision figee : ce processor ne porte QUE
|
* Validators metier (ERP-89). Ce processor porte la completude Comptabilite : a
|
||||||
* RG-2.03 (completude Information exigee pour le role Commerciale — detection du
|
* la validation complete de l'onglet (les six scalaires obligatoires presents
|
||||||
* role cote back, non exprimable en contrainte d'entite). Les RG inter-champs
|
* dans le payload), chacun doit etre renseigne. (RG-2.03 « Information obligatoire
|
||||||
* RG-2.07 (Virement -> banque), RG-2.08 (LCR -> >= 1 RIB) et RG-2.10 (categorie
|
* pour la Commerciale » a ete retiree, miroir client M1 — l'onglet Information est
|
||||||
* de type FOURNISSEUR) sont portees par des Assert\Callback + ->atPath() sur
|
* desormais entierement facultatif, quel que soit le role.)
|
||||||
* 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,
|
* Les RG inter-champs RG-2.07 (Virement -> banque), RG-2.08 (LCR -> >= 1 RIB) et
|
||||||
* les Callback RG-2.07/2.08/2.10...) est jouee par API Platform AVANT ce
|
* RG-2.10 (categorie de type FOURNISSEUR) sont portees par des Assert\Callback +
|
||||||
* processor ; on n'y traite donc que les regles non exprimables en simples
|
* ->atPath() sur l'entite Supplier (jouees par API Platform AVANT ce processor),
|
||||||
* contraintes d'entite (RG-2.03, qui depend du role de l'utilisateur courant).
|
* pour que chaque 422 porte un propertyPath consommable par extractApiViolations
|
||||||
|
* (mapping inline, pas un toast — convention ERP-101).
|
||||||
*
|
*
|
||||||
* @implements ProcessorInterface<Supplier, Supplier>
|
* @implements ProcessorInterface<Supplier, Supplier>
|
||||||
*/
|
*/
|
||||||
@@ -78,6 +74,14 @@ final class SupplierProcessor implements ProcessorInterface
|
|||||||
'paymentType', 'bank',
|
'paymentType', 'bank',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champs comptables obligatoires a la validation complete de l'onglet
|
||||||
|
* (spec-front M2 § Onglet Comptabilite). bank est exclu : conditionnel (RG-2.07).
|
||||||
|
*/
|
||||||
|
private const array ACCOUNTING_REQUIRED_FIELDS = [
|
||||||
|
'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType',
|
||||||
|
];
|
||||||
|
|
||||||
/** Champ d'archivage (groupe supplier:write:archive). */
|
/** Champ d'archivage (groupe supplier:write:archive). */
|
||||||
private const string ARCHIVE_FIELD = 'isArchived';
|
private const string ARCHIVE_FIELD = 'isArchived';
|
||||||
|
|
||||||
@@ -102,7 +106,7 @@ final class SupplierProcessor implements ProcessorInterface
|
|||||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||||
private readonly ProcessorInterface $persistProcessor,
|
private readonly ProcessorInterface $persistProcessor,
|
||||||
private readonly SupplierFieldNormalizer $normalizer,
|
private readonly SupplierFieldNormalizer $normalizer,
|
||||||
private readonly SupplierInformationCompletenessValidator $informationValidator,
|
private readonly SupplierAccountingCompletenessValidator $accountingValidator,
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
private readonly RequestStack $requestStack,
|
private readonly RequestStack $requestStack,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
@@ -132,7 +136,7 @@ final class SupplierProcessor implements ProcessorInterface
|
|||||||
// normalisees des deux cotes (l'etat persiste l'a deja ete).
|
// normalisees des deux cotes (l'etat persiste l'a deja ete).
|
||||||
$this->guardManage($data);
|
$this->guardManage($data);
|
||||||
|
|
||||||
$this->validateInformationCompleteness($data);
|
$this->validateAccountingCompleteness($data);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
@@ -262,35 +266,23 @@ final class SupplierProcessor implements ProcessorInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RG-2.03 : si l'utilisateur porte le role metier Commerciale, TOUS les
|
* spec-front M2 § Onglet Comptabilite : a la validation COMPLETE de l'onglet
|
||||||
* champs de l'onglet Information sont obligatoires sur POST comme sur TOUT
|
* (les six champs obligatoires presents dans le payload — le front les envoie
|
||||||
* PATCH — independamment des champs reellement envoyes. Garantit qu'un
|
* toujours ensemble), chacun doit etre renseigne, sinon 422 par champ. On ne
|
||||||
* fournisseur cree/edite par une Commerciale ne reste jamais avec un onglet
|
* declenche pas sur un PATCH ciblant un sous-ensemble de champs comptables :
|
||||||
* Information incomplet. Pour les autres roles, ces champs restent optionnels.
|
* ce n'est pas une validation d'onglet (edition ponctuelle preservee). bank /
|
||||||
*
|
* RIB restent geres par validatePaymentTypeConsistency sur l'entite (RG-2.07 /
|
||||||
* Consequence (cf. spec § 7, miroir RG-1.04) : le POST n'exposant que
|
* RG-2.08). Miroir du ClientProcessor (M1).
|
||||||
* 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
|
private function validateAccountingCompleteness(Supplier $data): void
|
||||||
{
|
{
|
||||||
if ($this->currentUserIsCommerciale()) {
|
// Declenche uniquement si TOUS les champs requis sont presents dans le
|
||||||
$this->informationValidator->validate($data);
|
// payload (= soumission d'onglet, pas un PATCH partiel cible).
|
||||||
|
if ([] !== array_diff(self::ACCOUNTING_REQUIRED_FIELDS, $this->payloadKeys())) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
$this->accountingValidator->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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -152,7 +152,8 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
|
|||||||
$supplier->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8'));
|
$supplier->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8'));
|
||||||
$supplier->addCategory($this->supplierCategory('NEGOCIANT'));
|
$supplier->addCategory($this->supplierCategory('NEGOCIANT'));
|
||||||
|
|
||||||
// Onglet Information complet (RG-2.03 : exige pour la Commerciale).
|
// Onglet Information complet : donnees de reference pour les tests de
|
||||||
|
// lecture / serialisation / comptabilite (l'Information est facultative).
|
||||||
$supplier->setDescription('Fournisseur de test complet.');
|
$supplier->setDescription('Fournisseur de test complet.');
|
||||||
$supplier->setCompetitors('Concurrent A, Concurrent B');
|
$supplier->setCompetitors('Concurrent A, Concurrent B');
|
||||||
$supplier->setFoundedAt(new DateTimeImmutable('2008-04-01'));
|
$supplier->setFoundedAt(new DateTimeImmutable('2008-04-01'));
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ final class SupplierAccountingApiTest extends AbstractSupplierApiTestCase
|
|||||||
|
|
||||||
// === RG-2.08 : LCR impose au moins un RIB ===
|
// === RG-2.08 : LCR impose au moins un RIB ===
|
||||||
|
|
||||||
public function testLcrWithoutRibReturns422OnRibsPath(): void
|
public function testLcrWithoutRibReturns422OnPaymentTypePath(): void
|
||||||
{
|
{
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
$seed = $this->seedSupplier('Lcr No Rib');
|
$seed = $this->seedSupplier('Lcr No Rib');
|
||||||
@@ -60,7 +60,9 @@ final class SupplierAccountingApiTest extends AbstractSupplierApiTestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
self::assertResponseStatusCodeSame(422);
|
||||||
self::assertArrayHasKey('ribs', $this->violationsByPath($response->toArray(false)));
|
// Miroir client : violation portee sur `paymentType` (select « Type de
|
||||||
|
// règlement »), `ribs` n'ayant pas de champ de formulaire pour l'ancrer.
|
||||||
|
self::assertArrayHasKey('paymentType', $this->violationsByPath($response->toArray(false)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testLcrWithRibReturns200(): void
|
public function testLcrWithRibReturns200(): void
|
||||||
@@ -77,5 +79,58 @@ final class SupplierAccountingApiTest extends AbstractSupplierApiTestCase
|
|||||||
self::assertResponseStatusCodeSame(200);
|
self::assertResponseStatusCodeSame(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Completude de l'onglet Comptabilite (six scalaires obligatoires) ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* spec-front M2 § Onglet Comptabilite : a la validation COMPLETE de l'onglet
|
||||||
|
* (les six champs requis presents dans le payload), chacun vide doit renvoyer
|
||||||
|
* une 422 sur son propre propertyPath (mapping inline front, ERP-101). Miroir
|
||||||
|
* du comportement client (ClientAccountingCompletenessValidator).
|
||||||
|
*/
|
||||||
|
public function testIncompleteAccountingTabReturns422OnEachField(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$seed = $this->seedSupplier('Accounting Incomplete');
|
||||||
|
|
||||||
|
$response = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||||
|
'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD],
|
||||||
|
'json' => [
|
||||||
|
'siren' => null,
|
||||||
|
'accountNumber' => null,
|
||||||
|
'tvaMode' => null,
|
||||||
|
'nTva' => null,
|
||||||
|
'paymentDelay' => null,
|
||||||
|
'paymentType' => null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
$paths = $this->violationsByPath($response->toArray(false));
|
||||||
|
self::assertArrayHasKey('siren', $paths);
|
||||||
|
self::assertArrayHasKey('accountNumber', $paths);
|
||||||
|
self::assertArrayHasKey('tvaMode', $paths);
|
||||||
|
self::assertArrayHasKey('nTva', $paths);
|
||||||
|
self::assertArrayHasKey('paymentDelay', $paths);
|
||||||
|
self::assertArrayHasKey('paymentType', $paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un PATCH ciblant un sous-ensemble de champs comptables n'est PAS une
|
||||||
|
* validation d'onglet : la completude ne se declenche pas (edition ponctuelle
|
||||||
|
* preservee, cf. validateAccountingCompleteness).
|
||||||
|
*/
|
||||||
|
public function testPartialAccountingPatchSkipsCompleteness(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$seed = $this->seedSupplier('Accounting Partial');
|
||||||
|
|
||||||
|
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||||
|
'headers' => ['Content-Type' => self::MERGE],
|
||||||
|
'json' => ['nTva' => 'FR12345678901'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(200);
|
||||||
|
}
|
||||||
|
|
||||||
// violationsByPath() : helper mutualise dans AbstractSupplierApiTestCase.
|
// violationsByPath() : helper mutualise dans AbstractSupplierApiTestCase.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ use Symfony\Component\Console\Output\NullOutput;
|
|||||||
/**
|
/**
|
||||||
* Matrice RBAC complete du repertoire fournisseurs par role metier (spec-back M2
|
* Matrice RBAC complete du repertoire fournisseurs par role metier (spec-back M2
|
||||||
* § 2.9 + ERP-90). Valide 200/403 par verbe et par onglet pour
|
* § 2.9 + ERP-90). Valide 200/403 par verbe et par onglet pour
|
||||||
* bureau / compta / commerciale / usine, le gating des champs comptables en
|
* bureau / compta / commerciale / usine et le gating des champs comptables en
|
||||||
* lecture (omission de cle) et le durcissement RG-2.03 (Commerciale) au POST/PATCH.
|
* lecture (omission de cle).
|
||||||
*
|
*
|
||||||
* Les comptes demo et la matrice sont seedes via la commande reelle
|
* Les comptes demo et la matrice sont seedes via la commande reelle
|
||||||
* `app:seed-rbac --with-demo-users` (le MEME chemin qu'en recette), idempotente —
|
* `app:seed-rbac --with-demo-users` (le MEME chemin qu'en recette), idempotente —
|
||||||
@@ -23,7 +23,7 @@ use Symfony\Component\Console\Output\NullOutput;
|
|||||||
* Matrice § 2.9 (ERP-90) — rappel :
|
* Matrice § 2.9 (ERP-90) — rappel :
|
||||||
* - bureau : suppliers.view + manage (ni accounting, ni archive)
|
* - bureau : suppliers.view + manage (ni accounting, ni archive)
|
||||||
* - compta : suppliers.view + accounting.view + accounting.manage (PAS manage)
|
* - compta : suppliers.view + accounting.view + accounting.manage (PAS manage)
|
||||||
* - commerciale : suppliers.view + manage (PAS accounting), durcie RG-2.03
|
* - commerciale : suppliers.view + manage (PAS accounting)
|
||||||
* - usine : aucune permission (403 partout)
|
* - usine : aucune permission (403 partout)
|
||||||
* - archive : admin seul (aucun role metier)
|
* - archive : admin seul (aucun role metier)
|
||||||
*
|
*
|
||||||
@@ -93,7 +93,7 @@ final class SupplierRBACMatrixTest extends AbstractSupplierApiTestCase
|
|||||||
$client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]);
|
$client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]);
|
||||||
self::assertResponseStatusCodeSame(200);
|
self::assertResponseStatusCodeSame(200);
|
||||||
|
|
||||||
// manage : creation OK (bureau n'est pas gate par RG-2.03)
|
// manage : creation OK
|
||||||
$client->request('POST', '/api/suppliers', [
|
$client->request('POST', '/api/suppliers', [
|
||||||
'headers' => ['Content-Type' => self::LD],
|
'headers' => ['Content-Type' => self::LD],
|
||||||
'json' => $this->validMainPayload('Bureau Created', $cat->getId()),
|
'json' => $this->validMainPayload('Bureau Created', $cat->getId()),
|
||||||
@@ -211,15 +211,13 @@ final class SupplierRBACMatrixTest extends AbstractSupplierApiTestCase
|
|||||||
self::assertResponseStatusCodeSame(200);
|
self::assertResponseStatusCodeSame(200);
|
||||||
|
|
||||||
// manage : la creation passe la security d'operation (pas un 403 comme
|
// manage : la creation passe la security d'operation (pas un 403 comme
|
||||||
// Compta) mais bute sur RG-2.03 (onglet Information incomplet) -> 422.
|
// Compta) -> 201. L'onglet Information est facultatif (RG-2.03 retiree,
|
||||||
$response = $client->request('POST', '/api/suppliers', [
|
// miroir client M1) : une Commerciale cree avec le seul onglet principal.
|
||||||
|
$client->request('POST', '/api/suppliers', [
|
||||||
'headers' => ['Content-Type' => self::LD],
|
'headers' => ['Content-Type' => self::LD],
|
||||||
'json' => $this->validMainPayload('Commerciale Post'),
|
'json' => $this->validMainPayload('Commerciale Post'),
|
||||||
]);
|
]);
|
||||||
self::assertResponseStatusCodeSame(422);
|
self::assertResponseStatusCodeSame(201);
|
||||||
// Le 422 doit bien etre celui de RG-2.03 (onglet Information) et non un
|
|
||||||
// 422 orthogonal : on exige une violation sur un champ de completude.
|
|
||||||
self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false)));
|
|
||||||
|
|
||||||
// PAS accounting : edition onglet Comptabilite refusee
|
// PAS accounting : edition onglet Comptabilite refusee
|
||||||
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||||
@@ -251,50 +249,6 @@ final class SupplierRBACMatrixTest extends AbstractSupplierApiTestCase
|
|||||||
self::assertArrayNotHasKey('ribs', $data);
|
self::assertArrayNotHasKey('ribs', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRG203CommercialePostIncompleteIs422AdminIs201(): void
|
|
||||||
{
|
|
||||||
$cat = $this->supplierCategory('NEGOCIANT');
|
|
||||||
|
|
||||||
// RG-2.03 : Commerciale POST sans onglet Information complet -> 422.
|
|
||||||
$commerciale = $this->authAs('commerciale');
|
|
||||||
$response = $commerciale->request('POST', '/api/suppliers', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => $this->validMainPayload('RG203 Commerciale', $cat->getId()),
|
|
||||||
]);
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
|
||||||
self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false)));
|
|
||||||
|
|
||||||
// Meme payload par un Admin (non gate par RG-2.03) -> 201.
|
|
||||||
$admin = $this->createAdminClient();
|
|
||||||
$admin->request('POST', '/api/suppliers', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => $this->validMainPayload('RG203 Admin', $cat->getId()),
|
|
||||||
]);
|
|
||||||
self::assertResponseStatusCodeSame(201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRG203CommercialePatchIncompleteIs422(): void
|
|
||||||
{
|
|
||||||
// RG-2.03 : tout PATCH par une Commerciale exige l'Information complete.
|
|
||||||
// Le fournisseur seede a une Information vide -> meme un PATCH du nom -> 422.
|
|
||||||
$seed = $this->seedSupplier('Commerciale Patch Incomplete');
|
|
||||||
$commerciale = $this->authAs('commerciale');
|
|
||||||
|
|
||||||
$response = $commerciale->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
|
||||||
'headers' => ['Content-Type' => self::MERGE],
|
|
||||||
'json' => ['companyName' => 'Commerciale Renamed'],
|
|
||||||
]);
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
|
||||||
self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false)));
|
|
||||||
|
|
||||||
// Le meme PATCH par un Admin passe (non gate par RG-2.03) -> 200.
|
|
||||||
$admin = $this->createAdminClient();
|
|
||||||
$admin->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
|
||||||
'headers' => ['Content-Type' => self::MERGE],
|
|
||||||
'json' => ['companyName' => 'Admin Renamed'],
|
|
||||||
]);
|
|
||||||
self::assertResponseStatusCodeSame(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function authAs(string $role): Client
|
private function authAs(string $role): Client
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -172,8 +172,9 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
|
|||||||
public function testPostAddressWithIncoherentCityAndPostalCodeReturns201(): void
|
public function testPostAddressWithIncoherentCityAndPostalCodeReturns201(): void
|
||||||
{
|
{
|
||||||
$this->skipIfSitesModuleDisabled();
|
$this->skipIfSitesModuleDisabled();
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
$seed = $this->seedSupplier('Address Incoherent');
|
$seed = $this->seedSupplier('Address Incoherent');
|
||||||
|
$category = $this->supplierCategory('NEGOCIANT');
|
||||||
|
|
||||||
// RG-2.05 : pas de controle strict de coherence CP/ville cote serveur.
|
// RG-2.05 : pas de controle strict de coherence CP/ville cote serveur.
|
||||||
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
||||||
@@ -184,6 +185,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
|
|||||||
'city' => 'Marseille',
|
'city' => 'Marseille',
|
||||||
'street' => '1 rue du Test',
|
'street' => '1 rue du Test',
|
||||||
'sites' => [$this->firstSiteIri()],
|
'sites' => [$this->firstSiteIri()],
|
||||||
|
'categories' => ['/api/categories/'.$category->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -217,9 +219,10 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
|
|||||||
public function testPostAddressWithEachValidTypeReturns201(): void
|
public function testPostAddressWithEachValidTypeReturns201(): void
|
||||||
{
|
{
|
||||||
$this->skipIfSitesModuleDisabled();
|
$this->skipIfSitesModuleDisabled();
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
$seed = $this->seedSupplier('Address Types');
|
$seed = $this->seedSupplier('Address Types');
|
||||||
$siteIri = $this->firstSiteIri();
|
$siteIri = $this->firstSiteIri();
|
||||||
|
$category = $this->supplierCategory('NEGOCIANT');
|
||||||
|
|
||||||
foreach (['PROSPECT', 'DEPART', 'RENDU'] as $type) {
|
foreach (['PROSPECT', 'DEPART', 'RENDU'] as $type) {
|
||||||
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
||||||
@@ -230,6 +233,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
|
|||||||
'city' => 'Châtellerault',
|
'city' => 'Châtellerault',
|
||||||
'street' => '1 rue du Test',
|
'street' => '1 rue du Test',
|
||||||
'sites' => [$siteIri],
|
'sites' => [$siteIri],
|
||||||
|
'categories' => ['/api/categories/'.$category->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
self::assertResponseStatusCodeSame(201, sprintf('addressType=%s doit etre accepte.', $type));
|
self::assertResponseStatusCodeSame(201, sprintf('addressType=%s doit etre accepte.', $type));
|
||||||
|
|||||||
@@ -87,12 +87,14 @@ final class SupplierValidationTest extends TestCase
|
|||||||
|
|
||||||
// === RG-2.08 : LCR impose au moins un RIB ===
|
// === RG-2.08 : LCR impose au moins un RIB ===
|
||||||
|
|
||||||
public function testLcrWithoutRibIsRejectedOnRibsPath(): void
|
public function testLcrWithoutRibIsRejectedOnPaymentTypePath(): void
|
||||||
{
|
{
|
||||||
$supplier = $this->validSupplier();
|
$supplier = $this->validSupplier();
|
||||||
$supplier->setPaymentType($this->paymentType('LCR'));
|
$supplier->setPaymentType($this->paymentType('LCR'));
|
||||||
|
|
||||||
self::assertContains('ribs', $this->violationPaths($supplier));
|
// Miroir client : la violation LCR -> >= 1 RIB est portee sur `paymentType`
|
||||||
|
// (affichee sous le select « Type de règlement », `ribs` n'ayant pas de champ).
|
||||||
|
self::assertContains('paymentType', $this->violationPaths($supplier));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testLcrWithRibPasses(): void
|
public function testLcrWithRibPasses(): void
|
||||||
@@ -101,7 +103,7 @@ final class SupplierValidationTest extends TestCase
|
|||||||
$supplier->setPaymentType($this->paymentType('LCR'));
|
$supplier->setPaymentType($this->paymentType('LCR'));
|
||||||
$supplier->addRib(new SupplierRib());
|
$supplier->addRib(new SupplierRib());
|
||||||
|
|
||||||
self::assertNotContains('ribs', $this->violationPaths($supplier));
|
self::assertNotContains('paymentType', $this->violationPaths($supplier));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testNeutralPaymentTypeRequiresNeitherBankNorRib(): void
|
public function testNeutralPaymentTypeRequiresNeitherBankNorRib(): void
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
<?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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
<?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()));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testAdminIncompleteInformationPasses(): void
|
|
||||||
{
|
|
||||||
// Distinct du cas user=null : un utilisateur AUTHENTIFIE mais non-Commerciale
|
|
||||||
// (ici un admin, BusinessRoleAwareInterface renvoyant false pour tout role
|
|
||||||
// metier) n'est pas soumis a la completude Information -> 200 malgre un
|
|
||||||
// onglet Information incomplet. Prouve que le gate porte bien sur le ROLE
|
|
||||||
// metier Commerciale, et pas sur « il y a un utilisateur connecte ».
|
|
||||||
$supplier = $this->minimalSupplier();
|
|
||||||
$supplier->setDescription('Une description');
|
|
||||||
|
|
||||||
$processor = $this->makeProcessor(
|
|
||||||
payload: ['description' => 'Une description'],
|
|
||||||
user: $this->adminUser(),
|
|
||||||
);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utilisateur authentifie non-Commerciale (profil admin) : porte
|
|
||||||
* BusinessRoleAwareInterface mais ne reconnait aucun role metier. Sert a
|
|
||||||
* distinguer « pas de role Commerciale » de « pas d'utilisateur » (null).
|
|
||||||
*/
|
|
||||||
private function adminUser(): UserInterface
|
|
||||||
{
|
|
||||||
return new class implements UserInterface, BusinessRoleAwareInterface {
|
|
||||||
public function hasBusinessRole(string $roleCode): bool
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRoles(): array
|
|
||||||
{
|
|
||||||
return ['ROLE_ADMIN'];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function eraseCredentials(): void {}
|
|
||||||
|
|
||||||
public function getUserIdentifier(): string
|
|
||||||
{
|
|
||||||
return 'admin-test';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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