Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3ab23ee8f | |||
| 222338e5a4 |
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.99'
|
||||
app.version: '0.1.100'
|
||||
|
||||
@@ -956,35 +956,21 @@ function askRemoveRib(index: number): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting,
|
||||
* exige accounting.manage cote back) PUIS DELETE/POST/PATCH des RIB sur la
|
||||
* sous-ressource. Aucun champ main/information dans le payload (mode strict
|
||||
* RG-1.28 : sinon 403 sur tout le payload).
|
||||
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
||||
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
|
||||
* back) PUIS DELETE des RIB retires. Les RIB crees d'abord : le back valide RG-1.13
|
||||
* (LCR => au moins un RIB persiste) sur le PATCH scalaires ; les suppressions en
|
||||
* dernier (le guard back n'autorise la suppression du dernier RIB qu'une fois quitte
|
||||
* LCR). Aucun champ main/information dans le payload (mode strict RG-1.28 : sinon
|
||||
* 403 sur tout le payload).
|
||||
*/
|
||||
async function submitAccounting(): Promise<void> {
|
||||
if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
accountingErrors.clearErrors()
|
||||
// Reset des erreurs RIB des le debut : l'etape 1 (PATCH scalaires) peut
|
||||
// echouer et `return` avant submitRows (qui porte sinon le reset), laissant
|
||||
// des erreurs de RIB obsoletes affichees sous les blocs.
|
||||
ribErrors.value = []
|
||||
try {
|
||||
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||
try {
|
||||
await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false })
|
||||
}
|
||||
catch (error) {
|
||||
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
|
||||
return
|
||||
}
|
||||
|
||||
for (const id of removedRibIds.value) {
|
||||
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedRibIds.value = []
|
||||
|
||||
// 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes).
|
||||
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
|
||||
// tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2.
|
||||
// Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex.
|
||||
// IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline.
|
||||
const ribHasError = await submitRows(
|
||||
@@ -1011,6 +997,23 @@ async function submitAccounting(): Promise<void> {
|
||||
rib => rib.id === null && isRibBlank(rib),
|
||||
)
|
||||
if (ribHasError) return
|
||||
|
||||
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||
try {
|
||||
await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false })
|
||||
}
|
||||
catch (error) {
|
||||
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
|
||||
return
|
||||
}
|
||||
|
||||
// 3) DELETE des RIB retires : APRES le PATCH scalaires (si on quitte LCR, le
|
||||
// guard back n'autorise la suppression du dernier RIB qu'une fois le type change).
|
||||
for (const id of removedRibIds.value) {
|
||||
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedRibIds.value = []
|
||||
|
||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||
}
|
||||
catch (e) {
|
||||
|
||||
@@ -939,37 +939,20 @@ function askRemoveRib(index: number): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting)
|
||||
* PUIS POST des RIB sur /clients/{id}/ribs. Deux appels distincts (mode strict
|
||||
* RG-1.28 : il n'existe pas d'endpoint /accounting, cf. recon back).
|
||||
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur /clients/{id}/ribs PUIS
|
||||
* PATCH des scalaires (groupe client:write:accounting). Les RIB d'abord : le back
|
||||
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires, les RIB
|
||||
* doivent donc exister en base AVANT (sinon 422 « Au moins un RIB est obligatoire
|
||||
* pour le type de reglement LCR »). Deux appels distincts (mode strict RG-1.28 :
|
||||
* il n'existe pas d'endpoint /accounting, cf. recon back).
|
||||
*/
|
||||
async function submitAccounting(): Promise<void> {
|
||||
if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
accountingErrors.clearErrors()
|
||||
// Reset des erreurs RIB des le debut : l'etape 1 (PATCH scalaires) peut
|
||||
// echouer et `return` avant submitRows (qui porte sinon le reset), laissant
|
||||
// des erreurs de RIB obsoletes affichees sous les blocs.
|
||||
ribErrors.value = []
|
||||
try {
|
||||
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||
try {
|
||||
await api.patch(`/clients/${clientId.value}`, {
|
||||
siren: accounting.siren || null,
|
||||
accountNumber: accounting.accountNumber || null,
|
||||
tvaMode: accounting.tvaModeIri,
|
||||
nTva: accounting.nTva || null,
|
||||
paymentDelay: accounting.paymentDelayIri,
|
||||
paymentType: accounting.paymentTypeIri,
|
||||
bank: isBankRequired.value ? accounting.bankIri : null,
|
||||
}, { toast: false })
|
||||
}
|
||||
catch (error) {
|
||||
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
|
||||
return
|
||||
}
|
||||
|
||||
// 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes).
|
||||
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
|
||||
// tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2.
|
||||
// Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex.
|
||||
// IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline.
|
||||
const ribHasError = await submitRows(
|
||||
@@ -997,6 +980,23 @@ async function submitAccounting(): Promise<void> {
|
||||
)
|
||||
if (ribHasError) return
|
||||
|
||||
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||
try {
|
||||
await api.patch(`/clients/${clientId.value}`, {
|
||||
siren: accounting.siren || null,
|
||||
accountNumber: accounting.accountNumber || null,
|
||||
tvaMode: accounting.tvaModeIri,
|
||||
nTva: accounting.nTva || null,
|
||||
paymentDelay: accounting.paymentDelayIri,
|
||||
paymentType: accounting.paymentTypeIri,
|
||||
bank: isBankRequired.value ? accounting.bankIri : null,
|
||||
}, { toast: false })
|
||||
}
|
||||
catch (error) {
|
||||
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
|
||||
return
|
||||
}
|
||||
|
||||
completeTab('accounting')
|
||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
* comptable et la conformite, cf. spec § 2.5 / § 6.1).
|
||||
*
|
||||
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony au M1
|
||||
* (HP-M2-14 : pas de controle externe banque reelle). Timestampable/Blamable
|
||||
* standard.
|
||||
* (HP-M2-14 : pas de controle externe banque reelle), avec controle croise pays
|
||||
* BIC/IBAN (ibanPropertyPath). Timestampable/Blamable standard.
|
||||
*
|
||||
* Sous-ressource API (ERP-57, spec § 4.5) — gating comptable renforce :
|
||||
* - POST /api/clients/{clientId}/ribs : creation rattachee au client parent
|
||||
@@ -109,9 +109,15 @@ class ClientRib implements TimestampableInterface, BlamableInterface
|
||||
|
||||
// Bic/Iban bornent deja le format (et donc la longueur) : pas de Length
|
||||
// redondant calee sur la colonne (whitelist du garde-fou ERP-107).
|
||||
// ibanPropertyPath : controle croise — le pays du BIC (positions 5-6) doit
|
||||
// correspondre au pays de l'IBAN (positions 1-2). Violation portee sur `bic`.
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Bic(message: 'Le BIC n\'est pas valide.')]
|
||||
#[Assert\Bic(
|
||||
message: 'Le BIC n\'est pas valide.',
|
||||
ibanPropertyPath: 'iban',
|
||||
ibanMessage: 'Le BIC ne correspond pas au pays de l\'IBAN.',
|
||||
)]
|
||||
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
|
||||
private ?string $bic = null;
|
||||
|
||||
|
||||
@@ -44,7 +44,8 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
* Tout passe par le SupplierRibProcessor (RG-2.08 sur DELETE).
|
||||
*
|
||||
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony (pas de controle
|
||||
* banque reelle). Audite (#[Auditable]) + Timestampable / Blamable.
|
||||
* banque reelle), avec controle croise pays BIC/IBAN (ibanPropertyPath). Audite
|
||||
* (#[Auditable]) + Timestampable / Blamable.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
@@ -105,9 +106,15 @@ class SupplierRib implements TimestampableInterface, BlamableInterface
|
||||
|
||||
// Bic/Iban bornent deja le format (et donc la longueur) : pas de Length
|
||||
// redondant calee sur la colonne (auto-exempte du miroir ERP-107).
|
||||
// ibanPropertyPath : controle croise — le pays du BIC (positions 5-6) doit
|
||||
// correspondre au pays de l'IBAN (positions 1-2). Violation portee sur `bic`.
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Bic(message: 'Le BIC n\'est pas valide.')]
|
||||
#[Assert\Bic(
|
||||
message: 'Le BIC n\'est pas valide.',
|
||||
ibanPropertyPath: 'iban',
|
||||
ibanMessage: 'Le BIC ne correspond pas au pays de l\'IBAN.',
|
||||
)]
|
||||
#[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
|
||||
private ?string $bic = null;
|
||||
|
||||
|
||||
@@ -51,6 +51,9 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
|
||||
/** IBAN/BIC valides (Assert\Iban / Assert\Bic) reutilises par les seeds. */
|
||||
protected const string VALID_IBAN = 'FR1420041010050500013M02606';
|
||||
protected const string VALID_BIC = 'BNPAFRPPXXX';
|
||||
// BIC allemand valide isolement (pays DE en positions 5-6) : sert au controle
|
||||
// croise pays BIC/IBAN (DE vs IBAN FR -> mismatch, cf. Assert\Bic ibanPropertyPath).
|
||||
protected const string FOREIGN_BIC = 'DEUTDEFFXXX';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
@@ -295,6 +298,26 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
|
||||
return $this->referential(Bank::class, $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexe les violations d'un corps de reponse 422 par propertyPath. Permet
|
||||
* d'asserter qu'un 422 porte bien sur le champ attendu (et n'est pas un 422
|
||||
* orthogonal) : un test qui se contente du code 422 passerait meme si la RG
|
||||
* visee etait cassee pour une autre raison.
|
||||
*
|
||||
* @param array<string, mixed> $body corps decode de la reponse (toArray(false))
|
||||
*
|
||||
* @return array<string, string> propertyPath => message
|
||||
*/
|
||||
protected function violationsByPath(array $body): array
|
||||
{
|
||||
$byPath = [];
|
||||
foreach ($body['violations'] ?? [] as $v) {
|
||||
$byPath[$v['propertyPath']] = $v['message'];
|
||||
}
|
||||
|
||||
return $byPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere un referentiel comptable seede (CommercialReferentialFixtures) par
|
||||
* code. Echoue explicitement si absent (fixtures non chargees).
|
||||
@@ -316,24 +339,4 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexe les violations d'un corps de reponse 422 par propertyPath. Permet
|
||||
* d'asserter qu'un 422 porte bien sur le champ attendu (et n'est pas un 422
|
||||
* orthogonal) : un test qui se contente du code 422 passerait meme si la RG
|
||||
* visee etait cassee pour une autre raison.
|
||||
*
|
||||
* @param array<string, mixed> $body corps decode de la reponse (toArray(false))
|
||||
*
|
||||
* @return array<string, string> propertyPath => message
|
||||
*/
|
||||
protected function violationsByPath(array $body): array
|
||||
{
|
||||
$byPath = [];
|
||||
foreach ($body['violations'] ?? [] as $v) {
|
||||
$byPath[$v['propertyPath']] = $v['message'];
|
||||
}
|
||||
|
||||
return $byPath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
|
||||
private const string MERGE = 'application/merge-patch+json';
|
||||
private const string VALID_IBAN = 'FR1420041010050500013M02606';
|
||||
private const string VALID_BIC = 'BNPAFRPPXXX';
|
||||
// BIC allemand valide isolement (pays DE en positions 5-6) : sert au controle
|
||||
// croise pays BIC/IBAN (DE vs IBAN FR -> mismatch, cf. Assert\Bic ibanPropertyPath).
|
||||
private const string FOREIGN_BIC = 'DEUTDEFFXXX';
|
||||
|
||||
// === Contacts ===
|
||||
|
||||
@@ -359,6 +362,35 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
/**
|
||||
* Controle croise pays BIC/IBAN (Assert\Bic ibanPropertyPath) : un BIC (DE) et
|
||||
* un IBAN (FR) valides isolement mais de pays differents -> 422. La violation
|
||||
* porte propertyPath=bic et le message FR `ibanMessage` (mapping inline front).
|
||||
*/
|
||||
public function testPostRibWithBicIbanCountryMismatchReturns422WithFrenchMessageOnBic(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Rib Pays Mismatch');
|
||||
|
||||
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => [
|
||||
'label' => 'Compte incoherent',
|
||||
'bic' => self::FOREIGN_BIC,
|
||||
'iban' => self::VALID_IBAN,
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
$byPath = [];
|
||||
foreach ($response->toArray(false)['violations'] ?? [] as $v) {
|
||||
$byPath[$v['propertyPath']] = $v['message'];
|
||||
}
|
||||
|
||||
self::assertArrayHasKey('bic', $byPath, 'Le mismatch pays BIC/IBAN doit porter propertyPath=bic (mapping front).');
|
||||
self::assertSame('Le BIC ne correspond pas au pays de l\'IBAN.', $byPath['bic']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression ERP-110 : POST d'un RIB sur un client qui en a DEJA >= 2 ne doit
|
||||
* pas exploser en 500 (NonUniqueResult sur la resolution du parent). L'admin
|
||||
|
||||
@@ -294,6 +294,27 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
/**
|
||||
* Controle croise pays BIC/IBAN (Assert\Bic ibanPropertyPath) : un BIC (DE) et
|
||||
* un IBAN (FR) valides isolement mais de pays differents -> 422. La violation
|
||||
* porte propertyPath=bic et le message FR `ibanMessage` (mapping inline front).
|
||||
*/
|
||||
public function testPostRibWithBicIbanCountryMismatchReturns422WithFrenchMessageOnBic(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedSupplier('Rib Pays Mismatch');
|
||||
|
||||
$response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => ['label' => 'Compte incoherent', 'bic' => self::FOREIGN_BIC, 'iban' => self::VALID_IBAN],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
$byPath = $this->violationsByPath($response->toArray(false));
|
||||
self::assertArrayHasKey('bic', $byPath, 'Le mismatch pays BIC/IBAN doit porter propertyPath=bic (mapping front).');
|
||||
self::assertSame('Le BIC ne correspond pas au pays de l\'IBAN.', $byPath['bic']);
|
||||
}
|
||||
|
||||
public function testDeleteRibNonLcrReturns204(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
Reference in New Issue
Block a user