diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index 9604965..bf078ae 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -125,11 +125,15 @@ :readonly="businessReadonly" :error="informationErrors.errors.employeesCount" /> + client.value?.companyName ?? t('commercial.cl const main = reactive(mapMainDraft({} as ClientDetail)) const information = reactive(mapInformationDraft({} as ClientDetail)) const accounting = reactive(mapAccountingFormDraft({} as ClientDetail)) + +// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du +// champ controle quand le plafonnement laisse le modelValue inchange. +const revenueAmountKey = ref(0) + +/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */ +function onRevenueAmountInput(value: string | null): void { + const clamped = clampRevenueAmount(value) + information.revenueAmount = clamped ?? null + if (clamped !== value) { + revenueAmountKey.value += 1 + } +} const contacts = ref([]) const addresses = ref([]) const ribs = ref([]) diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue index 4aa6a0c..1ab0949 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -120,11 +120,15 @@ :readonly="isValidated('information')" :error="informationErrors.errors.employeesCount" /> + { if (clientId.value === null || tabSubmitting.value) return diff --git a/frontend/modules/commercial/pages/suppliers/[id]/edit.vue b/frontend/modules/commercial/pages/suppliers/[id]/edit.vue index 985a13c..1399482 100644 --- a/frontend/modules/commercial/pages/suppliers/[id]/edit.vue +++ b/frontend/modules/commercial/pages/suppliers/[id]/edit.vue @@ -86,11 +86,15 @@ :readonly="businessReadonly" :error="informationErrors.errors.employeesCount" /> + supplier.value?.companyName ?? t('commercial. const main = reactive(mapMainDraft({} as SupplierDetail)) const information = reactive(mapInformationDraft({} as SupplierDetail)) const accounting = reactive(mapAccountingFormDraft({} as SupplierDetail)) + +// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du +// champ controle quand le plafonnement laisse le modelValue inchange. +const revenueAmountKey = ref(0) + +/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */ +function onRevenueAmountInput(value: string | null): void { + const clamped = clampRevenueAmount(value) + information.revenueAmount = clamped ?? null + if (clamped !== value) { + revenueAmountKey.value += 1 + } +} const contacts = ref([]) const addresses = ref([]) const ribs = ref([]) diff --git a/frontend/modules/commercial/pages/suppliers/new.vue b/frontend/modules/commercial/pages/suppliers/new.vue index f471e3b..deccbc2 100644 --- a/frontend/modules/commercial/pages/suppliers/new.vue +++ b/frontend/modules/commercial/pages/suppliers/new.vue @@ -80,11 +80,15 @@ :readonly="isValidated('information')" :error="informationErrors.errors.employeesCount" /> + { if (supplierId.value === null || tabSubmitting.value) return diff --git a/frontend/modules/commercial/utils/forms/__tests__/amountInput.spec.ts b/frontend/modules/commercial/utils/forms/__tests__/amountInput.spec.ts new file mode 100644 index 0000000..4a8bffb --- /dev/null +++ b/frontend/modules/commercial/utils/forms/__tests__/amountInput.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { clampRevenueAmount, REVENUE_AMOUNT_MAX } from '../amountInput' + +describe('clampRevenueAmount', () => { + it('laisse les valeurs vides / nulles telles quelles', () => { + expect(clampRevenueAmount(null)).toBeNull() + expect(clampRevenueAmount(undefined)).toBeUndefined() + expect(clampRevenueAmount('')).toBe('') + }) + + it('laisse une valeur sous le plafond inchangee', () => { + expect(clampRevenueAmount('1000.50')).toBe('1000.50') + expect(clampRevenueAmount('999999999999.99')).toBe('999999999999.99') + }) + + it('plafonne une valeur au-dessus du maximum', () => { + expect(clampRevenueAmount('1000000000000')).toBe('999999999999.99') + expect(clampRevenueAmount('999999999999999.99')).toBe('999999999999.99') + }) + + it('tolere une saisie a virgule / avec espaces (securite)', () => { + expect(clampRevenueAmount('1 000 000 000 000,00')).toBe('999999999999.99') + expect(clampRevenueAmount('12,5')).toBe('12,5') + }) + + it('ne touche pas une saisie non numerique', () => { + expect(clampRevenueAmount('abc')).toBe('abc') + }) + + it('expose le plafond metier', () => { + expect(REVENUE_AMOUNT_MAX).toBe(999_999_999_999.99) + }) +}) diff --git a/frontend/modules/commercial/utils/forms/amountInput.ts b/frontend/modules/commercial/utils/forms/amountInput.ts new file mode 100644 index 0000000..8fc4310 --- /dev/null +++ b/frontend/modules/commercial/utils/forms/amountInput.ts @@ -0,0 +1,29 @@ +/** + * Helpers de saisie des montants des formulaires Client / Fournisseur (commercial). + * Purs / testables. Pendant FRONT de la contrainte back `LessThanOrEqual` posee sur + * `revenueAmount` (Client/Supplier) — retour metier ERP-193 : le chiffre d'affaires + * est plafonne a 999 999 999 999,99. + */ + +/** Plafond metier du chiffre d'affaires (CA) : 999 999 999 999,99. */ +export const REVENUE_AMOUNT_MAX = 999_999_999_999.99 + +/** Valeur « modele » (decimale a point, sans separateur) renvoyee quand on plafonne. */ +const REVENUE_AMOUNT_MAX_MODEL = '999999999999.99' + +/** + * Plafonne le CA au maximum metier. Recoit le modele emis par `MalioInputAmount` + * (chaine propre a decimale `.`, sans espaces) ; tolere malgre tout une virgule / + * des espaces par securite. Renvoie la valeur telle quelle si elle est vide, non + * numerique ou sous le plafond ; sinon la valeur plafonnee. + */ +export function clampRevenueAmount(value: string | null | undefined): string | null | undefined { + if (value === null || value === undefined || value === '') { + return value + } + const n = Number(String(value).replace(/\s/g, '').replace(',', '.')) + if (Number.isNaN(n)) { + return value + } + return n > REVENUE_AMOUNT_MAX ? REVENUE_AMOUNT_MAX_MODEL : value +} diff --git a/src/Module/Commercial/Domain/Entity/Client.php b/src/Module/Commercial/Domain/Entity/Client.php index d0e9eb4..478cf26 100644 --- a/src/Module/Commercial/Domain/Entity/Client.php +++ b/src/Module/Commercial/Domain/Entity/Client.php @@ -233,7 +233,10 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa #[Groups(['client:read', 'client:write:information'])] private ?int $employeesCount = null; + // Chiffre d'affaires plafonne a 999 999 999 999,99 (12 chiffres, retour metier + // ERP-193). La colonne (decimal 15,2) tolere plus, mais le metier borne la saisie. #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)] + #[Assert\LessThanOrEqual(value: 999_999_999_999.99, message: 'Le chiffre d\'affaires ne peut pas dépasser 999 999 999 999,99.')] #[Groups(['client:read', 'client:write:information'])] private ?string $revenueAmount = null; diff --git a/src/Module/Commercial/Domain/Entity/Supplier.php b/src/Module/Commercial/Domain/Entity/Supplier.php index fbd9c4d..a1ca155 100644 --- a/src/Module/Commercial/Domain/Entity/Supplier.php +++ b/src/Module/Commercial/Domain/Entity/Supplier.php @@ -212,7 +212,10 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt #[Groups(['supplier:read', 'supplier:write:information'])] private ?int $employeesCount = null; + // Chiffre d'affaires plafonne a 999 999 999 999,99 (12 chiffres, retour metier + // ERP-193). La colonne (decimal 15,2) tolere plus, mais le metier borne la saisie. #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)] + #[Assert\LessThanOrEqual(value: 999_999_999_999.99, message: 'Le chiffre d\'affaires ne peut pas dépasser 999 999 999 999,99.')] #[Groups(['supplier:read', 'supplier:write:information'])] private ?string $revenueAmount = null; diff --git a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php index 74374e7..5a6790c 100644 --- a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php +++ b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php @@ -95,6 +95,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase Assert\Positive::class, Assert\NegativeOrZero::class, Assert\Negative::class, + Assert\LessThanOrEqual::class, ]; public function testEveryConstraintHasAnExplicitFrenchMessage(): void @@ -302,7 +303,9 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase Assert\Length::class => new Assert\Length(max: 1), Assert\Count::class => new Assert\Count(min: 1), Assert\Regex::class => new Assert\Regex(pattern: '/^x$/'), - default => new $class(), + // AbstractComparison exige value|propertyPath des l'instanciation. + Assert\LessThanOrEqual::class => new Assert\LessThanOrEqual(value: 0), + default => new $class(), }; $value = $bare->{$prop} ?? null; diff --git a/tests/Module/Commercial/Api/RevenueAmountCapTest.php b/tests/Module/Commercial/Api/RevenueAmountCapTest.php new file mode 100644 index 0000000..e49af93 --- /dev/null +++ b/tests/Module/Commercial/Api/RevenueAmountCapTest.php @@ -0,0 +1,81 @@ + 422 porte sur revenueAmount. */ + public function testClientRevenueAmountAuDelaDuPlafondEst422(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('CA Cap Client SARL'); + + $body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['revenueAmount' => '1000000000000.00'], + ])->toArray(false); + + self::assertResponseStatusCodeSame(422); + self::assertArrayHasKey('revenueAmount', $this->violationsByPath($body)); + } + + /** Client : CA exactement au plafond -> accepte (200). */ + public function testClientRevenueAmountAuPlafondEst200(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('CA Max Client SARL'); + + $client->request('PATCH', '/api/clients/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['revenueAmount' => self::MAX], + ]); + + self::assertResponseStatusCodeSame(200); + } + + /** Fournisseur : CA au-dela du plafond -> 422 porte sur revenueAmount. */ + public function testSupplierRevenueAmountAuDelaDuPlafondEst422(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('CA Cap Fournisseur SARL'); + + $body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['revenueAmount' => '1000000000000.00'], + ])->toArray(false); + + self::assertResponseStatusCodeSame(422); + self::assertArrayHasKey('revenueAmount', $this->violationsByPath($body)); + } + + /** Fournisseur : CA exactement au plafond -> accepte (200). */ + public function testSupplierRevenueAmountAuPlafondEst200(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('CA Max Fournisseur SARL'); + + $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['revenueAmount' => self::MAX], + ]); + + self::assertResponseStatusCodeSame(200); + } +}