feat(front) : page Ajouter un fournisseur (/suppliers/new) + workflow par onglets (ERP-94) (#83)
Auto Tag Develop / tag (push) Successful in 7s

ERP-94 (etape front 7/7 du M2). **Stack sur #97** (base = `feature/ERP-97-suppliers-i18n-sidebar`, elle-meme sur #93) pour un diff isole. A recibler sur `develop` une fois #93 (MR #81) et #97 (MR #82) mergees.

Page « Ajouter un fournisseur » — **replique a l'identique le fonctionnement de l'ecran Client** (workflow inline par onglets, blocs reutilisables, validation 422 inline ERP-101), avec les specificites M2.

## Architecture (miroir Client)
- Workflow par onglets **inline dans `suppliers/new.vue`** (comme `clients/new.vue` — il n'existe pas de `useClientForm` monolithique). Helpers paralleles : `useSupplierReferentials`, `useSupplierFormErrors`, `supplierFormRules`, `supplierEdit` (payloads), `types/supplierForm`.
- Blocs `SupplierContactBlock` / `SupplierAddressBlock` (miroir des blocs Client).
- POST `/suppliers` puis PATCH partiels par onglet (mode strict, groupes de serialisation). Sous-ressources : `/suppliers/{id}/contacts|addresses|ribs`.
- Validation ERP-101 : 422 `violations[].propertyPath` mappees inline par champ (`useFormErrors` / `mapViolationsToRecord`), `{ toast: false }`, bouton Valider toujours actif.

## Specificites M2 (vs M1)
- Formulaire principal **sans contact inline** (ERP-106) : Entreprise + Categorie (type FOURNISSEUR, `?typeCode=FOURNISSEUR`).
- Adresse : **radio exclusif** Prospect/Depart/Rendu (`addressType` enum, RG-2.09), champs **Bennes** (stepper) + **Prestation de triage**, **pas d'email de facturation**.
- Information : champ **Volume previsionnel** (8e champ).
- Compta (Admin+Compta) : banque si VIREMENT (RG-2.07), RIB si LCR (RG-2.08) ; RIB sous-ressource gardee par `accounting.manage`.

## Tests (mirroir strategie Client)
- `make nuxt-test` : 338 passed (specs ajoutees : supplierFormRules, supplierEdit, useSupplierReferentials, SupplierContactBlock, SupplierAddressBlock).
- ESLint propre ; `nuxi typecheck` (lance en container) : **0 erreur**.
- Golden path navigateur valide end-to-end : POST /suppliers OK, companyName normalise UPPERCASE (RG-2.12), gating des onglets (Information actif, Contacts deverrouille).

## Note de revue
~30 `WARN Duplicated imports` au typecheck : les helpers Supplier exportent les memes noms generiques que leurs equivalents Client (`buildMainPayload`, `omitEmptyRequired`, `RefOption`...), tous deux auto-importes par Nuxt. **Sans impact runtime** : tous les consommateurs utilisent des imports explicites (qui priment). Consequence directe du miroir 1:1 ; une factorisation des generiques dans `shared/` pourrait etre un suivi.

Reviewed-on: #83
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #83.
This commit is contained in:
2026-06-11 07:14:51 +00:00
committed by Autin
parent 3c1fc39eee
commit d6790dd37d
26 changed files with 2837 additions and 566 deletions
@@ -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);
}
}
@@ -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
* (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).
* - RG-2.08 : paymentType = LCR impose au moins un RIB -> violation sur
* `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
* 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()) {
$context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.')
->atPath('ribs')
->atPath('paymentType')
->addViolation()
;
}
@@ -199,12 +199,14 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
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> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[ORM\JoinTable(name: 'supplier_address_category')]
#[ORM\JoinColumn(name: 'supplier_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[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'])]
private Collection $categories;
@@ -7,10 +7,8 @@ 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\Application\Validator\SupplierAccountingCompletenessValidator;
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;
@@ -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
* restauration).
*
* 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).
* Validators metier (ERP-89). Ce processor porte la completude Comptabilite : a
* la validation complete de l'onglet (les six scalaires obligatoires presents
* dans le payload), chacun doit etre renseigne. (RG-2.03 « Information obligatoire
* pour la Commerciale » a ete retiree, miroir client M1 — l'onglet Information est
* desormais entierement facultatif, quel que soit le role.)
*
* 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).
* 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).
*
* @implements ProcessorInterface<Supplier, Supplier>
*/
@@ -78,6 +74,14 @@ final class SupplierProcessor implements ProcessorInterface
'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). */
private const string ARCHIVE_FIELD = 'isArchived';
@@ -102,7 +106,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 SupplierAccountingCompletenessValidator $accountingValidator,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
@@ -132,7 +136,7 @@ final class SupplierProcessor implements ProcessorInterface
// normalisees des deux cotes (l'etat persiste l'a deja ete).
$this->guardManage($data);
$this->validateInformationCompleteness($data);
$this->validateAccountingCompleteness($data);
try {
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
* 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.
* spec-front M2 § Onglet Comptabilite : a la validation COMPLETE de l'onglet
* (les six champs obligatoires presents dans le payload — le front les envoie
* toujours ensemble), chacun doit etre renseigne, sinon 422 par champ. On ne
* declenche pas sur un PATCH ciblant un sous-ensemble de champs comptables :
* ce n'est pas une validation d'onglet (edition ponctuelle preservee). bank /
* RIB restent geres par validatePaymentTypeConsistency sur l'entite (RG-2.07 /
* RG-2.08). Miroir du ClientProcessor (M1).
*/
private function validateInformationCompleteness(Supplier $data): void
private function validateAccountingCompleteness(Supplier $data): void
{
if ($this->currentUserIsCommerciale()) {
$this->informationValidator->validate($data);
// Declenche uniquement si TOUS les champs requis sont presents dans le
// payload (= soumission d'onglet, pas un PATCH partiel cible).
if ([] !== array_diff(self::ACCOUNTING_REQUIRED_FIELDS, $this->payloadKeys())) {
return;
}
}
/**
* 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);
$this->accountingValidator->validate($data);
}
/**