Files
Starseed/frontend/modules/commercial/utils/supplierFormRules.ts
T
tristan 8eff37186d fix(front) : 422 sur champ requis vide en edition fournisseur (ERP-96)
En edition (PATCH merge), omettre la cle d'un champ requis vide laissait la valeur
serveur inchangee -> faux 200 (l'ancien code postal etait conserve). Nouveau helper
blankEmptyRequired + flag forUpdate sur les builders : a la creation (POST) on omet
toujours la cle (NotBlank), en edition d'une ligne existante on envoie '' (chaine
valide, pas de 400 de type) pour declencher NotBlank 422 inline sous le champ.
Applique au bloc principal, aux adresses et aux RIB (selon id !== null).
2026-06-11 08:36:47 +02:00

245 lines
9.7 KiB
TypeScript

/**
* Regles metier pures de l'ecran « Ajouter un fournisseur » (M2 Commercial).
*
* Miroir de `clientFormRules.ts` (M1), centralisees ici (hors composant) pour
* rester testables unitairement et partagees entre la creation et les ecrans
* d'edition/consultation (95/96). Ces helpers ne touchent ni a l'API ni a l'etat
* reactif : ils prennent des brouillons « plats » et retournent des booleens.
*
* Le back reste la source de verite (les RG sont re-validees serveur, mode
* strict) ; ces regles ne servent qu'au feedback UI immediat (gating de boutons).
*
* Differences M2 vs M1 :
* - Adresse via enum `addressType` (PROSPECT/DEPART/RENDU, RG-2.09) — pas de
* drapeaux ni d'exclusivite a gerer cote front (le radio est exclusif par nature).
* - Pas d'email de facturation, pas de relation Distributeur/Courtier.
*/
import type { SupplierAddressType } from '~/modules/commercial/types/supplierForm'
/**
* Onglets « coquille » (non encore implementes) : frame vide, passage
* automatique a l'onglet suivant (aligne M1).
*/
export const SUPPLIER_FORM_PLACEHOLDER_TABS = ['transport', 'statistics', 'reports', 'exchanges'] as const
/**
* Onglets affiches uniquement en MODIFICATION/CONSULTATION (jamais a la
* creation) : Statistiques / Rapports / Echanges. A rebrancher dans les ecrans
* 95/96 via l'option `includeEditOnlyTabs`.
*/
export const SUPPLIER_FORM_EDIT_ONLY_TABS = ['statistics', 'reports', 'exchanges'] as const
/**
* Construit l'ordre des onglets du formulaire fournisseur.
* - L'onglet Comptabilite n'est present que si l'utilisateur a `accounting.view`
* (Bureau / Commerciale ne le voient pas).
* - Les onglets edit-only sont exclus par defaut (creation) ; passer
* `includeEditOnlyTabs: true` pour les afficher en modification/consultation.
* Ordre aligne sur la spec M2 § Ecran « Ajouter un fournisseur » (barre 5 onglets).
*/
export function buildSupplierFormTabKeys(
canAccountingView: boolean,
options: { includeEditOnlyTabs?: boolean } = {},
): string[] {
const keys = ['information', 'contacts', 'addresses', 'transport']
if (canAccountingView) {
keys.push('accounting')
}
if (options.includeEditOnlyTabs) {
keys.push(...SUPPLIER_FORM_EDIT_ONLY_TABS)
}
return keys
}
/**
* Dernier onglet REMPLISSABLE d'un jeu d'onglets : le dernier qui n'est pas un
* placeholder. Role-aware sans regle ad hoc — il suffit de lui passer les
* `tabKeys` deja filtres par permission. Sa validation marque la fin de l'ajout.
*/
export function lastFillableTabKey(tabKeys: string[]): string | undefined {
return [...tabKeys].reverse().find(
key => !(SUPPLIER_FORM_PLACEHOLDER_TABS as readonly string[]).includes(key),
)
}
/** Sous-ensemble d'un contact necessaire aux regles de nommage (RG-2.04/2.13). */
export interface ContactDraft {
firstName: string | null
lastName: string | null
}
/** 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-2.04 : 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-2.13 : l'onglet Contacts 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. 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-2.04 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 inline plutot que d'etre saute silencieusement.
*/
export function isRibBlank(rib: RibFillableDraft): boolean {
return isBlankRow([rib.label, rib.bic, rib.iban])
}
/**
* RG-2.08 : un RIB est complet quand ses trois champs sont remplis (label, BIC,
* IBAN). Predicat partage entre le gating du bouton « + RIB » 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)
}
/**
* Sous-ensemble d'une adresse necessaire a sa validite par-bloc : type (enum),
* sites et categories rattaches.
*/
export interface AddressValidityDraft {
addressType: SupplierAddressType | null
categoryIris: string[]
siteIris: string[]
}
/**
* Validite par-bloc d'une adresse : type renseigne (RG-2.09), >= 1 site (RG-2.06)
* et >= 1 categorie (RG-2.10). 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). Pas d'email de
* facturation cote fournisseur (difference M1).
*/
export function isAddressValid(address: AddressValidityDraft): boolean {
return address.addressType !== null
&& address.siteIris.length >= 1
&& address.categoryIris.length >= 1
}
/** Code stable du type de reglement « virement » (RG-2.07). */
const PAYMENT_TYPE_TRANSFER = 'VIREMENT'
/** Code stable du type de reglement « lettre de change » (RG-2.08). */
const PAYMENT_TYPE_LCR = 'LCR'
/** RG-2.07 : 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-2.08 : 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 requis adosses a une colonne NON-nullable (ERP-119) ───────────────
// Memes contraintes qu'au M1 : un champ requis (NotBlank) porte par une colonne
// Doctrine NON nullable rejette `null` en 400 de TYPE avant le Validator. Parade :
// OMETTRE la cle du payload quand elle est vide -> le back produit une 422 NotBlank
// avec propertyPath, mappee en rouge sous le champ.
export const MAIN_REQUIRED_NON_NULLABLE_KEYS = ['companyName'] as const
// addressType : colonne non-nullable + NotBlank cote back. Envoyer `null` (radio
// non choisi) provoque un 400 de TYPE a la deserialisation AVANT le Validator
// (« must be string, NULL given ») -> pas de violation, pas d'erreur inline. On
// omet donc la cle quand elle est vide pour obtenir une 422 NotBlank propertyPath.
export const ADDRESS_REQUIRED_NON_NULLABLE_KEYS = ['addressType', '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.
*/
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
}
/**
* Variante PATCH (edition d'une ligne EXISTANTE) : remplace les cles requises
* laissees vides par une chaine vide `''` au lieu de les OMETTRE.
*
* Pourquoi pas `omitEmptyRequired` en edition : un PATCH a semantique merge — une
* cle absente laisse la valeur serveur INCHANGEE. Vider un champ requis puis valider
* renverrait alors un 200 trompeur (l'ancienne valeur est conservee). En envoyant
* `''`, la propriete `?string` est bien deserialisee (pas de 400 de type, contrairement
* a `null` sur une colonne non-nullable), puis le Validator `NotBlank(trim)` la rejette
* -> 422 avec propertyPath, mappee inline sous le champ. Mute et retourne le payload.
*/
export function blankEmptyRequired<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 === '') {
(payload as Record<string, unknown>)[key] = ''
}
}
return payload
}