diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 29bf093..85d206e 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -432,12 +432,27 @@ "add": "Nouvelle adresse", "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." }, + "accounting": { + "siren": "SIREN", + "accountNumber": "Numéro de compte", + "tvaMode": "Mode de TVA", + "nTva": "N° de TVA", + "paymentDelay": "Délai de règlement", + "paymentType": "Type de règlement", + "bank": "Banque", + "ribLabel": "Libellé", + "ribBic": "BIC", + "ribIban": "IBAN", + "addRib": "Ajouter un RIB", + "removeRib": "Supprimer le RIB" + }, "confirmDelete": { "title": "Confirmer la suppression", "cancel": "Annuler", "confirm": "Supprimer", "contact": "Supprimer ce contact ?", - "address": "Supprimer cette adresse ?" + "address": "Supprimer cette adresse ?", + "rib": "Supprimer ce RIB ?" } }, "toast": { diff --git a/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts b/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts index a0b6ac1..a4a7c4f 100644 --- a/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts +++ b/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts @@ -19,8 +19,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mockPost = vi.hoisted(() => vi.fn()) const mockPatch = vi.hoisted(() => vi.fn()) -// Permission accounting.view pilotable par test (presence de l'onglet Comptabilite). -const permState = vi.hoisted(() => ({ accountingView: false })) +// Permissions comptables pilotables par test (presence/edition de l'onglet Comptabilite). +const permState = vi.hoisted(() => ({ accountingView: false, accountingManage: false })) vi.stubGlobal('useApi', () => ({ get: vi.fn(), @@ -37,7 +37,11 @@ vi.stubGlobal('useToast', () => ({ info: vi.fn(), })) vi.stubGlobal('usePermissions', () => ({ - can: (perm: string) => perm === 'technique.providers.accounting.view' ? permState.accountingView : true, + can: (perm: string) => { + if (perm === 'technique.providers.accounting.view') return permState.accountingView + if (perm === 'technique.providers.accounting.manage') return permState.accountingManage + return true + }, })) const { useProviderForm, buildProviderCreateTabKeys } = await import('../useProviderForm') @@ -62,6 +66,7 @@ describe('useProviderForm', () => { mockPost.mockReset() mockPatch.mockReset() permState.accountingView = false + permState.accountingManage = false }) it('RG-3.03/RG-3.09 (front) : bloque le POST si aucun site / aucune categorie', async () => { @@ -208,6 +213,7 @@ describe('useProviderForm — onglet Contact (ERP-142)', () => { mockPost.mockReset() mockPatch.mockReset() permState.accountingView = false + permState.accountingManage = false }) /** Place le formulaire en etat « prestataire cree » (onglet Contact accessible). */ @@ -315,6 +321,7 @@ describe('useProviderForm — onglet Adresse (ERP-143)', () => { mockPost.mockReset() mockPatch.mockReset() permState.accountingView = false + permState.accountingManage = false }) /** Place le formulaire en etat « prestataire cree » (onglet Adresse accessible). */ @@ -406,3 +413,175 @@ describe('useProviderForm — onglet Adresse (ERP-143)', () => { expect(form.isValidated('address')).toBe(false) }) }) + +describe('useProviderForm — onglet Comptabilite (ERP-144)', () => { + const TVA = '/api/tva_modes/1' + const DELAY = '/api/payment_delays/1' + const TYPE = '/api/payment_types/3' + const BANK = '/api/banks/2' + + beforeEach(() => { + mockPost.mockReset() + mockPatch.mockReset() + permState.accountingView = true + permState.accountingManage = true + }) + + /** Prestataire cree, onglet Comptabilite editable (view + manage). */ + function createdForm() { + const form = useProviderForm() + form.providerId.value = 7 + return form + } + + /** Remplit les scalaires comptables communs. */ + function fillScalars(form: ProviderForm): void { + form.accounting.siren = '123456789' + form.accounting.accountNumber = '4010' + form.accounting.tvaModeIri = TVA + form.accounting.nTva = 'FR123' + form.accounting.paymentDelayIri = DELAY + form.accounting.paymentTypeIri = TYPE + } + + it('lecture seule sans accounting.manage (Compta consultation / autres roles)', () => { + permState.accountingManage = false + const form = createdForm() + expect(form.accountingReadonly.value).toBe(true) + + permState.accountingManage = true + const form2 = createdForm() + expect(form2.accountingReadonly.value).toBe(false) + }) + + it('RG-3.07 : setPaymentType(VIREMENT) garde la banque ; un autre type la vide', () => { + const form = createdForm() + form.accounting.bankIri = BANK + + // Type VIREMENT -> banque requise, conservee. + form.setPaymentType(TYPE, true, false) + expect(form.accounting.bankIri).toBe(BANK) + + // Type non-VIREMENT -> banque videe (sans objet). + form.setPaymentType(TYPE, false, false) + expect(form.accounting.bankIri).toBeNull() + }) + + it('RG-3.08 : setPaymentType(LCR) garantit au moins un bloc RIB', () => { + const form = createdForm() + expect(form.ribs.value).toHaveLength(0) + + form.setPaymentType(TYPE, false, true) + expect(form.ribs.value).toHaveLength(1) + }) + + it('« + RIB » desactive tant que le dernier RIB est incomplet (RG-3.08)', () => { + const form = createdForm() + form.setPaymentType(TYPE, false, true) + expect(form.canAddRib.value).toBe(false) + + const rib = form.ribs.value[0] + if (rib) { + rib.label = 'Compte' + rib.bic = 'BNPAFRPP' + rib.iban = 'FR76...' + } + expect(form.canAddRib.value).toBe(true) + }) + + it('VIREMENT : PATCH des scalaires avec banque, aucun appel RIB', async () => { + mockPatch.mockResolvedValueOnce({}) + const form = createdForm() + fillScalars(form) + form.accounting.bankIri = BANK + + const ok = await form.submitAccounting(true, false, vi.fn()) + + expect(ok).toBe(true) + expect(mockPost).not.toHaveBeenCalled() + expect(mockPatch).toHaveBeenCalledWith( + '/providers/7', + expect.objectContaining({ paymentType: TYPE, bank: BANK, siren: '123456789' }), + { toast: false }, + ) + expect(form.isValidated('accounting')).toBe(true) + }) + + it('hors VIREMENT : la banque part a null dans le payload (RG-3.07)', async () => { + mockPatch.mockResolvedValueOnce({}) + const form = createdForm() + fillScalars(form) + form.accounting.bankIri = BANK // residu : doit etre ignore (isBankRequired=false) + + await form.submitAccounting(false, false, vi.fn()) + + const body = mockPatch.mock.calls[0]?.[1] as Record + expect(body.bank).toBeNull() + }) + + it('LCR : POST des RIB AVANT le PATCH des scalaires (ordre RG-3.08)', async () => { + mockPost.mockResolvedValueOnce({ id: 50 }) + mockPatch.mockResolvedValueOnce({}) + const form = createdForm() + fillScalars(form) + form.setPaymentType(TYPE, false, true) + const rib = form.ribs.value[0] + if (rib) { + rib.label = 'Compte' + rib.bic = 'BNPAFRPP' + rib.iban = 'FR76...' + } + + const ok = await form.submitAccounting(false, true, vi.fn()) + + expect(ok).toBe(true) + expect(mockPost).toHaveBeenCalledWith( + '/providers/7/ribs', + expect.objectContaining({ label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }), + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + expect(form.ribs.value[0]?.id).toBe(50) + // Le PATCH des scalaires intervient APRES la creation du RIB. + expect(mockPatch).toHaveBeenCalledWith('/providers/7', expect.any(Object), { toast: false }) + }) + + it('422 sur les scalaires (bank) : mapping inline, onglet non finalise', async () => { + mockPatch.mockRejectedValueOnce({ + response: { + status: 422, + _data: { violations: [{ propertyPath: 'bank', message: 'La banque est obligatoire pour un virement.' }] }, + }, + }) + const form = createdForm() + fillScalars(form) + + const ok = await form.submitAccounting(true, false, vi.fn()) + + expect(ok).toBe(false) + expect(form.accountingErrors.errors.bank).toBe('La banque est obligatoire pour un virement.') + expect(form.isValidated('accounting')).toBe(false) + }) + + it('LCR : 422 RIB par ligne -> pas de PATCH des scalaires', async () => { + mockPost.mockRejectedValueOnce({ + response: { + status: 422, + _data: { violations: [{ propertyPath: 'iban', message: 'L\'IBAN est obligatoire.' }] }, + }, + }) + const form = createdForm() + fillScalars(form) + form.setPaymentType(TYPE, false, true) + const rib = form.ribs.value[0] + if (rib) { + rib.label = 'Compte' + rib.bic = 'BNPAFRPP' + } + + const ok = await form.submitAccounting(false, true, vi.fn()) + + expect(ok).toBe(false) + expect(form.ribErrors.value[0]?.iban).toBe('L\'IBAN est obligatoire.') + expect(mockPatch).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/modules/technique/composables/useProviderForm.ts b/frontend/modules/technique/composables/useProviderForm.ts index 469a1e7..af1e090 100644 --- a/frontend/modules/technique/composables/useProviderForm.ts +++ b/frontend/modules/technique/composables/useProviderForm.ts @@ -2,15 +2,20 @@ import { computed, reactive, ref, type Ref } from 'vue' import { useFormErrors } from '~/shared/composables/useFormErrors' import { mapViolationsToRecord } from '~/shared/utils/api' import { + emptyProviderAccounting, emptyProviderAddress, emptyProviderContact, emptyProviderMain, + emptyProviderRib, + type ProviderAccountingDraft, type ProviderAddressFormDraft, type ProviderAddressResponse, type ProviderContactFormDraft, type ProviderContactResponse, type ProviderMainDraft, type ProviderMainResponse, + type ProviderRibFormDraft, + type ProviderRibResponse, } from '~/modules/technique/types/providerForm' import { buildProviderContactPayload, @@ -20,6 +25,12 @@ import { buildProviderAddressPayload, isProviderAddressValid, } from '~/modules/technique/utils/forms/providerAddress' +import { + buildProviderAccountingPayload, + buildProviderRibPayload, + isRibBlank, + isRibComplete, +} from '~/modules/technique/utils/forms/providerAccounting' /** * Workflow de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) — @@ -72,6 +83,7 @@ export function useProviderForm() { // ── Onglets : ordre + gating progressif ─────────────────────────────────── const canAccountingView = computed(() => can('technique.providers.accounting.view')) + const canAccountingManage = computed(() => can('technique.providers.accounting.manage')) const tabKeys = computed(() => buildProviderCreateTabKeys(canAccountingView.value)) // Index du dernier onglet deverrouille (-1 tant que le prestataire n'est pas cree). @@ -370,6 +382,130 @@ export function useProviderForm() { } } + // ── Onglet Comptabilite (ERP-144) ───────────────────────────────────────── + const accounting = reactive(emptyProviderAccounting()) + const ribs = ref([]) + const accountingErrors = useFormErrors() + // Erreurs 422 par ligne de RIB (alignees sur l'index du v-for). + const ribErrors = ref[]>([]) + + // L'onglet est editable seulement avec accounting.manage (sinon lecture seule). + const accountingReadonly = computed(() => isValidated('accounting') || !canAccountingManage.value) + + /** + * Met a jour le type de reglement (IRI) en propageant ses RG inter-champs : + * - hors VIREMENT (RG-3.07) : on vide la banque (sans objet) ; + * - LCR (RG-3.08) : on garantit au moins un bloc RIB visible ; hors LCR, on + * purge les erreurs de RIB (les blocs sont conserves mais non persistes). + * `isBankRequired` / `isRibRequired` sont calcules par l'appelant (page) a + * partir du code resolu via les referentiels. + */ + function setPaymentType(iri: string | null, isBankRequired: boolean, isRibRequired: boolean): void { + accounting.paymentTypeIri = iri + if (!isBankRequired) { + accounting.bankIri = null + } + if (isRibRequired) { + if (ribs.value.length === 0) { + ribs.value.push(emptyProviderRib()) + } + } + else { + ribErrors.value = [] + } + } + + // « + RIB » desactive tant que le dernier bloc RIB n'est pas complet (RG-3.08). + const canAddRib = computed(() => { + const last = ribs.value[ribs.value.length - 1] + return last !== undefined && isRibComplete(last) + }) + + function addRib(): void { + if (canAddRib.value) { + ribs.value.push(emptyProviderRib()) + } + } + + function removeRib(index: number): void { + ribs.value.splice(index, 1) + ribErrors.value.splice(index, 1) + // Garde au moins un bloc RIB visible (sous LCR). + if (ribs.value.length === 0) { + ribs.value.push(emptyProviderRib()) + } + } + + /** + * Valide l'onglet Comptabilite : (1) sous LCR, POST/PATCH des RIB d'abord + * (le back valide RG-3.08 sur le PATCH scalaires, les RIB doivent donc exister + * AVANT) ; (2) PATCH des scalaires comptables (groupe provider:write:accounting, + * banque envoyee seulement si VIREMENT — RG-3.07). Erreurs RIB par ligne ; + * erreurs scalaires inline (bank/paymentType). Retourne true si l'onglet a ete + * valide. + */ + async function submitAccounting( + isBankRequired: boolean, + isRibRequired: boolean, + onRibError: (error: unknown) => void, + ): Promise { + if (providerId.value === null || tabSubmitting.value) { + return false + } + tabSubmitting.value = true + accountingErrors.clearErrors() + try { + // 1) RIB d'abord, uniquement sous LCR. Une amorce vide neuve est sautee + // s'il reste un autre RIB soumettable ; sinon (LCR sans aucun RIB rempli) + // on la soumet pour declencher la 422 NotBlank inline. + if (isRibRequired) { + const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r)) + const ribHasError = await submitRows( + ribs.value, + ribErrors, + async (rib) => { + const body = buildProviderRibPayload(rib) + if (rib.id === null) { + const created = await api.post( + `/providers/${providerId.value}/ribs`, + body, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + rib.id = created.id + } + else { + await api.patch(`/provider_ribs/${rib.id}`, body, { toast: false }) + } + }, + onRibError, + rib => hasSubmittableRib && rib.id === null && isRibBlank(rib), + ) + if (ribHasError) { + return false + } + } + + // 2) PATCH des scalaires comptables (erreurs inline sur leurs champs). + try { + await api.patch( + `/providers/${providerId.value}`, + buildProviderAccountingPayload(accounting, isBankRequired), + { toast: false }, + ) + } + catch (error) { + accountingErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') }) + return false + } + + completeTab('accounting') + return true + } + finally { + tabSubmitting.value = false + } + } + return { // etat main, @@ -380,6 +516,7 @@ export function useProviderForm() { mainErrors, // onglets canAccountingView, + canAccountingManage, tabKeys, activeTab, unlockedIndex, @@ -399,6 +536,17 @@ export function useProviderForm() { addAddress, removeAddress, submitAddresses, + // comptabilite + accounting, + ribs, + accountingErrors, + ribErrors, + accountingReadonly, + setPaymentType, + canAddRib, + addRib, + removeRib, + submitAccounting, // actions validateMainFront, buildMainPayload, diff --git a/frontend/modules/technique/composables/useProviderReferentials.ts b/frontend/modules/technique/composables/useProviderReferentials.ts index 95c5308..3b56d8c 100644 --- a/frontend/modules/technique/composables/useProviderReferentials.ts +++ b/frontend/modules/technique/composables/useProviderReferentials.ts @@ -28,10 +28,20 @@ export interface RefOption { label: string } +/** Option de type de reglement enrichie de son code stable (RG-3.07 / RG-3.08). */ +export interface PaymentTypeOption extends RefOption { + code: string +} + interface HydraMember { '@id': string } +interface ReferentialMember extends HydraMember { + code: string + label: string +} + interface CategoryMember extends HydraMember { code: string name: string @@ -55,6 +65,11 @@ export function useProviderReferentials() { const categories = ref([]) const sites = ref([]) const countries = ref([]) + // Referentiels comptables (charges a la demande via loadAccounting). + const tvaModes = ref([]) + const paymentDelays = ref([]) + const paymentTypes = ref([]) + const banks = ref([]) /** Recupere une collection complete (pagination desactivee) en Hydra. */ async function fetchAll( @@ -88,10 +103,34 @@ export function useProviderReferentials() { ]) } + /** + * Charge les referentiels comptables (onglet Comptabilite, ERP-144). Appele + * uniquement quand l'utilisateur peut voir l'onglet (accounting.view). Resilient + * (allSettled) : un referentiel en echec reste vide. + */ + async function loadAccounting(): Promise { + await Promise.allSettled([ + fetchAll('/tva_modes') + .then((list) => { tvaModes.value = list.map(t => ({ value: t['@id'], label: t.label })) }), + fetchAll('/payment_delays') + .then((list) => { paymentDelays.value = list.map(d => ({ value: d['@id'], label: d.label })) }), + // Le code stable du type sert les RG-3.07 (VIREMENT) / RG-3.08 (LCR). + fetchAll('/payment_types') + .then((list) => { paymentTypes.value = list.map(p => ({ value: p['@id'], label: p.label, code: p.code })) }), + fetchAll('/banks') + .then((list) => { banks.value = list.map(b => ({ value: b['@id'], label: b.label })) }), + ]) + } + return { categories, sites, countries, + tvaModes, + paymentDelays, + paymentTypes, + banks, loadMain, + loadAccounting, } } diff --git a/frontend/modules/technique/pages/providers/new.vue b/frontend/modules/technique/pages/providers/new.vue index 28dd406..7054103 100644 --- a/frontend/modules/technique/pages/providers/new.vue +++ b/frontend/modules/technique/pages/providers/new.vue @@ -127,7 +127,136 @@ - + + @@ -158,8 +287,15 @@ import { computed, onMounted, reactive, ref } from 'vue' import { useProviderReferentials, type RefOption } from '~/modules/technique/composables/useProviderReferentials' import { useProviderForm } from '~/modules/technique/composables/useProviderForm' +import { + isBankRequiredForPaymentType, + isRibRequiredForPaymentType, +} from '~/modules/technique/utils/forms/providerAccounting' import { extractApiErrorMessage } from '~/shared/utils/api' +// Masque SIREN : 9 chiffres (la normalisation finale reste serveur). +const SIREN_MASK = '#########' + const { t } = useI18n() const router = useRouter() const toast = useToast() @@ -200,6 +336,16 @@ const { addAddress, removeAddress, submitAddresses, + accounting, + ribs, + accountingErrors, + ribErrors, + accountingReadonly, + setPaymentType, + canAddRib, + addRib, + removeRib, + submitAccounting, } = useProviderForm() /** Retour vers le repertoire prestataires (fleche d'en-tete). */ @@ -282,6 +428,43 @@ function askRemoveAddress(index: number): void { askConfirm(t('technique.providers.form.confirmDelete.address'), () => removeAddress(index)) } +// ── Onglet Comptabilite ─────────────────────────────────────────────────────── +// Code stable du type de reglement selectionne (pour RG-3.07 / RG-3.08). +const selectedPaymentTypeCode = computed(() => + referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null, +) +const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value)) +const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value)) + +// Les blocs RIB ne sont affiches que pour une LCR (RG-3.08). +const visibleRibs = computed(() => isRibRequired.value ? ribs.value : []) + +/** Changement de type de reglement : propage les RG inter-champs (banque / RIB). */ +function onPaymentTypeChange(value: string | number | null): void { + const iri = value === null ? null : String(value) + const code = referentials.paymentTypes.value.find(p => p.value === iri)?.code ?? null + setPaymentType(iri, isBankRequiredForPaymentType(code), isRibRequiredForPaymentType(code)) +} + +function askRemoveRib(index: number): void { + askConfirm(t('technique.providers.form.confirmDelete.rib'), () => removeRib(index)) +} + +/** Valide l'onglet Comptabilite ; toast de succes si l'onglet a ete finalise. */ +async function onSubmitAccounting(): Promise { + const ok = await submitAccounting( + isBankRequired.value, + isRibRequired.value, + error => toast.error({ + title: t('technique.providers.toast.error'), + message: apiErrorMessage(error), + }), + ) + if (ok) { + toast.success({ title: t('technique.providers.toast.updateSuccess') }) + } +} + // ── Modal de confirmation generique ───────────────────────────────────────── const confirmModal = reactive({ open: false, @@ -320,5 +503,9 @@ const tabs = computed(() => tabKeys.value.map((key, index) => ({ onMounted(() => { // Echec du chargement des referentiels non bloquant : les selects restent vides. referentials.loadMain().catch(() => {}) + // Referentiels comptables charges uniquement si l'onglet est accessible. + if (canAccountingView.value) { + referentials.loadAccounting().catch(() => {}) + } }) diff --git a/frontend/modules/technique/types/providerForm.ts b/frontend/modules/technique/types/providerForm.ts index d963033..2a4f1b7 100644 --- a/frontend/modules/technique/types/providerForm.ts +++ b/frontend/modules/technique/types/providerForm.ts @@ -124,3 +124,54 @@ export function emptyProviderAddress(): ProviderAddressFormDraft { export interface ProviderAddressResponse { id: number } + +/** + * Etat « plat » de l'onglet Comptabilite (groupe `provider:write:accounting`). + * Relations (TVA / delai / type de reglement / banque) portees par leur IRI. + */ +export interface ProviderAccountingDraft { + siren: string | null + accountNumber: string | null + tvaModeIri: string | null + nTva: string | null + paymentDelayIri: string | null + paymentTypeIri: string | null + /** Banque : requise et envoyee uniquement si Type de reglement = VIREMENT (RG-3.07). */ + bankIri: string | null +} + +/** Fabrique un onglet Comptabilite vierge. */ +export function emptyProviderAccounting(): ProviderAccountingDraft { + return { + siren: null, + accountNumber: null, + tvaModeIri: null, + nTva: null, + paymentDelayIri: null, + paymentTypeIri: null, + bankIri: null, + } +} + +/** Un RIB du prestataire (sous-collection comptable, obligatoire si Type = LCR — RG-3.08). */ +export interface ProviderRibFormDraft { + id: number | null + label: string | null + bic: string | null + iban: string | null +} + +/** Fabrique un RIB vierge. */ +export function emptyProviderRib(): ProviderRibFormDraft { + return { + id: null, + label: null, + bic: null, + iban: null, + } +} + +/** Reponse du POST /providers/{id}/ribs (id suffisant pour le suivi cote front). */ +export interface ProviderRibResponse { + id: number +} diff --git a/frontend/modules/technique/utils/forms/__tests__/providerAccounting.spec.ts b/frontend/modules/technique/utils/forms/__tests__/providerAccounting.spec.ts new file mode 100644 index 0000000..fc9915c --- /dev/null +++ b/frontend/modules/technique/utils/forms/__tests__/providerAccounting.spec.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest' +import { + buildProviderAccountingPayload, + buildProviderRibPayload, + isBankRequiredForPaymentType, + isRibBlank, + isRibComplete, + isRibRequiredForPaymentType, +} from '../providerAccounting' +import { emptyProviderAccounting, emptyProviderRib } from '~/modules/technique/types/providerForm' + +/** + * Helpers purs de l'onglet Comptabilite prestataire (ERP-144) : RG inter-champs + * RG-3.07 (banque si VIREMENT) / RG-3.08 (RIB si LCR) + construction des payloads. + */ +describe('providerAccounting helpers', () => { + describe('RG-3.07 / RG-3.08 — type de reglement', () => { + it('banque requise uniquement pour VIREMENT', () => { + expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true) + expect(isBankRequiredForPaymentType('LCR')).toBe(false) + expect(isBankRequiredForPaymentType('CHEQUE')).toBe(false) + expect(isBankRequiredForPaymentType(null)).toBe(false) + }) + + it('RIB requis uniquement pour LCR', () => { + expect(isRibRequiredForPaymentType('LCR')).toBe(true) + expect(isRibRequiredForPaymentType('VIREMENT')).toBe(false) + expect(isRibRequiredForPaymentType(null)).toBe(false) + }) + }) + + describe('isRibBlank / isRibComplete', () => { + it('un RIB vierge est vide et incomplet', () => { + expect(isRibBlank(emptyProviderRib())).toBe(true) + expect(isRibComplete(emptyProviderRib())).toBe(false) + }) + + it('un RIB partiel n\'est ni vide ni complet', () => { + const rib = { ...emptyProviderRib(), iban: 'FR76...' } + expect(isRibBlank(rib)).toBe(false) + expect(isRibComplete(rib)).toBe(false) + }) + + it('un RIB avec libelle + BIC + IBAN est complet', () => { + const rib = { ...emptyProviderRib(), label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' } + expect(isRibComplete(rib)).toBe(true) + }) + }) + + describe('buildProviderAccountingPayload (RG-3.07)', () => { + it('envoie la banque si requise (VIREMENT)', () => { + const payload = buildProviderAccountingPayload({ + ...emptyProviderAccounting(), + paymentTypeIri: '/api/payment_types/3', + bankIri: '/api/banks/2', + }, true) + expect(payload.bank).toBe('/api/banks/2') + expect(payload.paymentType).toBe('/api/payment_types/3') + }) + + it('force la banque a null si non requise (hors VIREMENT)', () => { + const payload = buildProviderAccountingPayload({ + ...emptyProviderAccounting(), + bankIri: '/api/banks/2', + }, false) + expect(payload.bank).toBeNull() + }) + }) + + describe('buildProviderRibPayload', () => { + it('omet les champs requis vides (NotBlank back joue sur le champ)', () => { + const payload = buildProviderRibPayload(emptyProviderRib()) + expect(payload).not.toHaveProperty('label') + expect(payload).not.toHaveProperty('bic') + expect(payload).not.toHaveProperty('iban') + }) + + it('conserve les champs remplis', () => { + const payload = buildProviderRibPayload({ ...emptyProviderRib(), label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }) + expect(payload).toEqual({ label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }) + }) + }) +}) diff --git a/frontend/modules/technique/utils/forms/providerAccounting.ts b/frontend/modules/technique/utils/forms/providerAccounting.ts new file mode 100644 index 0000000..4357acf --- /dev/null +++ b/frontend/modules/technique/utils/forms/providerAccounting.ts @@ -0,0 +1,86 @@ +/** + * Helpers purs de l'onglet Comptabilite prestataire (M3 Technique, ERP-144) — + * miroir SIMPLIFIE des regles M2, reimplemente cote module Technique (regle + * ABSOLUE n°1 : pas d'import inter-module). Portent les RG inter-champs RG-3.07 + * (banque si VIREMENT) et RG-3.08 (RIB si LCR), testables sans Vue ni API. + */ + +import type { + ProviderAccountingDraft, + ProviderRibFormDraft, +} from '~/modules/technique/types/providerForm' + +/** Code pivot du type de reglement imposant une banque (RG-3.07). */ +const PAYMENT_TYPE_VIREMENT = 'VIREMENT' +/** Code pivot du type de reglement imposant au moins un RIB (RG-3.08). */ +const PAYMENT_TYPE_LCR = 'LCR' + +/** Champs RIB obligatoires non nullable cote back (NotBlank) — omis si vides au POST. */ +const RIB_REQUIRED_NON_NULLABLE_KEYS = ['label', 'bic', 'iban'] as const + +/** Vrai si une chaine porte au moins un caractere non-espace. */ +function isFilled(value: string | null | undefined): boolean { + return value !== null && value !== undefined && value.trim() !== '' +} + +/** RG-3.07 : la banque n'est requise/visible que pour un reglement par VIREMENT. */ +export function isBankRequiredForPaymentType(code: string | null | undefined): boolean { + return code === PAYMENT_TYPE_VIREMENT +} + +/** RG-3.08 : au moins un RIB n'est requis que pour un reglement par LCR. */ +export function isRibRequiredForPaymentType(code: string | null | undefined): boolean { + return code === PAYMENT_TYPE_LCR +} + +/** Vrai si AUCUN champ du bloc RIB n'est rempli (amorce vide a ignorer au submit). */ +export function isRibBlank(rib: ProviderRibFormDraft): boolean { + return ![rib.label, rib.bic, rib.iban].some(isFilled) +} + +/** Vrai si les 3 champs du RIB sont remplis (gating « + RIB »). */ +export function isRibComplete(rib: ProviderRibFormDraft): boolean { + return isFilled(rib.label) && isFilled(rib.bic) && isFilled(rib.iban) +} + +/** + * Payload du PATCH comptable (groupe `provider:write:accounting`). Les relations + * sont en IRI ; la banque n'est envoyee que si elle est requise (RG-3.07), sinon + * `null` (le back vide la relation hors VIREMENT). + */ +export function buildProviderAccountingPayload( + accounting: ProviderAccountingDraft, + isBankRequired: boolean, +): Record { + return { + siren: accounting.siren || null, + accountNumber: accounting.accountNumber || null, + tvaMode: accounting.tvaModeIri, + nTva: accounting.nTva || null, + paymentDelay: accounting.paymentDelayIri, + paymentType: accounting.paymentTypeIri, + bank: isBankRequired ? accounting.bankIri : null, + } +} + +/** + * Payload d'un RIB (sous-ressource, groupe `provider:write:accounting`). Les + * champs requis vides sont omis a la creation pour que la 422 NotBlank porte sur + * le champ. + */ +export function buildProviderRibPayload(rib: ProviderRibFormDraft): Record { + const payload: Record = { + label: rib.label, + bic: rib.bic, + iban: rib.iban, + } + + for (const key of RIB_REQUIRED_NON_NULLABLE_KEYS) { + const value = payload[key] + if (value === null || value === undefined || value === '') { + delete payload[key] + } + } + + return payload +}