/** * 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>( 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 }