fix(commercial) : rendre obligatoires les 6 champs comptables — 422 back par champ + bouton « Valider » grisé tant que l'onglet est incomplet (ERP-110)

spec-front marquait SIREN / N° compte / Mode TVA / N° TVA / Délai / Type de règlement obligatoires, mais ni le back (colonnes nullable, aucun NotBlank) ni le front (canValidateAccounting ne gardait que bank/RIB) ne l'imposaient : on validait un onglet vide.

- Back : ClientAccountingCompletenessValidator (calqué sur RG-1.04) invoqué par le ClientProcessor quand les 6 champs sont présents dans le payload (validation d'onglet) → 422 par propertyPath. PATCH partiel ciblé non impacté.
- Front : helper hasAllRequiredAccountingFields + canValidateAccounting étendu dans new.vue / edit.vue (cohérent avec les onglets Contact/Adresse).
- Spec-back : RG-1.30 documente la règle et résout l'incohérence spec-front/spec-back.
This commit is contained in:
2026-06-04 14:45:14 +02:00
parent ad32d8147d
commit c2282fdac5
8 changed files with 263 additions and 0 deletions
+1
View File
@@ -883,6 +883,7 @@ Cf. § 2.6. Pattern Shared standard.
### Onglet Comptabilité
- **RG-1.30** _(ajoutée — correctif incohérence spec-front/spec-back)_ : à la **validation complète de l'onglet Comptabilité**, les six champs scalaires `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType` sont **obligatoires** (alignement sur spec-front § Onglet Comptabilité). Colonnes `nullable` en base (l'onglet est rempli dans un second temps, et l'onglet principal ne les envoie pas) + validateur contextuel `ClientAccountingCompletenessValidator` invoqué par le `ClientProcessor` — même parti que RG-1.04 (Information). Déclenchement : uniquement quand **les six champs sont présents dans le payload** (le front les envoie toujours ensemble via « Valider ») ; un PATCH ciblant un sous-ensemble de champs comptables (édition ponctuelle) n'est pas soumis à la complétude. Chaque champ manquant → 422 sur son `propertyPath` (mapping inline front, ERP-101). `bank` reste hors complétude (conditionnel RG-1.12).
- **RG-1.12** : Le champ `bank` est visible et obligatoire **uniquement** si `paymentType.code = 'VIREMENT'`. Validation server-side dans le `ClientProcessor` : si `payment_type.code = VIREMENT` et `bank IS NULL` → 422.
- **RG-1.13** : Les champs RIB (`label`, `bic`, `iban`) sont obligatoires si **au moins un bloc RIB est présent ET** `paymentType.code = 'LCR'`. C'est-à-dire :
- Si `paymentType.code = LCR` ET `client.ribs.count() = 0` → 422 « Au moins un RIB est obligatoire pour le type LCR ».
@@ -411,6 +411,7 @@ import {
} from '~/modules/commercial/utils/clientEdit'
import {
buildClientFormTabKeys,
hasAllRequiredAccountingFields,
hasAtLeastOneValidContact,
isBankRequiredForPaymentType,
isBillingEmailRequired,
@@ -878,6 +879,7 @@ function ribIsComplete(rib: { label: string | null, bic: string | null, iban: st
}
const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) return false
if (isBankRequired.value && accounting.bankIri === null) return false
if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false
return true
@@ -382,6 +382,7 @@ import { useClientFormErrors } from '~/modules/commercial/composables/useClientF
import {
buildClientFormTabKeys,
CLIENT_FORM_PLACEHOLDER_TABS,
hasAllRequiredAccountingFields,
hasAtLeastOneValidContact,
isBankRequiredForPaymentType,
isBillingEmailRequired,
@@ -860,8 +861,11 @@ function ribIsComplete(rib: RibFormDraft): boolean {
return filled(rib.label) && filled(rib.bic) && filled(rib.iban)
}
// RG-1.30 : les 6 champs scalaires obligatoires (comme les onglets Contact /
// Adresse, le bouton reste desactive tant que l'onglet n'est pas complet).
// RG-1.12 : banque requise si VIREMENT. RG-1.13 : >= 1 RIB complet si LCR.
const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) return false
if (isBankRequired.value && (accounting.bankIri === null)) return false
if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false
return true
@@ -4,6 +4,7 @@ import {
buildClientFormTabKeys,
canSelectDeliveryOrBilling,
canSelectProspect,
hasAllRequiredAccountingFields,
hasAtLeastOneValidContact,
isBankRequiredForPaymentType,
isBillingEmailRequired,
@@ -209,3 +210,36 @@ describe('regles type de reglement (RG-1.12 / RG-1.13)', () => {
expect(isRibRequiredForPaymentType(null)).toBe(false)
})
})
describe('hasAllRequiredAccountingFields (RG-1.30)', () => {
const complete = {
siren: '123456789',
accountNumber: '00012345678',
nTva: 'FR12345678901',
tvaModeIri: '/api/tva_modes/1',
paymentDelayIri: '/api/payment_delays/1',
paymentTypeIri: '/api/payment_types/1',
}
it('vrai quand les six champs obligatoires sont remplis', () => {
expect(hasAllRequiredAccountingFields(complete)).toBe(true)
})
it('faux si un champ est manquant (null ou vide apres trim)', () => {
expect(hasAllRequiredAccountingFields({ ...complete, siren: null })).toBe(false)
expect(hasAllRequiredAccountingFields({ ...complete, accountNumber: ' ' })).toBe(false)
expect(hasAllRequiredAccountingFields({ ...complete, tvaModeIri: null })).toBe(false)
expect(hasAllRequiredAccountingFields({ ...complete, paymentTypeIri: null })).toBe(false)
})
it('faux quand tout est vide (onglet non rempli)', () => {
expect(hasAllRequiredAccountingFields({
siren: null,
accountNumber: null,
nTva: null,
tvaModeIri: null,
paymentDelayIri: null,
paymentTypeIri: null,
})).toBe(false)
})
})
@@ -208,3 +208,32 @@ export function isBankRequiredForPaymentType(code: string | null | undefined): b
export function isRibRequiredForPaymentType(code: string | null | undefined): boolean {
return code === PAYMENT_TYPE_LCR
}
/** Sous-ensemble du brouillon comptable portant les six champs obligatoires. */
export interface AccountingRequiredDraft {
siren: string | null
accountNumber: string | null
nTva: string | null
tvaModeIri: string | null
paymentDelayIri: string | null
paymentTypeIri: string | null
}
/**
* RG-1.30 : les six champs scalaires de l'onglet Comptabilite sont obligatoires
* pour valider l'onglet (SIREN, N de compte, Mode de TVA, N de TVA, Delai de
* reglement, Type de reglement). bank / RIB restent conditionnels (RG-1.12 /
* RG-1.13) et sont evalues a part. Miroir front du
* ClientAccountingCompletenessValidator : meme gate que les onglets Contact /
* Adresse (bouton « Valider » desactive tant que l'onglet n'est pas complet).
*/
export function hasAllRequiredAccountingFields(accounting: AccountingRequiredDraft): boolean {
const filled = (v: string | null): boolean => v !== null && v.trim() !== ''
return filled(accounting.siren)
&& filled(accounting.accountNumber)
&& filled(accounting.nTva)
&& filled(accounting.tvaModeIri)
&& filled(accounting.paymentDelayIri)
&& filled(accounting.paymentTypeIri)
}
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Application\Validator;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Domain\Entity\Client;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Validator metier (spec-front § 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-1.12) et les RIB aussi
* (RG-1.13) : ils ne sont pas couverts ici.
*
* Calque sur ClientInformationCompletenessValidator (RG-1.04) : 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 ClientProcessor uniquement quand le payload porte les six champs
* (= une validation d'onglet), jamais sur un PATCH ciblant un seul champ comptable.
*
* 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 ClientAccountingCompletenessValidator
{
public function validate(Client $client): void
{
// Map champ -> valeur courante des champs obligatoires de l'onglet.
$fields = [
'siren' => $client->getSiren(),
'accountNumber' => $client->getAccountNumber(),
'tvaMode' => $client->getTvaMode(),
'nTva' => $client->getNTva(),
'paymentDelay' => $client->getPaymentDelay(),
'paymentType' => $client->getPaymentType(),
];
$violations = new ConstraintViolationList();
foreach ($fields as $property => $value) {
if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation(
'Ce champ est obligatoire.',
null,
[],
$client,
$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);
}
}
@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
use App\Module\Commercial\Application\Validator\ClientAccountingCompletenessValidator;
use App\Module\Commercial\Application\Validator\ClientInformationCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Client;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
@@ -75,6 +76,14 @@ final class ClientProcessor implements ProcessorInterface
'paymentType', 'bank',
];
/**
* Champs comptables obligatoires a la validation complete de l'onglet
* (spec-front § Onglet Comptabilite). bank est exclu : conditionnel (RG-1.12).
*/
private const array ACCOUNTING_REQUIRED_FIELDS = [
'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType',
];
/** Champ d'archivage (groupe client:write:archive). */
private const string ARCHIVE_FIELD = 'isArchived';
@@ -100,6 +109,7 @@ final class ClientProcessor implements ProcessorInterface
private readonly ProcessorInterface $persistProcessor,
private readonly ClientFieldNormalizer $normalizer,
private readonly ClientInformationCompletenessValidator $informationValidator,
private readonly ClientAccountingCompletenessValidator $accountingValidator,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
@@ -125,6 +135,7 @@ final class ClientProcessor implements ProcessorInterface
$this->validateDistributorBroker($data);
$this->validateAccountingConsistency($data);
$this->validateAccountingCompleteness($data);
$this->validateInformationCompleteness($data);
try {
@@ -486,6 +497,29 @@ final class ClientProcessor implements ProcessorInterface
}
}
/**
* spec-front § 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 validateAccountingConsistency (RG-1.12 / RG-1.13).
*
* Colonnes nullable en base + validateur contextuel (meme parti que RG-1.04) :
* un Assert\NotBlank sur l'entite casserait le POST de l'onglet principal, qui
* n'envoie aucun champ comptable.
*/
private function validateAccountingCompleteness(Client $data): void
{
// 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;
}
$this->accountingValidator->validate($data);
}
/**
* RG-1.04 (durcie ERP-74) : si l'utilisateur porte le role metier
* Commerciale, TOUS les champs de l'onglet Information sont obligatoires sur
@@ -8,11 +8,14 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
use App\Module\Commercial\Application\Validator\ClientAccountingCompletenessValidator;
use App\Module\Commercial\Application\Validator\ClientInformationCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\ClientRib;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientProcessor;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Security\BusinessRoles;
@@ -280,6 +283,65 @@ final class ClientProcessorTest extends TestCase
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
}
public function testFullAccountingSubmitWithEmptyFieldsIsUnprocessable(): void
{
// spec-front § Onglet Comptabilite : une validation complete de l'onglet
// (les 6 champs presents dans le payload) avec des valeurs vides -> 422.
// C'est le bug corrige : avant, le back acceptait un onglet tout vide.
$client = $this->minimalClient(); // aucun champ comptable renseigne
$processor = $this->makeProcessor(
granted: ['commercial.clients.accounting.manage'],
payload: $this->emptyAccountingPayload(),
);
$this->expectException(ValidationException::class);
$processor->process($client, $this->operation());
}
public function testFullAccountingSubmitWithAllFieldsPasses(): void
{
// Les 6 champs obligatoires renseignes + type de reglement neutre
// (ni VIREMENT ni LCR -> ni banque ni RIB requis) -> 200.
$client = $this->minimalClient();
$client->setSiren('123456789');
$client->setAccountNumber('00012345678');
$client->setTvaMode(new TvaMode());
$client->setNTva('FR12345678901');
$client->setPaymentDelay(new PaymentDelay());
$client->setPaymentType($this->paymentType('CHEQUE'));
$processor = $this->makeProcessor(
granted: ['commercial.clients.accounting.manage'],
payload: $this->emptyAccountingPayload(),
);
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
}
public function testPartialAccountingPatchSkipsCompleteness(): void
{
// Un PATCH ciblant un seul champ comptable n'est pas une validation
// d'onglet : la completude n'est pas exigee (les autres champs restent
// vides) -> 200. Preserve l'edition ponctuelle (ex. Compta corrige le SIREN).
$client = $this->minimalClient();
$client->setSiren('999999999');
$processor = $this->makeProcessor(
granted: ['commercial.clients.accounting.manage'],
payload: ['siren' => '999999999'],
managed: true,
originalData: [
'siren' => '111111111',
'companyName' => 'TEST CO',
'triageService' => false,
'isArchived' => false,
],
);
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
}
public function testCommercialeIncompleteInformationIsUnprocessable(): void
{
// RG-1.04 : role Commerciale + onglet Information incomplet -> 422.
@@ -379,6 +441,7 @@ final class ClientProcessorTest extends TestCase
$persist,
new ClientFieldNormalizer(),
new ClientInformationCompletenessValidator(),
new ClientAccountingCompletenessValidator(),
$security,
$requestStack,
$em,
@@ -398,6 +461,25 @@ final class ClientProcessorTest extends TestCase
return $client;
}
/**
* Payload simulant une validation complete de l'onglet Comptabilite : les 6
* champs obligatoires presents (le front les envoie toujours ensemble). Les
* valeurs importent peu — la completude est evaluee sur l'etat de l'entite.
*
* @return array<string, mixed>
*/
private function emptyAccountingPayload(): array
{
return [
'siren' => null,
'accountNumber' => null,
'tvaMode' => null,
'nTva' => null,
'paymentDelay' => null,
'paymentType' => null,
];
}
private function paymentType(string $code): PaymentType
{
$type = new PaymentType();