Files
Starseed/frontend/modules/commercial/utils/clientFormRules.ts
T
tristan 8490de99da
Auto Tag Develop / tag (push) Successful in 7s
ERP-119 : revue validation front clients + évolutions écran client (types d'adresse, 2e email, saisies manuelles, redirection) (#80)
## 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>
2026-06-09 19:47:40 +00:00

422 lines
16 KiB
TypeScript

/**
* Regles metier pures de l'ecran « Ajouter un client » (M1 Commercial).
*
* Centralisees ici (hors composant) pour rester testables unitairement et
* partagees entre la page de creation et les futurs ecrans d'edition (1.11/1.12).
* Ces helpers ne touchent ni a l'API ni a l'etat reactif : ils prennent des
* brouillons « plats » et retournent des booleens / nouveaux objets.
*
* Le back reste la source de verite (les RG sont re-validees serveur) ; ces
* regles ne servent qu'au feedback UI immediat (gating de boutons, visibilite).
*
* NOTE : l'onglet Information est facultatif pour tous les roles. L'ancienne
* RG-1.04 (« Information obligatoire pour la Commerciale ») a ete retiree cote
* back — rien a miroiter ici.
*/
/**
* Onglets « coquille » (non encore implementes) : frame vide, passage
* automatique a l'onglet suivant (decision Tristan 28/05).
*/
export const CLIENT_FORM_PLACEHOLDER_TABS = ['transport', 'statistics', 'reports', 'exchanges'] as const
/**
* Onglets affiches uniquement en MODIFICATION (selon le role), jamais a la
* creation : Statistiques / Rapports / Echanges. A rebrancher dans les ecrans
* d'edition (1.11/1.12) via l'option `includeEditOnlyTabs`.
*/
export const CLIENT_FORM_EDIT_ONLY_TABS = ['statistics', 'reports', 'exchanges'] as const
/**
* Construit l'ordre des onglets du formulaire client.
* - L'onglet Comptabilite n'est present que si l'utilisateur a `accounting.view`
* (Bureau / Commerciale ne le voient pas).
* - Les onglets edit-only (Statistiques / Rapports / Echanges) sont exclus par
* defaut (creation) ; passer `includeEditOnlyTabs: true` pour les afficher en
* modification.
* Ordre aligne sur la spec M1 § Ecran « Ajouter un client ».
*/
export function buildClientFormTabKeys(
canAccountingView: boolean,
options: { includeEditOnlyTabs?: boolean } = {},
): string[] {
const keys = ['information', 'contact', 'address', 'transport']
if (canAccountingView) {
keys.push('accounting')
}
if (options.includeEditOnlyTabs) {
keys.push(...CLIENT_FORM_EDIT_ONLY_TABS)
}
return keys
}
/**
* Dernier onglet REMPLISSABLE d'un jeu d'onglets : le dernier qui n'est pas un
* placeholder (coquille). Role-aware sans regle ad hoc — il suffit de lui passer
* les `tabKeys` deja filtres par permission (l'onglet Comptabilite n'y figure que
* si accounting.view). Sa validation marque la fin de l'ajout (redirection liste).
*/
export function lastFillableTabKey(tabKeys: string[]): string | undefined {
return [...tabKeys].reverse().find(
key => !(CLIENT_FORM_PLACEHOLDER_TABS as readonly string[]).includes(key),
)
}
/**
* Codes de categorie « intermediaire » : un client dont la categorie est
* Distributeur ou Courtier n'a ni relation amont (il EST le distributeur /
* courtier) ni prestation de triage. Sert a conditionner l'affichage des champs
* « Relation » et « Prestation de triage » du formulaire principal.
*/
export const DISTRIBUTOR_BROKER_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER'] as const
/**
* Vrai des qu'au moins une categorie « ordinaire » (autre que Distributeur /
* Courtier) est selectionnee. Les champs « Relation » (depend du distributeur /
* courtier) et « Prestation de triage » du formulaire principal sont masques par
* defaut et reveles uniquement dans ce cas.
*/
export function showsRelationAndTriageFields(selectedCategoryCodes: string[]): boolean {
return selectedCategoryCodes.some(
code => !(DISTRIBUTOR_BROKER_CATEGORY_CODES as readonly string[]).includes(code),
)
}
/** Sous-ensemble d'un contact necessaire aux regles de nommage (RG-1.05/1.14). */
export interface ContactDraft {
firstName: string | null
lastName: string | null
}
/** Drapeaux d'usage d'une adresse (RG-1.06/07/08/11). */
export interface AddressFlagsDraft {
isProspect: boolean
isDelivery: boolean
isBilling: boolean
/** Adresse Courtier — type autonome exclusif (comme isProspect). */
isBroker: boolean
/** Adresse Distributeur — type autonome exclusif (comme isProspect). */
isDistributor: boolean
}
/** Vrai si une chaine porte au moins un caractere non-espace. */
function isFilled(value: string | null | undefined): boolean {
return value !== null && value !== undefined && value.trim() !== ''
}
/**
* RG-1.05 : un contact est valide des qu'il porte un nom OU un prenom.
*/
export function isContactNamed(contact: ContactDraft): boolean {
return isFilled(contact.firstName) || isFilled(contact.lastName)
}
/**
* RG-1.14 : l'onglet Contact ne peut etre finalise que s'il reste au moins un
* contact nomme (nom ou prenom).
*/
export function hasAtLeastOneValidContact(contacts: ContactDraft[]): boolean {
return contacts.some(isContactNamed)
}
/**
* Primitive reutilisable : vrai si TOUTES les valeurs fournies sont vides (null /
* undefined / espaces uniquement). Sert a detecter un bloc de collection
* totalement vide (amorce non remplie). Un bloc qui porte la moindre donnee
* n'est PAS « blank » : il doit etre soumis pour declencher sa 422 inline plutot
* que d'etre saute silencieusement.
*/
export function isBlankRow(values: (string | null | undefined)[]): boolean {
return values.every(value => !isFilled(value))
}
/** Champs saisissables d'un bloc contact (pour detecter un bloc totalement vide). */
export interface ContactFillableDraft extends ContactDraft {
jobTitle: string | null
phonePrimary: string | null
phoneSecondary: string | null
email: string | null
}
/**
* Vrai si AUCUN champ saisissable du bloc contact n'est rempli. Distingue un bloc
* d'amorce vide (a ignorer au submit) d'un bloc partiellement rempli sans nom
* (email / telephone / fonction seul) : ce dernier doit etre soumis pour
* declencher la 422 RG-1.05 (« prenom ou nom obligatoire ») affichee inline.
*/
export function isContactBlank(contact: ContactFillableDraft): boolean {
return isBlankRow([
contact.firstName,
contact.lastName,
contact.jobTitle,
contact.phonePrimary,
contact.phoneSecondary,
contact.email,
])
}
/** Champs saisissables d'un bloc RIB (pour detecter un bloc totalement vide). */
export interface RibFillableDraft {
label: string | null
bic: string | null
iban: string | null
}
/**
* Vrai si AUCUN champ du bloc RIB n'est rempli. Un RIB partiellement rempli (ex.
* IBAN seul) n'est PAS « blank » : il doit etre soumis pour declencher les 422
* NotBlank (label / bic / iban) inline plutot que d'etre saute silencieusement.
*/
export function isRibBlank(rib: RibFillableDraft): boolean {
return isBlankRow([rib.label, rib.bic, rib.iban])
}
/**
* RG-1.13 : un RIB est complet quand ses trois champs sont remplis (label, BIC,
* IBAN). Predicat par-bloc partage entre le gating du bouton « + RIB » (le
* dernier bloc doit etre complet avant d'en ajouter un autre) et la validation
* de l'onglet (au moins un RIB complet si reglement LCR).
*/
export function isRibComplete(rib: RibFillableDraft): boolean {
return isFilled(rib.label) && isFilled(rib.bic) && isFilled(rib.iban)
}
/**
* RG-1.06/07/08 : une adresse de prospection est exclusive d'une adresse de
* livraison/facturation. Prospect n'est selectionnable que si ni Livraison ni
* Facturation ne sont coches.
*/
export function canSelectProspect(flags: AddressFlagsDraft): boolean {
return !flags.isDelivery && !flags.isBilling
}
/**
* RG-1.06/07/08 : Livraison et Facturation ne sont selectionnables que si
* Prospect n'est pas coche.
*/
export function canSelectDeliveryOrBilling(flags: AddressFlagsDraft): boolean {
return !flags.isProspect
}
/**
* Applique l'exclusivite Prospect / (Livraison|Facturation) au changement d'un
* drapeau. Cocher Prospect efface Livraison + Facturation ; cocher Livraison ou
* Facturation efface Prospect. Decocher n'a aucun effet de bord. Retourne un
* nouvel objet (pas de mutation de l'entree).
*/
export function applyProspectExclusivity(
flags: AddressFlagsDraft,
field: keyof AddressFlagsDraft,
value: boolean,
): AddressFlagsDraft {
const next: AddressFlagsDraft = { ...flags, [field]: value }
if (value && field === 'isProspect') {
next.isDelivery = false
next.isBilling = false
}
else if (value && (field === 'isDelivery' || field === 'isBilling')) {
next.isProspect = false
}
return next
}
/**
* RG-1.11 : l'email de facturation n'est visible/obligatoire que si l'adresse
* est une adresse de facturation.
*/
export function isBillingEmailRequired(flags: AddressFlagsDraft): boolean {
return flags.isBilling
}
/**
* Type d'adresse expose a l'utilisateur (Select unique remplacant les trois
* cases a cocher). Sucre purement front : le back continue de recevoir les
* drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). Les seules
* combinaisons proposees respectent l'exclusivite Prospect (RG-1.06/07/08).
*/
export type AddressType = 'prospect' | 'delivery' | 'billing' | 'delivery_billing' | 'broker' | 'distributor'
/**
* Mappe le type d'adresse choisi vers les cinq drapeaux back.
* « Adresse + Facturation » = livraison ET facturation sur la meme adresse.
* Courtier / Distributeur sont autonomes (un seul drapeau, exclusif du reste).
*/
export function addressFlagsFromType(type: AddressType): AddressFlagsDraft {
const none: AddressFlagsDraft = {
isProspect: false, isDelivery: false, isBilling: false, isBroker: false, isDistributor: false,
}
switch (type) {
case 'prospect':
return { ...none, isProspect: true }
case 'delivery':
return { ...none, isDelivery: true }
case 'billing':
return { ...none, isBilling: true }
case 'delivery_billing':
return { ...none, isDelivery: true, isBilling: true }
case 'broker':
return { ...none, isBroker: true }
case 'distributor':
return { ...none, isDistributor: true }
}
}
/**
* Reconstruit le type d'adresse a partir des drapeaux (consultation / edition
* d'une adresse persistee, ou amorce vierge). Retourne null si aucun drapeau
* n'est positionne — le Select reste alors a saisir (et bloque la validation).
*/
export function addressTypeFromFlags(flags: AddressFlagsDraft): AddressType | null {
if (flags.isProspect) return 'prospect'
if (flags.isBroker) return 'broker'
if (flags.isDistributor) return 'distributor'
if (flags.isDelivery && flags.isBilling) return 'delivery_billing'
if (flags.isDelivery) return 'delivery'
if (flags.isBilling) return 'billing'
return null
}
/**
* Sous-ensemble d'une adresse necessaire a sa validite par-bloc : drapeaux
* d'usage (pour le type + l'email de facturation conditionnel), sites et
* categories rattaches, email de facturation.
*/
export interface AddressValidityDraft extends AddressFlagsDraft {
categoryIris: string[]
siteIris: string[]
billingEmail: string | null
}
/**
* Validite par-bloc d'une adresse : type renseigne (RG-1.06/07/08), >= 1 site
* (RG-1.10), >= 1 categorie, et email de facturation rempli si l'adresse est de
* facturation (RG-1.11). Predicat partage entre le gating du bouton « + Adresse »
* (le dernier bloc doit etre valide avant d'en ajouter un autre) et la
* validation de l'onglet (toutes les adresses valides).
*/
export function isAddressValid(address: AddressValidityDraft): boolean {
return addressTypeFromFlags(address) !== null
&& address.siteIris.length >= 1
&& address.categoryIris.length >= 1
&& (!isBillingEmailRequired(address) || isFilled(address.billingEmail))
}
/** Code stable du type de reglement « virement » (cf. PaymentType.code, RG-1.12). */
const PAYMENT_TYPE_TRANSFER = 'VIREMENT'
/** Code stable du type de reglement « lettre de change » (RG-1.13). */
const PAYMENT_TYPE_LCR = 'LCR'
/**
* RG-1.12 : la banque est obligatoire lorsque le type de reglement est un
* virement.
*/
export function isBankRequiredForPaymentType(code: string | null | undefined): boolean {
return code === PAYMENT_TYPE_TRANSFER
}
/**
* RG-1.13 : au moins un RIB complet est obligatoire lorsque le type de reglement
* est une LCR.
*/
export function isRibRequiredForPaymentType(code: string | null | undefined): boolean {
return code === PAYMENT_TYPE_LCR
}
/** Champs saisissables de l'onglet Information (tous facultatifs). */
export interface InformationFieldsDraft {
description: string | null
competitors: string | null
foundedAt: string | null
employeesCount: string | null
revenueAmount: string | null
profitAmount: string | null
directorName: string | null
}
/**
* Vrai si au moins un champ de l'onglet Information est rempli. L'onglet est
* facultatif (aucun champ obligatoire), mais on n'autorise pas une validation
* « a vide » a la creation : sans donnee, rien a enregistrer, l'utilisateur
* passe directement a l'onglet Contact. (En edition, vider tous les champs reste
* une action legitime : ce gate n'y est pas applique.)
*/
export function hasAtLeastOneInformationField(information: InformationFieldsDraft): boolean {
return !isBlankRow([
information.description,
information.competitors,
information.foundedAt,
information.employeesCount,
information.revenueAmount,
information.profitAmount,
information.directorName,
])
}
/** Sous-ensemble du brouillon comptable portant les six champs obligatoires. */
export interface AccountingRequiredDraft {
siren: string | null
accountNumber: string | null
nTva: string | null
tvaModeIri: string | null
paymentDelayIri: string | null
paymentTypeIri: string | null
}
/**
* RG-1.30 : les six champs scalaires de l'onglet Comptabilite sont obligatoires
* pour valider l'onglet (SIREN, N de compte, Mode de TVA, N de TVA, Delai de
* reglement, Type de reglement). bank / RIB restent conditionnels (RG-1.12 /
* RG-1.13) et sont evalues a part. Miroir front du
* ClientAccountingCompletenessValidator : meme gate que les onglets Contact /
* Adresse (bouton « Valider » desactive tant que l'onglet n'est pas complet).
*/
export function hasAllRequiredAccountingFields(accounting: AccountingRequiredDraft): boolean {
const filled = (v: string | null): boolean => v !== null && v.trim() !== ''
return filled(accounting.siren)
&& filled(accounting.accountNumber)
&& filled(accounting.nTva)
&& filled(accounting.tvaModeIri)
&& filled(accounting.paymentDelayIri)
&& filled(accounting.paymentTypeIri)
}
// ── Champs requis adosses a une colonne NON-nullable (ERP-119) ───────────────
// Ces champs requis (NotBlank back) sont portes par une colonne Doctrine NON
// nullable. Si le front envoie `null` (champ vide, desormais possible : le bouton
// « Valider » n'est plus desactive), API Platform rejette la valeur en 400 de TYPE
// a la deserialisation (« The type of the X attribute must be string, NULL given »)
// AVANT le Validator -> pas de violation, donc pas d'erreur rouge cote champ.
// La parade : OMETTRE la cle du payload quand elle est vide. Sans la cle, la
// propriete garde son defaut null cote entite et #[Assert\NotBlank] se declenche
// normalement -> 422 avec propertyPath, mappee en rouge sous le champ.
// (Les champs requis a colonne NULLABLE — contacts, scalaires compta — acceptent
// deja `null` et renvoient une 422 : inutile de les omettre.)
export const MAIN_REQUIRED_NON_NULLABLE_KEYS = ['companyName'] as const
export const ADDRESS_REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const
export const RIB_REQUIRED_NON_NULLABLE_KEYS = ['label', 'bic', 'iban'] as const
/**
* Retire d'un payload d'ecriture les cles requises laissees vides (null / ''
* / undefined), pour laisser le back produire une 422 NotBlank par champ plutot
* qu'un 400 de type sur une colonne non-nullable. Mute et retourne le payload.
* A n'appliquer QU'aux cles ci-dessus (champs requis a colonne non-nullable).
*/
export function omitEmptyRequired<T extends Record<string, unknown>>(
payload: T,
requiredKeys: readonly string[],
): T {
for (const key of requiredKeys) {
const value = payload[key]
if (value === null || value === undefined || value === '') {
delete payload[key]
}
}
return payload
}