fix(commercial) : validation tous-blocs des onglets collection client + fix 500 NonUniqueResult (ERP-110) #61
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user