Files
Starseed/frontend/modules/commercial/utils/supplierEdit.ts
T
tristan c594a76d47
Auto Tag Develop / tag (push) Successful in 7s
feat(front) : page Modification fournisseur (/suppliers/{id}/edit) (ERP-96) (#85)
## ERP-96 — Modification fournisseur

Étape 7/7 (front). Dépend de #94 (Ajouter) + #95 (Consultation).

> ⚠️ MR **stackée sur `feature/ERP-95-suppliers-show`** (95 → 94, pas encore mergées dans develop) pour limiter le diff aux 3 fichiers d'ERP-96. À recibler sur `develop` une fois 94 puis 95 mergées. Squash au merge.

### Périmètre
- Route `/suppliers/{id}/edit` : champs **pré-remplis** depuis GET /suppliers/{id}, **PATCH partiel indépendant par onglet**. Bloc principal conservé (éditable via son propre PATCH `supplier:write:main`), pas de contact inline (ERP-106).
- **Mode strict (RG-2.16)** : chaque onglet n'envoie QUE les champs de son groupe de sérialisation (jamais de mélange → sinon 403). Builders de payload scopés (`supplierEdit`).
- Éditabilité par rôle (`resolveTabEditability`) : métier readonly sans `manage` ; Comptabilité visible/éditable selon `accounting.view`/`accounting.manage` ; placeholders non éditables.
- Collections contacts/adresses/RIB : POST/PATCH par ligne + DELETE différé des retraits ; 422 mappées **inline par champ** (`propertyPath` → `useSupplierFormErrors`/`extractApiViolations`), jamais un toast fourre-tout (ERP-101).

### Tests
- Vitest : `supplierEdit.spec.ts` enrichi (mappers d'hydratation `mapMainDraft`/`mapInformationDraft` avec `volumeForecast`/`mapAccountingFormDraft` + `resolveTabEditability` matrice § 2.7). `make nuxt-test` → 375/375 . ESLint .
- `nuxi typecheck` non lancé sur l'hôte (casse le conteneur dev-nuxt).

Miroir de l'écran Modification client (M1), adapté M2 (enum `addressType`, `bennes`/`triageProvider`/`volumeForecast`, pas de relation Distributeur/Courtier).

Reviewed-on: #85
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-11 07:26:32 +00:00

251 lines
10 KiB
TypeScript

/**
* 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).
*
* Ces helpers ne touchent ni a l'API ni a l'etat reactif.
*/
import {
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
blankEmptyRequired,
MAIN_REQUIRED_NON_NULLABLE_KEYS,
omitEmptyRequired,
RIB_REQUIRED_NON_NULLABLE_KEYS,
} from '~/modules/commercial/utils/supplierFormRules'
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
import type {
SupplierAddressFormDraft,
SupplierContactFormDraft,
SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm'
/** Etat « plat » du bloc principal (groupe supplier:write:main). */
export interface MainFormDraft {
companyName: string | null
/** IRI des categories rattachees (M2M, type FOURNISSEUR). */
categoryIris: string[]
}
/** Etat « plat » de l'onglet Information (groupe supplier:write:information). */
export interface InformationFormDraft {
description: string | null
competitors: string | null
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
foundedAt: string | null
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
employeesCount: string | null
revenueAmount: string | null
profitAmount: string | null
directorName: string | null
/** Volume previsionnel (entier >= 0, specifique fournisseur) en chaine. */
volumeForecast: string | null
}
/** Etat « plat » de l'onglet Comptabilite (groupe supplier:write:accounting). */
export interface AccountingFormDraft {
siren: string | null
accountNumber: string | null
nTva: string | null
tvaModeIri: string | null
paymentDelayIri: string | null
paymentTypeIri: string | null
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 ──────────────────────────────────
/**
* Options de construction d'un payload d'ecriture.
* - `forUpdate: false` (defaut, CREATION/POST) : les champs requis vides sont OMIS
* -> 422 NotBlank a l'insert (le back ne reçoit pas la cle).
* - `forUpdate: true` (EDITION/PATCH d'une ligne existante) : les champs requis
* vides sont envoyes en `''` -> 422 NotBlank (sinon une cle omise laisse la valeur
* serveur inchangee, faux 200 — cf. blankEmptyRequired).
*/
export interface BuildPayloadOptions {
forUpdate?: boolean
}
/** Selectionne le finaliseur des champs requis selon création (omit) vs édition (blank). */
function finalizeRequired<T extends Record<string, unknown>>(
payload: T,
requiredKeys: readonly string[],
options: BuildPayloadOptions,
): T {
return options.forUpdate
? blankEmptyRequired(payload, requiredKeys)
: omitEmptyRequired(payload, requiredKeys)
}
/**
* Payload du bloc principal — groupe supplier:write:main UNIQUEMENT.
* companyName vide -> 422 NotBlank (omis a la creation, `''` en edition — ERP-119).
*/
export function buildMainPayload(main: MainFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> {
return finalizeRequired({
companyName: main.companyName,
categories: main.categoryIris,
}, MAIN_REQUIRED_NON_NULLABLE_KEYS, options)
}
/** Payload de l'onglet Information — groupe supplier:write:information UNIQUEMENT. */
export function buildInformationPayload(information: InformationFormDraft): Record<string, unknown> {
return {
description: information.description || null,
competitors: information.competitors || null,
foundedAt: information.foundedAt || null,
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
revenueAmount: information.revenueAmount || null,
profitAmount: information.profitAmount || null,
directorName: information.directorName || null,
volumeForecast: information.volumeForecast ? Number(information.volumeForecast) : null,
}
}
/**
* Payload des scalaires de l'onglet Comptabilite — groupe supplier:write:accounting
* UNIQUEMENT (les RIB passent par la sous-ressource /suppliers/{id}/ribs). La
* banque n'a de sens que pour un Virement (RG-2.07) : forcee a null sinon.
*/
export function buildAccountingPayload(
accounting: AccountingFormDraft,
isBankRequired: boolean,
): Record<string, unknown> {
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 contact (sous-ressource supplier_contact). */
export function buildContactPayload(contact: SupplierContactFormDraft): Record<string, unknown> {
return {
firstName: contact.firstName || null,
lastName: contact.lastName || null,
jobTitle: contact.jobTitle || null,
phonePrimary: contact.phonePrimary || null,
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
email: contact.email || null,
}
}
/**
* Payload d'une adresse (sous-ressource supplier_address). postalCode / city /
* street omis si vides -> 422 NotBlank (ERP-119). Specifique fournisseur :
* `bennes` (entier, 0 par defaut) + `triageProvider` (booleen). Pas d'email de
* facturation (difference M1).
*/
export function buildAddressPayload(address: SupplierAddressFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> {
return finalizeRequired({
addressType: address.addressType,
country: address.country,
postalCode: address.postalCode || null,
city: address.city || null,
street: address.street || null,
streetComplement: address.streetComplement || null,
categories: address.categoryIris,
sites: address.siteIris,
contacts: address.contactIris,
bennes: address.bennes !== null && address.bennes !== '' ? Number(address.bennes) : null,
triageProvider: address.triageProvider,
}, ADDRESS_REQUIRED_NON_NULLABLE_KEYS, options)
}
/** Payload d'un RIB (sous-ressource supplier_rib). */
export function buildRibPayload(rib: SupplierRibFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> {
return finalizeRequired({
label: rib.label,
bic: rib.bic,
iban: rib.iban,
}, RIB_REQUIRED_NON_NULLABLE_KEYS, options)
}