feat(commercial) : plafonne le chiffre d'affaires client/fournisseur à 999 999 999 999,99 (ERP-193)
This commit is contained in:
@@ -125,11 +125,15 @@
|
||||
:readonly="businessReadonly"
|
||||
:error="informationErrors.errors.employeesCount"
|
||||
/>
|
||||
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
|
||||
:key force le re-affichage quand on plafonne (modelValue inchange). -->
|
||||
<MalioInputAmount
|
||||
v-model="information.revenueAmount"
|
||||
:key="revenueAmountKey"
|
||||
:model-value="information.revenueAmount"
|
||||
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||
:readonly="businessReadonly"
|
||||
:error="informationErrors.errors.revenueAmount"
|
||||
@update:model-value="onRevenueAmountInput"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.directorName"
|
||||
@@ -423,6 +427,7 @@ import {
|
||||
type InformationFormDraft,
|
||||
type MainFormDraft,
|
||||
} from '~/modules/commercial/utils/forms/clientEdit'
|
||||
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
|
||||
import {
|
||||
buildClientFormTabKeys,
|
||||
isAddressValid,
|
||||
@@ -491,6 +496,19 @@ const headerTitle = computed(() => client.value?.companyName ?? t('commercial.cl
|
||||
const main = reactive<MainFormDraft>(mapMainDraft({} as ClientDetail))
|
||||
const information = reactive<InformationFormDraft>(mapInformationDraft({} as ClientDetail))
|
||||
const accounting = reactive<AccountingFormDraft>(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<ContactFormDraft[]>([])
|
||||
const addresses = ref<AddressFormDraft[]>([])
|
||||
const ribs = ref<RibFormDraft[]>([])
|
||||
|
||||
@@ -120,11 +120,15 @@
|
||||
:readonly="isValidated('information')"
|
||||
:error="informationErrors.errors.employeesCount"
|
||||
/>
|
||||
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
|
||||
:key force le re-affichage quand on plafonne (modelValue inchange). -->
|
||||
<MalioInputAmount
|
||||
v-model="information.revenueAmount"
|
||||
:key="revenueAmountKey"
|
||||
:model-value="information.revenueAmount"
|
||||
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||
:readonly="isValidated('information')"
|
||||
:error="informationErrors.errors.revenueAmount"
|
||||
@update:model-value="onRevenueAmountInput"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.directorName"
|
||||
@@ -407,6 +411,7 @@ import {
|
||||
lastFillableTabKey,
|
||||
showsRelationAndTriageFields,
|
||||
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
|
||||
import {
|
||||
buildAddressPayload,
|
||||
buildMainPayload,
|
||||
@@ -665,6 +670,19 @@ const information = reactive({
|
||||
directorName: null as string | null,
|
||||
})
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
|
||||
async function submitInformation(): Promise<void> {
|
||||
if (clientId.value === null || tabSubmitting.value) return
|
||||
|
||||
@@ -86,11 +86,15 @@
|
||||
:readonly="businessReadonly"
|
||||
:error="informationErrors.errors.employeesCount"
|
||||
/>
|
||||
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
|
||||
:key force le re-affichage quand on plafonne (modelValue inchange). -->
|
||||
<MalioInputAmount
|
||||
v-model="information.revenueAmount"
|
||||
:key="revenueAmountKey"
|
||||
:model-value="information.revenueAmount"
|
||||
:label="t('commercial.suppliers.form.information.revenueAmount')"
|
||||
:readonly="businessReadonly"
|
||||
:error="informationErrors.errors.revenueAmount"
|
||||
@update:model-value="onRevenueAmountInput"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.directorName"
|
||||
@@ -392,6 +396,7 @@ import {
|
||||
type MainFormDraft,
|
||||
type SupplierEditAbilities,
|
||||
} from '~/modules/commercial/utils/forms/supplierEdit'
|
||||
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
|
||||
import {
|
||||
buildSupplierFormTabKeys,
|
||||
isAddressValid,
|
||||
@@ -457,6 +462,19 @@ const headerTitle = computed(() => supplier.value?.companyName ?? t('commercial.
|
||||
const main = reactive<MainFormDraft>(mapMainDraft({} as SupplierDetail))
|
||||
const information = reactive<InformationFormDraft>(mapInformationDraft({} as SupplierDetail))
|
||||
const accounting = reactive<AccountingFormDraft>(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<SupplierContactFormDraft[]>([])
|
||||
const addresses = ref<SupplierAddressFormDraft[]>([])
|
||||
const ribs = ref<SupplierRibFormDraft[]>([])
|
||||
|
||||
@@ -80,11 +80,15 @@
|
||||
:readonly="isValidated('information')"
|
||||
:error="informationErrors.errors.employeesCount"
|
||||
/>
|
||||
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
|
||||
:key force le re-affichage quand on plafonne (modelValue inchange). -->
|
||||
<MalioInputAmount
|
||||
v-model="information.revenueAmount"
|
||||
:key="revenueAmountKey"
|
||||
:model-value="information.revenueAmount"
|
||||
:label="t('commercial.suppliers.form.information.revenueAmount')"
|
||||
:readonly="isValidated('information')"
|
||||
:error="informationErrors.errors.revenueAmount"
|
||||
@update:model-value="onRevenueAmountInput"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.directorName"
|
||||
@@ -375,6 +379,7 @@ import {
|
||||
buildMainPayload,
|
||||
buildRibPayload,
|
||||
} from '~/modules/commercial/utils/forms/supplierEdit'
|
||||
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
|
||||
import {
|
||||
emptyAddress,
|
||||
emptyContact,
|
||||
@@ -564,6 +569,19 @@ const information = reactive({
|
||||
volumeForecast: null as string | null,
|
||||
})
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
/** PATCH /suppliers/{id} — mode strict : uniquement les champs du groupe information. */
|
||||
async function submitInformation(): Promise<void> {
|
||||
if (supplierId.value === null || tabSubmitting.value) return
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
/**
|
||||
* Validation back-autoritative du plafond du chiffre d'affaires (revenueAmount,
|
||||
* onglet Information) sur Client ET Fournisseur — retour metier ERP-193.
|
||||
*
|
||||
* Le CA est plafonne a 999 999 999 999,99 (12 chiffres). La colonne decimal(15,2)
|
||||
* tolererait plus, mais le metier borne la saisie : au-dela, 422 porte sur
|
||||
* `revenueAmount` (mappable inline par useFormErrors). La valeur exactement egale
|
||||
* au plafond reste acceptee. Le front clampe deja la saisie (amountInput.ts), mais
|
||||
* le back reste la couche autoritaire.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class RevenueAmountCapTest extends AbstractSupplierApiTestCase
|
||||
{
|
||||
/** Plafond metier : 12 chiffres + 2 decimales. */
|
||||
private const string MAX = '999999999999.99';
|
||||
|
||||
/** Client : CA au-dela du plafond -> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user