diff --git a/docs/specs/M1-clients/spec-back.md b/docs/specs/M1-clients/spec-back.md index 0476b45..fb30551 100644 --- a/docs/specs/M1-clients/spec-back.md +++ b/docs/specs/M1-clients/spec-back.md @@ -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 ». diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index 99b9af1..7cbd718 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -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 diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue index 1b82795..c248499 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -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 diff --git a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts index eda7206..34da52c 100644 --- a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts +++ b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts @@ -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) + }) +}) diff --git a/frontend/modules/commercial/utils/clientFormRules.ts b/frontend/modules/commercial/utils/clientFormRules.ts index f1f6830..7fee6a4 100644 --- a/frontend/modules/commercial/utils/clientFormRules.ts +++ b/frontend/modules/commercial/utils/clientFormRules.ts @@ -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) +} diff --git a/src/Module/Commercial/Application/Validator/ClientAccountingCompletenessValidator.php b/src/Module/Commercial/Application/Validator/ClientAccountingCompletenessValidator.php new file mode 100644 index 0000000..32a2a23 --- /dev/null +++ b/src/Module/Commercial/Application/Validator/ClientAccountingCompletenessValidator.php @@ -0,0 +1,77 @@ + 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); + } +} diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php index 293900f..e451ee7 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php @@ -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 diff --git a/tests/Module/Commercial/Unit/ClientProcessorTest.php b/tests/Module/Commercial/Unit/ClientProcessorTest.php index 8a025f3..1ac8905 100644 --- a/tests/Module/Commercial/Unit/ClientProcessorTest.php +++ b/tests/Module/Commercial/Unit/ClientProcessorTest.php @@ -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 + */ + 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();