feat(front) : onglet comptabilite prestataire (ERP-144) (#106)
Auto Tag Develop / tag (push) Successful in 8s

Empilée sur ERP-143 (#105).

## Périmètre ERP-144
Onglet **Comptabilité** de l'écran `/providers/new` — gated par permission + blocs RIB conditionnels.

- Champs (`Malio*`) : SIREN / Numéro de compte / Mode de TVA (`/api/tva_modes`) / N° de TVA / Délai (`/api/payment_delays`) / Type de règlement (`/api/payment_types`) / Banque (`/api/banks`).
- **RG-3.07** : Banque visible **et** obligatoire **seulement si** Type = `VIREMENT` (affichage conditionnel + payload `bank` forcé à null sinon).
- **RG-3.08** : blocs RIB (Libellé/BIC/IBAN) affichés et requis si Type = `LCR` ; « + RIB » gated (dernier RIB complet) / Supprimer (modal). À la validation, **POST des RIB AVANT** le PATCH des scalaires (le back valide RG-3.08 sur le PATCH).
- **Gating** : onglet présent uniquement si `technique.providers.accounting.view` ; **éditable** uniquement si `.manage` (sinon lecture seule). Masqué pour Bureau/Commerciale.
- « Valider » → PATCH `/api/providers/{id}` (groupe `provider:write:accounting`) + sous-ressource RIBs (`/providers/{id}/ribs` + `/provider_ribs/{id}`). Erreurs 422 inline (scalaires) et par ligne (RIB).
- `useProviderReferentials.loadAccounting()` (chargé seulement si l'onglet est accessible). Helpers purs `utils/forms/providerAccounting.ts`.
- i18n `technique.providers.form.accounting` + `confirmDelete.rib`.

> NB : les placeholders **Rapports / Échanges** relèvent des écrans Consultation/Modification (ERP-145) — le flux de **création** ne porte que 3 onglets (Contact/Adresse/Comptabilité), conformément à la spec.

## Conformité
- `useApi()` only ; `Malio*` only ; pas de masque email ; aucun texte FR en dur ; pas d'import inter-module (helpers ré-implémentés côté Technique, règle ABSOLUE n°1).

## Vérifications
- Vitest : 454/454 (18 nouveaux : helpers compta RG-3.07/3.08, workflow VIREMENT/LCR, ordre RIB→scalaires, 422 inline + par ligne, lecture seule sans manage).
- ESLint : OK.
- `nuxi typecheck` : 0 erreur sur les fichiers source du ticket.
- Golden path navigateur : page compile, onglet Comptabilité visible (gating accounting.view OK pour admin). Contenu de l'onglet gaté derrière le déverrouillage des 3 onglets (multiselect `Malio` non pilotable en a11y) — couvert par les tests unitaires + typecheck.

Reviewed-on: #106
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #106.
This commit is contained in:
2026-06-15 09:15:20 +00:00
committed by Autin
parent 17aa61d014
commit c25c33116d
8 changed files with 793 additions and 5 deletions
@@ -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<string, unknown>
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()
})
})