feat(front) : page Modification fournisseur (/suppliers/{id}/edit) (ERP-96)

- page edit.vue : champs pre-remplis depuis GET /suppliers/{id}, PATCH partiel
  INDEPENDANT par onglet (mode strict RG-2.16 : un seul groupe de serialisation
  par appel), pas de formulaire principal masque mais editable via son propre PATCH
- pas de contact inline (ERP-106) ; onglets metier readonly sans manage, Comptabilite
  visible/editable selon accounting.view / accounting.manage (resolveTabEditability)
- collections contacts/adresses/RIB : POST/PATCH par ligne + DELETE differe des
  retraits ; erreurs 422 mappees inline par champ (propertyPath) via useSupplierFormErrors
- supplierEdit : mappers d'hydratation (mapMainDraft/mapInformationDraft/
  mapAccountingFormDraft, + volumeForecast) et resolveTabEditability
- tests Vitest : mappers d'hydratation + gating par role (matrice 2.7)
- miroir de l'ecran Modification client (M1), adapte M2 (addressType/bennes/
  triageProvider/volumeForecast, pas de relation Distributeur/Courtier)
This commit is contained in:
2026-06-11 08:22:17 +02:00
parent 5722912413
commit 18fdf9354f
3 changed files with 1095 additions and 6 deletions
@@ -6,7 +6,12 @@ import {
buildInformationPayload,
buildMainPayload,
buildRibPayload,
mapAccountingFormDraft,
mapInformationDraft,
mapMainDraft,
resolveTabEditability,
} from '../supplierEdit'
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/supplierForm'
describe('buildMainPayload (groupe supplier:write:main)', () => {
@@ -113,3 +118,85 @@ describe('buildRibPayload (sous-ressource supplier_rib)', () => {
expect(payload.iban).toBe('FR1420041010050500013M02606')
})
})
describe('mapMainDraft — pre-remplissage bloc principal (companyName + categories, pas de relation M2)', () => {
it('extrait companyName et les IRI de categories', () => {
const draft = mapMainDraft({
'@id': '/api/suppliers/85', id: 85,
companyName: 'DOD862875',
categories: [{ '@id': '/api/categories/2279', code: 'NEGOCIANT' }],
} as SupplierDetail)
expect(draft.companyName).toBe('DOD862875')
expect(draft.categoryIris).toEqual(['/api/categories/2279'])
})
it('gere les cles omises (skip_null_values) sans planter', () => {
const draft = mapMainDraft({ '@id': '/api/suppliers/2', id: 2 } as SupplierDetail)
expect(draft.companyName).toBeNull()
expect(draft.categoryIris).toEqual([])
})
})
describe('mapInformationDraft — pre-remplissage onglet Information (+ volumeForecast M2)', () => {
it('tronque foundedAt, stringifie employeesCount et volumeForecast', () => {
const draft = mapInformationDraft({
'@id': '/api/suppliers/85', id: 85,
foundedAt: '2008-04-01T00:00:00+02:00', employeesCount: 42, volumeForecast: 8000,
} as SupplierDetail)
expect(draft.foundedAt).toBe('2008-04-01')
expect(draft.employeesCount).toBe('42')
expect(draft.volumeForecast).toBe('8000')
})
it('cles omises -> null (volumeForecast inclus)', () => {
const draft = mapInformationDraft({ '@id': '/api/suppliers/1', id: 1 } as SupplierDetail)
expect(draft.foundedAt).toBeNull()
expect(draft.employeesCount).toBeNull()
expect(draft.volumeForecast).toBeNull()
expect(draft.description).toBeNull()
})
})
describe('mapAccountingFormDraft — pre-remplissage onglet Comptabilite', () => {
it('extrait les scalaires et les IRI des referentiels embarques', () => {
const draft = mapAccountingFormDraft({
'@id': '/api/suppliers/85', id: 85,
siren: '123456789', accountNumber: 'F0001', nTva: 'FR00123456789',
tvaMode: { '@id': '/api/tva_modes/30', label: 'France (ventes)' },
paymentType: '/api/payment_types/14',
} as SupplierDetail)
expect(draft.siren).toBe('123456789')
expect(draft.tvaModeIri).toBe('/api/tva_modes/30')
expect(draft.paymentTypeIri).toBe('/api/payment_types/14')
expect(draft.bankIri).toBeNull()
})
it('cles comptables absentes (gating par omission) -> scalaires/IRI null', () => {
const draft = mapAccountingFormDraft({ '@id': '/api/suppliers/1', id: 1 } as SupplierDetail)
expect(draft.siren).toBeNull()
expect(draft.tvaModeIri).toBeNull()
expect(draft.bankIri).toBeNull()
})
})
describe('resolveTabEditability — gating par role (matrice § 2.7)', () => {
it('Admin : tout editable', () => {
expect(resolveTabEditability({ canManage: true, canAccountingView: true, canAccountingManage: true }))
.toEqual({ businessEditable: true, accountingVisible: true, accountingEditable: true })
})
it('Bureau / Commerciale (manage seul) : metier editable, Comptabilite masquee', () => {
expect(resolveTabEditability({ canManage: true, canAccountingView: false, canAccountingManage: false }))
.toEqual({ businessEditable: true, accountingVisible: false, accountingEditable: false })
})
it('Compta (accounting seul) : metier readonly, Comptabilite editable', () => {
expect(resolveTabEditability({ canManage: false, canAccountingView: true, canAccountingManage: true }))
.toEqual({ businessEditable: false, accountingVisible: true, accountingEditable: true })
})
it('Sans permission d\'edition : rien d\'editable', () => {
expect(resolveTabEditability({ canManage: false, canAccountingView: false, canAccountingManage: false }))
.toEqual({ businessEditable: false, accountingVisible: false, accountingEditable: false })
})
})
@@ -1,11 +1,14 @@
/**
* Helpers purs de payload de l'ecran « Ajouter un fournisseur » (M2 Commercial),
* partages avec la future modification (96) — miroir de `clientEdit.ts` (M1).
* Helpers purs des ecrans « Ajouter » / « Modifier » un fournisseur (M2
* Commercial) — miroir de `clientEdit.ts` (M1). Deux responsabilites, toutes deux
* testables unitairement (cf. supplierEdit.spec.ts) :
* 1. Pre-remplissage : mapper le payload `GET /api/suppliers/{id}` (embed +
* scalaires) vers les brouillons « plats » edites par la page de modification.
* 2. Scoping STRICT des payloads PATCH (mode strict RG-2.16 / ERP-74) : chaque
* onglet n'envoie QUE les champs de SON groupe de serialisation, jamais un
* payload mixte (un champ hors-permission = 403 sur l'integralite cote back).
*
* Scoping STRICT des payloads (mode strict, aligne ERP-74/RG) : chaque onglet
* n'envoie QUE les champs de SON groupe de serialisation, jamais un payload mixte
* (un champ hors-permission = 403 sur l'integralite cote back). Ces helpers ne
* touchent ni a l'API ni a l'etat reactif.
* Ces helpers ne touchent ni a l'API ni a l'etat reactif.
*/
import {
@@ -14,6 +17,7 @@ import {
omitEmptyRequired,
RIB_REQUIRED_NON_NULLABLE_KEYS,
} from '~/modules/commercial/utils/supplierFormRules'
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
import type {
SupplierAddressFormDraft,
SupplierContactFormDraft,
@@ -53,6 +57,86 @@ export interface AccountingFormDraft {
bankIri: string | null
}
/** Permissions de l'utilisateur courant pertinentes pour l'edition d'un fournisseur. */
export interface SupplierEditAbilities {
/** `commercial.suppliers.manage` : bloc principal + onglets metier. */
canManage: boolean
/** `commercial.suppliers.accounting.view` : visibilite de l'onglet Comptabilite. */
canAccountingView: boolean
/** `commercial.suppliers.accounting.manage` : edition de l'onglet Comptabilite. */
canAccountingManage: boolean
}
/** Editabilite resolue par zone d'onglet (deduite des permissions). */
export interface TabEditability {
/** Bloc principal + onglets Information / Contacts / Adresses editables. */
businessEditable: boolean
/** Onglet Comptabilite present (affiche). */
accountingVisible: boolean
/** Onglet Comptabilite editable. */
accountingEditable: boolean
}
// ── Pre-remplissage (GET detail -> brouillons) ──────────────────────────────
/** Mappe le detail fournisseur vers le brouillon du bloc principal. */
export function mapMainDraft(supplier: SupplierDetail): MainFormDraft {
return {
companyName: supplier.companyName ?? null,
categoryIris: (supplier.categories ?? []).map(c => c['@id']),
}
}
/** Mappe le detail fournisseur vers le brouillon de l'onglet Information. */
export function mapInformationDraft(supplier: SupplierDetail): InformationFormDraft {
return {
description: supplier.description ?? null,
competitors: supplier.competitors ?? null,
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
foundedAt: supplier.foundedAt ? supplier.foundedAt.slice(0, 10) : null,
employeesCount: supplier.employeesCount != null ? String(supplier.employeesCount) : null,
revenueAmount: supplier.revenueAmount ?? null,
profitAmount: supplier.profitAmount ?? null,
directorName: supplier.directorName ?? null,
// Volume previsionnel (entier, specifique fournisseur) en chaine pour la saisie.
volumeForecast: supplier.volumeForecast != null ? String(supplier.volumeForecast) : null,
}
}
/** Mappe les champs comptables du detail vers le brouillon de l'onglet (scalaires + IRI). */
export function mapAccountingFormDraft(supplier: SupplierDetail): AccountingFormDraft {
return {
siren: supplier.siren ?? null,
accountNumber: supplier.accountNumber ?? null,
nTva: supplier.nTva ?? null,
tvaModeIri: iriOf(supplier.tvaMode),
paymentDelayIri: iriOf(supplier.paymentDelay),
paymentTypeIri: iriOf(supplier.paymentType),
bankIri: iriOf(supplier.bank),
}
}
/**
* Resout l'editabilite par zone a partir des permissions (option 1 ERP-74,
* miroir UI du re-gating champ-par-champ du SupplierProcessor) :
* - bloc principal + Information/Contacts/Adresses : editables ssi `manage` ;
* - Comptabilite : visible ssi `accounting.view`, editable ssi `accounting.manage`.
*
* Produit le comportement attendu :
* - Admin : tout editable.
* - Bureau / Commerciale (manage, sans accounting) : metier editable, Compta masquee.
* - Compta (accounting seul, sans manage) : metier readonly, Compta editable.
*/
export function resolveTabEditability(abilities: SupplierEditAbilities): TabEditability {
return {
businessEditable: abilities.canManage,
accountingVisible: abilities.canAccountingView,
accountingEditable: abilities.canAccountingManage,
}
}
// ── Scoping strict des payloads PATCH/POST ──────────────────────────────────
/**
* Payload du bloc principal — groupe supplier:write:main UNIQUEMENT.
* companyName omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119).