fix(commercial) : conserver le RIB au changement de type de règlement hors-LCR (ERP-121) #86

Merged
tristan merged 1 commits from feature/ERP-121-conserver-rib-changement-type-reglement into develop 2026-06-11 10:05:42 +00:00
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 {