8490de99da
Auto Tag Develop / tag (push) Successful in 7s
## Contexte Branche ERP-119 — revue de la validation des formulaires clients (déclencheur : écran « Ajouter un client »), accompagnée de plusieurs évolutions de l'écran client (M1). ## Contenu ### Validation front (clients) - Boutons « Valider » toujours actifs (retrait du gating de validité) : c'est le back qui renvoie les 422, mappées en rouge par champ. - Champs requis adossés à une colonne non-nullable : la clé est omise du payload si vide (companyName, RIB, adresse) → 422 NotBlank au lieu d'un 400 de type. - Onglet Contact : au moins un contact requis (l'amorce vide est soumise → 422 RG-1.05). - Onglet Adresse : affichage inline des erreurs type / sites / catégories + RG back « au moins un type d'adresse obligatoire ». ### Nouveaux types d'adresse - Courtier / Distributeur, types autonomes exclusifs : colonnes `is_broker` / `is_distributor` (migration + CHECK miroir d'exclusivité), entité + Callback, et front (select, drapeaux, payloads). ### Saisies manuelles - Adresse : `allow-create` sur le champ Adresse → saisie libre si la BAN ne propose rien. - Date de création : `MalioDate :editable` → saisie clavier JJ/MM/AAAA en plus du calendrier. ### 2e email de facturation - Colonne `billing_email_secondary` (optionnel, max 2), miroir du téléphone secondaire. Bump `@malio/layer-ui` 1.7.8 (prop `addable`). ### Fin d'ajout d'un client - Redirection vers la liste à la validation du dernier onglet remplissable par le rôle (Adresse pour Bureau/Commerciale, Comptabilité pour Admin) + toast « Client ajouté ». Dérivé de `tabKeys`, sans règle RBAC custom. ## Vérifications - Back : suites Module/Commercial + Architecture vertes (Client : 124/124). Migrations appliquées (dev + test). - Front : Vitest vert (272), ESLint OK. > Note : le hook pré-commit flake aléatoirement (JWT 401 / timeout DB) sur des tests sans rapport (Supplier) ; les commits ont été faits après vérification des suites concernées en isolation. Reviewed-on: #80 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
266 lines
11 KiB
TypeScript
266 lines
11 KiB
TypeScript
/**
|
|
* Helpers purs de l'ecran « Modification client » (M1 Commercial, 1.12).
|
|
*
|
|
* Deux responsabilites, toutes deux testables unitairement (cf. clientEdit.spec.ts) :
|
|
* 1. Pre-remplissage : mapper le payload `GET /api/clients/{id}` (embed
|
|
* contacts/adresses/ribs + scalaires) vers les brouillons « plats » edites
|
|
* par la page et les blocs reutilisables (mappers contacts/adresses/ribs/
|
|
* comptabilite reutilises depuis clientConsultation).
|
|
* 2. Scoping STRICT des payloads PATCH (mode strict RG-1.28 / 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.
|
|
*
|
|
* NOTE : l'onglet Information est facultatif pour tous les roles (RG-1.04
|
|
* « Information obligatoire pour la Commerciale » retiree cote back).
|
|
*/
|
|
|
|
import {
|
|
iriOf,
|
|
relationOf,
|
|
type ClientDetail,
|
|
} from '~/modules/commercial/utils/clientConsultation'
|
|
import {
|
|
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
|
|
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
|
omitEmptyRequired,
|
|
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
|
} from '~/modules/commercial/utils/clientFormRules'
|
|
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
|
|
|
|
/**
|
|
* Etat « plat » du bloc principal (groupe client:write:main). Distinct des
|
|
* brouillons Contact : ces champs vivent sur le Client lui-meme (companyName,
|
|
* categories, relation, triage), pas sur une sous-ressource ClientContact. Les
|
|
* coordonnees de contact (nom, prenom, telephones, email) ne sont plus portees
|
|
* par le Client : elles vivent exclusivement dans l'onglet Contacts.
|
|
*/
|
|
export interface MainFormDraft {
|
|
companyName: string | null
|
|
/** IRI des categories rattachees (M2M). */
|
|
categoryIris: string[]
|
|
relationType: 'distributeur' | 'courtier' | null
|
|
distributorIri: string | null
|
|
brokerIri: string | null
|
|
triageService: boolean
|
|
}
|
|
|
|
/** Etat « plat » de l'onglet Information (groupe client: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
|
|
}
|
|
|
|
/** Etat « plat » de l'onglet Comptabilite (groupe client: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 client. */
|
|
export interface ClientEditAbilities {
|
|
/** `commercial.clients.manage` : bloc principal + onglets metier. */
|
|
canManage: boolean
|
|
/** `commercial.clients.accounting.view` : visibilite de l'onglet Comptabilite. */
|
|
canAccountingView: boolean
|
|
/** `commercial.clients.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 / Contact / Adresse editables. */
|
|
businessEditable: boolean
|
|
/** Onglet Comptabilite present (affiche). */
|
|
accountingVisible: boolean
|
|
/** Onglet Comptabilite editable. */
|
|
accountingEditable: boolean
|
|
}
|
|
|
|
// ── Pre-remplissage (GET detail -> brouillons) ──────────────────────────────
|
|
|
|
/**
|
|
* Mappe le detail client vers le brouillon du bloc principal. La relation
|
|
* Distributeur/Courtier est resolue par exclusivite (RG-1.03) et son IRI extrait
|
|
* de l'embed.
|
|
*/
|
|
export function mapMainDraft(client: ClientDetail): MainFormDraft {
|
|
const relation = relationOf(client)
|
|
|
|
return {
|
|
companyName: client.companyName ?? null,
|
|
categoryIris: (client.categories ?? []).map(c => c['@id']),
|
|
relationType: relation.type,
|
|
distributorIri: iriOf(client.distributor),
|
|
brokerIri: iriOf(client.broker),
|
|
triageService: client.triageService === true,
|
|
}
|
|
}
|
|
|
|
/** Mappe le detail client vers le brouillon de l'onglet Information. */
|
|
export function mapInformationDraft(client: ClientDetail): InformationFormDraft {
|
|
return {
|
|
description: client.description ?? null,
|
|
competitors: client.competitors ?? null,
|
|
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
|
foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null,
|
|
employeesCount: client.employeesCount != null ? String(client.employeesCount) : null,
|
|
revenueAmount: client.revenueAmount ?? null,
|
|
profitAmount: client.profitAmount ?? null,
|
|
directorName: client.directorName ?? null,
|
|
}
|
|
}
|
|
|
|
/** Mappe les champs comptables du detail vers le brouillon de l'onglet (scalaires + IRI). */
|
|
export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraft {
|
|
return {
|
|
siren: client.siren ?? null,
|
|
accountNumber: client.accountNumber ?? null,
|
|
nTva: client.nTva ?? null,
|
|
tvaModeIri: iriOf(client.tvaMode),
|
|
paymentDelayIri: iriOf(client.paymentDelay),
|
|
paymentTypeIri: iriOf(client.paymentType),
|
|
bankIri: iriOf(client.bank),
|
|
}
|
|
}
|
|
|
|
// ── Scoping strict des payloads PATCH ────────────────────────────────────────
|
|
|
|
/**
|
|
* Payload du bloc principal — groupe client:write:main UNIQUEMENT. La relation
|
|
* Distributeur/Courtier est mutuellement exclusive (RG-1.03) : on ne renseigne
|
|
* que la FK correspondant au type choisi, l'autre est forcee a null.
|
|
*/
|
|
export function buildMainPayload(main: MainFormDraft): Record<string, unknown> {
|
|
// companyName omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119).
|
|
// relationType : champ transitoire (non persiste cote back) qui porte
|
|
// l'intention UI « ce client depend d'un distributeur / courtier ». Il sert
|
|
// a la validation croisee serveur (RG-1.03 bis) : si une relation est choisie,
|
|
// la FK correspondante devient obligatoire -> 422 sur distributor / broker.
|
|
// Sans equivalent derivable cote back (FK nullable), c'est la seule facon de
|
|
// rester sur « on soumet, le back tranche » plutot qu'une garde front-only.
|
|
return omitEmptyRequired({
|
|
companyName: main.companyName,
|
|
categories: main.categoryIris,
|
|
relationType: main.relationType,
|
|
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
|
|
broker: main.relationType === 'courtier' ? main.brokerIri : null,
|
|
triageService: main.triageService,
|
|
}, MAIN_REQUIRED_NON_NULLABLE_KEYS)
|
|
}
|
|
|
|
/** Payload de l'onglet Information — groupe client: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,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Payload des scalaires de l'onglet Comptabilite — groupe client:write:accounting
|
|
* UNIQUEMENT (les RIB passent par la sous-ressource /clients/{id}/ribs). La banque
|
|
* n'a de sens que pour un Virement (RG-1.12) : 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 client_contact). */
|
|
export function buildContactPayload(contact: ContactFormDraft): 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 client_address). */
|
|
export function buildAddressPayload(
|
|
address: AddressFormDraft,
|
|
isBillingEmailRequired: boolean,
|
|
): Record<string, unknown> {
|
|
// postalCode / city / street omis si vides -> 422 NotBlank (ERP-119).
|
|
return omitEmptyRequired({
|
|
isProspect: address.isProspect,
|
|
isDelivery: address.isDelivery,
|
|
isBilling: address.isBilling,
|
|
isBroker: address.isBroker,
|
|
isDistributor: address.isDistributor,
|
|
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,
|
|
billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null,
|
|
billingEmailSecondary: isBillingEmailRequired && address.hasSecondaryBillingEmail ? (address.billingEmailSecondary || null) : null,
|
|
}, ADDRESS_REQUIRED_NON_NULLABLE_KEYS)
|
|
}
|
|
|
|
/** Payload d'un RIB (sous-ressource client_rib). */
|
|
export function buildRibPayload(rib: RibFormDraft): Record<string, unknown> {
|
|
// label / bic / iban omis si vides -> 422 NotBlank au lieu d'un 400 de type
|
|
// sur un RIB partiel (ex. IBAN seul). ERP-119.
|
|
return omitEmptyRequired({
|
|
label: rib.label,
|
|
bic: rib.bic,
|
|
iban: rib.iban,
|
|
}, RIB_REQUIRED_NON_NULLABLE_KEYS)
|
|
}
|
|
|
|
// ── Gating par permission ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resout l'editabilite par zone a partir des permissions (option 1 ERP-74,
|
|
* miroir UI du re-gating champ-par-champ du ClientProcessor) :
|
|
* - bloc principal + Information/Contact/Adresse : 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: ClientEditAbilities): TabEditability {
|
|
return {
|
|
businessEditable: abilities.canManage,
|
|
accountingVisible: abilities.canAccountingView,
|
|
accountingEditable: abilities.canAccountingManage,
|
|
}
|
|
}
|