From 18fdf9354f410b2c3dd36bbf2adbb6df799e5158 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 11 Jun 2026 08:22:17 +0200 Subject: [PATCH] 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) --- .../commercial/pages/suppliers/[id]/edit.vue | 918 ++++++++++++++++++ .../utils/__tests__/supplierEdit.spec.ts | 87 ++ .../modules/commercial/utils/supplierEdit.ts | 96 +- 3 files changed, 1095 insertions(+), 6 deletions(-) create mode 100644 frontend/modules/commercial/pages/suppliers/[id]/edit.vue diff --git a/frontend/modules/commercial/pages/suppliers/[id]/edit.vue b/frontend/modules/commercial/pages/suppliers/[id]/edit.vue new file mode 100644 index 0000000..6720480 --- /dev/null +++ b/frontend/modules/commercial/pages/suppliers/[id]/edit.vue @@ -0,0 +1,918 @@ + + + diff --git a/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts b/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts index 11b2570..581cc24 100644 --- a/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts +++ b/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts @@ -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 }) + }) +}) diff --git a/frontend/modules/commercial/utils/supplierEdit.ts b/frontend/modules/commercial/utils/supplierEdit.ts index a0f793a..55ac9d7 100644 --- a/frontend/modules/commercial/utils/supplierEdit.ts +++ b/frontend/modules/commercial/utils/supplierEdit.ts @@ -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).