fix(commercial) : conserver le RIB au changement de type de reglement hors-LCR (ERP-121)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m14s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m38s

Le passage d'un tiers de LCR vers virement (ou autre) supprimait ses RIB en base
via le front (DELETE differe). Le RIB est une coordonnee bancaire du tiers,
decouplee du mode de reglement : on le conserve desormais pour un eventuel retour
en LCR.

Clients ET fournisseurs (new.vue / [id]/edit.vue) :
- onPaymentTypeChange ne marque plus les RIB existants pour suppression et ne vide
  plus la saisie ; les RIB sont seulement masques (visibleRibs) et reapparaissent
  tels quels au retour LCR.
- submitAccounting ne (re)soumet les RIB que sous LCR ; seules les suppressions
  EXPLICITES (corbeille d'un bloc) restent en DELETE.

Consultation ([id]/index.vue) : RIB dormants totalement masques hors-LCR, via le
helper pur type-safe paymentTypeCodeOf (clientConsultation / supplierConsultation)
+ tests Vitest.

Aucune modification back (RG LCR -> >=1 RIB deja correcte, rien n'interdit un RIB
sur un tiers non-LCR) ni migration.
This commit is contained in:
2026-06-11 12:00:37 +02:00
parent 431d831c8b
commit 4d20583e1b
10 changed files with 252 additions and 147 deletions
@@ -912,17 +912,16 @@ const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
function onPaymentTypeChange(value: string | number | null): void { function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value) accounting.paymentTypeIri = value === null ? null : String(value)
if (!isBankRequired.value) accounting.bankIri = null if (!isBankRequired.value) accounting.bankIri = null
// Les RIB n'ont de sens que pour une LCR (RG-1.13) : on amorce un bloc vide // ERP-121 : un RIB est une coordonnee bancaire du client, decouplee du mode de
// quand LCR est choisi, sinon on vide la liste — les RIB deja persistes sont // reglement. Au passage hors-LCR on ne SUPPRIME plus les RIB existants : ils
// marques pour suppression serveur au prochain enregistrement. // restent en base, simplement masques a l'ecran (visibleRibs = []), et
// reapparaissent tels quels si l'on repasse en LCR. Seule la corbeille d'un
// bloc (askRemoveRib) retire reellement un RIB.
if (isRibRequired.value) { if (isRibRequired.value) {
if (ribs.value.length === 0) ribs.value.push(emptyRib()) if (ribs.value.length === 0) ribs.value.push(emptyRib())
} }
else { else {
for (const rib of ribs.value) { // Hors-LCR : on nettoie seulement les erreurs inline (plus affichees).
if (rib.id != null) removedRibIds.value.push(rib.id)
}
ribs.value = []
ribErrors.value = [] ribErrors.value = []
} }
} }
@@ -951,50 +950,58 @@ function askRemoveRib(index: number): void {
/** /**
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS * Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote * 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 * back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le back
* (LCR => au moins un RIB persiste) sur le PATCH scalaires ; les suppressions en * valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
* 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 * ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
* 403 sur tout le payload). * coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
* (corbeille d'un bloc, toujours sous LCR), plus l'auto-suppression au changement
* de type de reglement. Aucun champ main/information dans le payload (mode strict
* RG-1.28 : sinon 403 sur tout le payload).
*/ */
async function submitAccounting(): Promise<void> { async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || tabSubmitting.value) return if (accountingReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
accountingErrors.clearErrors() accountingErrors.clearErrors()
try { try {
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs // 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
// tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2. // ligne, tous les blocs tentes). Le back exige >=1 RIB persiste pour valider
// une LCR a l'etape 2. Hors-LCR (ERP-121), les RIB sont des coordonnees
// dormantes : rien d'editable n'est affiche, on ne les re-soumet pas.
// On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable : // On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable :
// sinon (ex. l'unique RIB existant supprime, remplace par un bloc vide), on la // sinon (ex. l'unique RIB existant supprime, remplace par un bloc vide), on la
// soumet pour declencher la 422 NotBlank inline plutot que de laisser le DELETE // soumet pour declencher la 422 NotBlank inline plutot que de laisser le DELETE
// echouer en « dernier RIB d'une LCR » (message plat sans propertyPath). // echouer en « dernier RIB d'une LCR » (message plat sans propertyPath).
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r)) if (isRibRequired.value) {
const ribHasError = await submitRows( const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
ribs.value, const ribHasError = await submitRows(
ribErrors, ribs.value,
async (rib) => { ribErrors,
// Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank async (rib) => {
// 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur). // Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank
const body = buildRibPayload(rib, { forUpdate: rib.id !== null }) // 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur).
if (rib.id === null) { const body = buildRibPayload(rib, { forUpdate: rib.id !== null })
const created = await api.post<{ id: number }>( if (rib.id === null) {
`/clients/${clientId}/ribs`, const created = await api.post<{ id: number }>(
body, `/clients/${clientId}/ribs`,
{ headers: { Accept: 'application/ld+json' }, toast: false }, body,
) { headers: { Accept: 'application/ld+json' }, toast: false },
rib.id = created.id )
} rib.id = created.id
else { }
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false }) else {
} await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
}, }
error => showError(error), },
// On ne saute une amorce neuve (id null) totalement vide que si un autre RIB error => showError(error),
// est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank // On ne saute une amorce neuve (id null) totalement vide que si un autre RIB
// inline (sinon la modif serait perdue en silence avec un faux toast succes). // est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib), // inline (sinon la modif serait perdue en silence avec un faux toast succes).
) rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
if (ribHasError) return )
if (ribHasError) return
}
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs). // 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try { try {
@@ -1005,8 +1012,9 @@ async function submitAccounting(): Promise<void> {
return return
} }
// 3) DELETE des RIB retires : APRES le PATCH scalaires (si on quitte LCR, le // 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
// guard back n'autorise la suppression du dernier RIB qu'une fois le type change). // PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
for (const id of removedRibIds.value) { for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false }) await api.delete(`/client_ribs/${id}`, {}, { toast: false })
} }
@@ -280,7 +280,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient' import { useClient } from '~/modules/commercial/composables/useClient'
import { buildClientFormTabKeys } from '~/modules/commercial/utils/clientFormRules' import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/clientFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab' import { readHistoryTab } from '~/shared/utils/historyTab'
import { import {
canEditClient, canEditClient,
@@ -290,6 +290,7 @@ import {
mapAddressView, mapAddressView,
mapContactToDraft, mapContactToDraft,
mapRibToDraft, mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf, referentialOptionOf,
relationOf, relationOf,
showArchiveAction, showArchiveAction,
@@ -355,9 +356,16 @@ const addressViews = computed(() => {
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }] return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
}) })
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le // Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le
// client n'en a pas (un RIB n'existe que pour un reglement LCR — RG-1.13). Pas // client n'en a pas. Pas de bloc vierge fantome en consultation.
// de bloc vierge fantome en consultation. // ERP-121 : un client peut desormais conserver des RIB « dormants » apres etre
const ribs = computed(() => (client.value?.ribs ?? []).map(mapRibToDraft)) // repasse hors-LCR (on ne les supprime plus). En consultation, decision metier =
// on les masque TOTALEMENT : on n'affiche les RIB que si le type de reglement
// courant est LCR (le `code` est embarque sous client:read:accounting).
const ribs = computed(() =>
isRibRequiredForPaymentType(paymentTypeCodeOf(client.value?.paymentType))
? (client.value?.ribs ?? []).map(mapRibToDraft)
: [],
)
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view). // Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail))) const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail)))
@@ -881,13 +881,14 @@ function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value) accounting.paymentTypeIri = value === null ? null : String(value)
// La banque n'a de sens que pour un virement : on la vide sinon (RG-1.12). // La banque n'a de sens que pour un virement : on la vide sinon (RG-1.12).
if (!isBankRequired.value) accounting.bankIri = null if (!isBankRequired.value) accounting.bankIri = null
// Les RIB n'ont de sens que pour une LCR (RG-1.13) : on amorce un bloc vide // ERP-121 : on ne jette plus la saisie RIB au passage hors-LCR. Les blocs sont
// quand LCR est choisi, on vide la liste sinon (pas de RIB fantome soumis). // masques (visibleRibs = []) mais conserves, et reapparaissent si l'on repasse
// en LCR. Ils ne sont persistes qu'a la validation SOUS LCR (cf. submitAccounting),
// donc une saisie abandonnee hors-LCR ne cree aucun RIB orphelin.
if (isRibRequired.value) { if (isRibRequired.value) {
if (ribs.value.length === 0) ribs.value.push(emptyRib()) if (ribs.value.length === 0) ribs.value.push(emptyRib())
} }
else { else {
ribs.value = []
ribErrors.value = [] ribErrors.value = []
} }
} }
@@ -924,36 +925,41 @@ async function submitAccounting(): Promise<void> {
tabSubmitting.value = true tabSubmitting.value = true
accountingErrors.clearErrors() accountingErrors.clearErrors()
try { try {
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs // 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
// tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2. // ligne, tous les blocs tentes). Le back exige >=1 RIB persiste pour valider
// une LCR a l'etape 2. Hors-LCR (ERP-121), une saisie RIB eventuellement
// restee dans le brouillon est masquee et n'est PAS persistee (pas de RIB
// orphelin sur un client en virement).
// On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable : // On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable :
// sinon (LCR sans aucun RIB rempli) on la soumet -> 422 NotBlank inline. // sinon (LCR sans aucun RIB rempli) on la soumet -> 422 NotBlank inline.
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r)) if (isRibRequired.value) {
const ribHasError = await submitRows( const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
ribs.value, const ribHasError = await submitRows(
ribErrors, ribs.value,
async (rib) => { ribErrors,
// Payload partage avec l'edition (buildRibPayload, ERP-119). async (rib) => {
const body = buildRibPayload(rib) // Payload partage avec l'edition (buildRibPayload, ERP-119).
if (rib.id === null) { const body = buildRibPayload(rib)
const created = await api.post<{ id: number }>( if (rib.id === null) {
`/clients/${clientId.value}/ribs`, const created = await api.post<{ id: number }>(
body, `/clients/${clientId.value}/ribs`,
{ headers: { Accept: 'application/ld+json' }, toast: false }, body,
) { headers: { Accept: 'application/ld+json' }, toast: false },
rib.id = created.id )
} rib.id = created.id
else { }
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false }) else {
} await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
}, }
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }), },
// On ne saute une amorce neuve (id null) totalement vide que si un autre RIB error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
// est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank // On ne saute une amorce neuve (id null) totalement vide que si un autre RIB
// inline (sinon la modif serait perdue en silence avec un faux toast succes). // est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib), // inline (sinon la modif serait perdue en silence avec un faux toast succes).
) rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
if (ribHasError) return )
if (ribHasError) return
}
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs). // 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try { try {
@@ -801,17 +801,16 @@ const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
function onPaymentTypeChange(value: string | number | null): void { function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value) accounting.paymentTypeIri = value === null ? null : String(value)
if (!isBankRequired.value) accounting.bankIri = null if (!isBankRequired.value) accounting.bankIri = null
// Les RIB n'ont de sens que pour une LCR (RG-2.08) : on amorce un bloc vide // ERP-121 : un RIB est une coordonnee bancaire du fournisseur, decouplee du mode
// quand LCR est choisi, sinon on vide la liste — les RIB deja persistes sont // de reglement. Au passage hors-LCR on ne SUPPRIME plus les RIB existants : ils
// marques pour suppression serveur au prochain enregistrement. // restent en base, simplement masques a l'ecran (visibleRibs = []), et
// reapparaissent tels quels si l'on repasse en LCR. Seule la corbeille d'un
// bloc (askRemoveRib) retire reellement un RIB.
if (isRibRequired.value) { if (isRibRequired.value) {
if (ribs.value.length === 0) ribs.value.push(emptyRib()) if (ribs.value.length === 0) ribs.value.push(emptyRib())
} }
else { else {
for (const rib of ribs.value) { // Hors-LCR : on nettoie seulement les erreurs inline (plus affichees).
if (rib.id != null) removedRibIds.value.push(rib.id)
}
ribs.value = []
ribErrors.value = [] ribErrors.value = []
} }
} }
@@ -840,44 +839,53 @@ function askRemoveRib(index: number): void {
/** /**
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS * Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
* PATCH des scalaires (groupe supplier:write:accounting, exige accounting.manage * PATCH des scalaires (groupe supplier:write:accounting, exige accounting.manage
* cote back) PUIS DELETE des RIB retires. Les RIB crees d'abord : le back valide * cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
* RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires. Aucun champ * back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
* main/information dans le payload (mode strict RG-2.16 : sinon 403 sur tout le payload). *
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
* (corbeille d'un bloc, toujours sous LCR). Aucun champ main/information dans le
* payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
*/ */
async function submitAccounting(): Promise<void> { async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || tabSubmitting.value) return if (accountingReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
accountingErrors.clearErrors() accountingErrors.clearErrors()
try { try {
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs // 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
// tentes). On ne saute une amorce neuve vide QUE s'il reste un autre RIB // ligne, tous les blocs tentes). Hors-LCR (ERP-121), les RIB sont des
// coordonnees dormantes : rien d'editable n'est affiche, on ne les re-soumet
// pas. On ne saute une amorce neuve vide QUE s'il reste un autre RIB
// soumettable : sinon (ex. l'unique RIB existant supprime, remplace par un // soumettable : sinon (ex. l'unique RIB existant supprime, remplace par un
// bloc vide), on la soumet pour declencher la 422 NotBlank inline plutot que // bloc vide), on la soumet pour declencher la 422 NotBlank inline plutot que
// de laisser le DELETE echouer en « dernier RIB d'une LCR » (message plat). // de laisser le DELETE echouer en « dernier RIB d'une LCR » (message plat).
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r)) if (isRibRequired.value) {
const ribHasError = await submitRows( const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
ribs.value, const ribHasError = await submitRows(
ribErrors, ribs.value,
async (rib) => { ribErrors,
// Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank async (rib) => {
// 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur). // Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank
const body = buildRibPayload(rib, { forUpdate: rib.id !== null }) // 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur).
if (rib.id === null) { const body = buildRibPayload(rib, { forUpdate: rib.id !== null })
const created = await api.post<{ id: number }>( if (rib.id === null) {
`/suppliers/${supplierId}/ribs`, const created = await api.post<{ id: number }>(
body, `/suppliers/${supplierId}/ribs`,
{ headers: { Accept: 'application/ld+json' }, toast: false }, body,
) { headers: { Accept: 'application/ld+json' }, toast: false },
rib.id = created.id )
} rib.id = created.id
else { }
await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false }) else {
} await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false })
}, }
error => showError(error), },
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib), error => showError(error),
) rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
if (ribHasError) return )
if (ribHasError) return
}
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs). // 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try { try {
@@ -888,8 +896,9 @@ async function submitAccounting(): Promise<void> {
return return
} }
// 3) DELETE des RIB retires : APRES le PATCH scalaires (si on quitte LCR, le // 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
// guard back n'autorise la suppression du dernier RIB qu'une fois le type change). // PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
for (const id of removedRibIds.value) { for (const id of removedRibIds.value) {
await api.delete(`/supplier_ribs/${id}`, {}, { toast: false }) await api.delete(`/supplier_ribs/${id}`, {}, { toast: false })
} }
@@ -263,7 +263,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useSupplier } from '~/modules/commercial/composables/useSupplier' import { useSupplier } from '~/modules/commercial/composables/useSupplier'
import { buildSupplierFormTabKeys } from '~/modules/commercial/utils/supplierFormRules' import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/supplierFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab' import { readHistoryTab } from '~/shared/utils/historyTab'
import { import {
canEditSupplier, canEditSupplier,
@@ -274,6 +274,7 @@ import {
mapAddressView, mapAddressView,
mapContactToDraft, mapContactToDraft,
mapRibToDraft, mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf, referentialOptionOf,
showArchiveAction, showArchiveAction,
showRestoreAction, showRestoreAction,
@@ -338,8 +339,15 @@ const addressViews = computed(() => {
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }] return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
}) })
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le // Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le
// fournisseur n'en a pas (un RIB n'existe que pour un reglement LCR — RG-2.08). // fournisseur n'en a pas. ERP-121 : un fournisseur peut desormais conserver des RIB
const ribs = computed(() => (supplier.value?.ribs ?? []).map(mapRibToDraft)) // « dormants » apres etre repasse hors-LCR (on ne les supprime plus). En consultation,
// decision metier = on les masque TOTALEMENT : on n'affiche les RIB que si le type de
// reglement courant est LCR (le `code` est embarque sous supplier:read:accounting).
const ribs = computed(() =>
isRibRequiredForPaymentType(paymentTypeCodeOf(supplier.value?.paymentType))
? (supplier.value?.ribs ?? []).map(mapRibToDraft)
: [],
)
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view). // Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
const accounting = computed(() => mapAccountingDraft(supplier.value ?? ({} as SupplierDetail))) const accounting = computed(() => mapAccountingDraft(supplier.value ?? ({} as SupplierDetail)))
@@ -745,13 +745,14 @@ function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value) accounting.paymentTypeIri = value === null ? null : String(value)
// La banque n'a de sens que pour un virement : on la vide sinon (RG-2.07). // La banque n'a de sens que pour un virement : on la vide sinon (RG-2.07).
if (!isBankRequired.value) accounting.bankIri = null if (!isBankRequired.value) accounting.bankIri = null
// Les RIB n'ont de sens que pour une LCR (RG-2.08) : amorce un bloc vide quand // ERP-121 : on ne jette plus la saisie RIB au passage hors-LCR. Les blocs sont
// LCR est choisi, vide la liste sinon (pas de RIB fantome soumis). // masques (visibleRibs = []) mais conserves, et reapparaissent si l'on repasse
// en LCR. Ils ne sont persistes qu'a la validation SOUS LCR (cf. submitAccounting),
// donc une saisie abandonnee hors-LCR ne cree aucun RIB orphelin.
if (isRibRequired.value) { if (isRibRequired.value) {
if (ribs.value.length === 0) ribs.value.push(emptyRib()) if (ribs.value.length === 0) ribs.value.push(emptyRib())
} }
else { else {
ribs.value = []
ribErrors.value = [] ribErrors.value = []
} }
} }
@@ -786,31 +787,36 @@ async function submitAccounting(): Promise<void> {
tabSubmitting.value = true tabSubmitting.value = true
accountingErrors.clearErrors() accountingErrors.clearErrors()
try { try {
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne). On ne saute une // 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
// amorce neuve vide QUE s'il reste un autre RIB soumettable : sinon (LCR sans // ligne). Hors-LCR (ERP-121), une saisie RIB eventuellement restee dans le
// aucun RIB rempli) on la soumet pour declencher la 422 NotBlank inline. // brouillon est masquee et n'est PAS persistee (pas de RIB orphelin sur un
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r)) // fournisseur en virement). On ne saute une amorce neuve vide QUE s'il reste
const ribHasError = await submitRows( // un autre RIB soumettable : sinon (LCR sans aucun RIB rempli) on la soumet
ribs.value, // pour declencher la 422 NotBlank inline.
ribErrors, if (isRibRequired.value) {
async (rib) => { const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
const body = buildRibPayload(rib) const ribHasError = await submitRows(
if (rib.id === null) { ribs.value,
const created = await api.post<{ id: number }>( ribErrors,
`/suppliers/${supplierId.value}/ribs`, async (rib) => {
body, const body = buildRibPayload(rib)
{ headers: { Accept: 'application/ld+json' }, toast: false }, if (rib.id === null) {
) const created = await api.post<{ id: number }>(
rib.id = created.id `/suppliers/${supplierId.value}/ribs`,
} body,
else { { headers: { Accept: 'application/ld+json' }, toast: false },
await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false }) )
} rib.id = created.id
}, }
error => toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(error) }), else {
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib), await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false })
) }
if (ribHasError) return },
error => toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(error) }),
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
)
if (ribHasError) return
}
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs). // 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try { try {
@@ -9,6 +9,7 @@ import {
mapAddressView, mapAddressView,
mapContactToDraft, mapContactToDraft,
mapRibToDraft, mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf, referentialOptionOf,
relationOf, relationOf,
showArchiveAction, showArchiveAction,
@@ -233,3 +234,17 @@ describe('showArchiveAction / showRestoreAction', () => {
expect(showRestoreAction(can([]), true)).toBe(false) expect(showRestoreAction(can([]), true)).toBe(false)
}) })
}) })
describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', () => {
it('retourne le code metier quand le type de reglement est embarque', () => {
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1', code: 'LCR' })).toBe('LCR')
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/2', code: 'VIREMENT' })).toBe('VIREMENT')
})
it('retourne null pour un IRI nu, un objet sans code, ou une relation absente', () => {
expect(paymentTypeCodeOf('/api/payment_types/1')).toBeNull()
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1' })).toBeNull()
expect(paymentTypeCodeOf(null)).toBeNull()
expect(paymentTypeCodeOf(undefined)).toBeNull()
})
})
@@ -9,6 +9,7 @@ import {
mapAddressView, mapAddressView,
mapContactToDraft, mapContactToDraft,
mapRibToDraft, mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf, referentialOptionOf,
showArchiveAction, showArchiveAction,
showRestoreAction, showRestoreAction,
@@ -222,3 +223,17 @@ describe('showArchiveAction / showRestoreAction', () => {
expect(showRestoreAction(can([]), true)).toBe(false) expect(showRestoreAction(can([]), true)).toBe(false)
}) })
}) })
describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', () => {
it('retourne le code metier quand le type de reglement est embarque', () => {
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1', code: 'LCR' })).toBe('LCR')
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/2', code: 'VIREMENT' })).toBe('VIREMENT')
})
it('retourne null pour un IRI nu, un objet sans code, ou une relation absente', () => {
expect(paymentTypeCodeOf('/api/payment_types/1')).toBeNull()
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1' })).toBeNull()
expect(paymentTypeCodeOf(null)).toBeNull()
expect(paymentTypeCodeOf(undefined)).toBeNull()
})
})
@@ -293,6 +293,21 @@ export function referentialOptionOf(relation: Relation): SelectOption[] {
return [{ value: relation['@id'], label }] return [{ value: relation['@id'], label }]
} }
/**
* Code metier d'un referentiel embarque (ex: PaymentType.code = 'LCR' / 'VIREMENT'),
* ou null si la relation est absente / serialisee en IRI nu. Type-safe : la branche
* chaine (IRI nu) et l'absence sont court-circuitees avant l'acces au code. Sert a
* conditionner l'affichage selon le type de reglement courant (ERP-121 : RIB masques
* hors-LCR en consultation).
*/
export function paymentTypeCodeOf(relation: Relation): string | null {
if (!relation || typeof relation === 'string') {
return null
}
return (relation.code as string | undefined) ?? null
}
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */ /** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
export function mapAddressView(address: AddressRead): AddressView { export function mapAddressView(address: AddressRead): AddressView {
return { return {
@@ -268,6 +268,21 @@ export function referentialOptionOf(relation: Relation): SelectOption[] {
return [{ value: relation['@id'], label }] return [{ value: relation['@id'], label }]
} }
/**
* Code metier d'un referentiel embarque (ex: PaymentType.code = 'LCR' / 'VIREMENT'),
* ou null si la relation est absente / serialisee en IRI nu. Type-safe : la branche
* chaine (IRI nu) et l'absence sont court-circuitees avant l'acces au code. Sert a
* conditionner l'affichage selon le type de reglement courant (ERP-121 : RIB masques
* hors-LCR en consultation).
*/
export function paymentTypeCodeOf(relation: Relation): string | null {
if (!relation || typeof relation === 'string') {
return null
}
return (relation.code as string | undefined) ?? null
}
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */ /** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
export function mapAddressView(address: AddressRead): AddressView { export function mapAddressView(address: AddressRead): AddressView {
return { return {