27f2dcd4c0
L'onglet Information n'est plus obligatoire pour le role metier Commerciale : il devient facultatif pour tous les roles, cote back comme cote front (le front l'etait deja). - Suppression du validateur ClientInformationCompletenessValidator et de son gating (validateInformationCompleteness / currentUserIsCommerciale) dans ClientProcessor. Security conserve (gating accounting/archive/manage). - Tests : retrait des 3 tests RG-1.04 (ClientProcessorTest) ; POST Commerciale attendu en 201 et suppression du test dedie (ClientRBACMatrixTest). - Coherence : commentaires de colonnes BDD (catalogue + migration d'init) passes a « Facultatif », nettoyage des references RG-1.04 (BusinessRoles, RbacSeeder, User, fixtures, front, specs M1, README). Le role metier Commerciale et ses permissions RBAC restent inchanges. - Pas de migration de schema (colonnes Information deja nullable).
361 lines
13 KiB
TypeScript
361 lines
13 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
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
/** 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'
|
|
|
|
/**
|
|
* Mappe le type d'adresse choisi vers les trois drapeaux back.
|
|
* « Adresse + Facturation » = livraison ET facturation sur la meme adresse.
|
|
*/
|
|
export function addressFlagsFromType(type: AddressType): AddressFlagsDraft {
|
|
switch (type) {
|
|
case 'prospect':
|
|
return { isProspect: true, isDelivery: false, isBilling: false }
|
|
case 'delivery':
|
|
return { isProspect: false, isDelivery: true, isBilling: false }
|
|
case 'billing':
|
|
return { isProspect: false, isDelivery: false, isBilling: true }
|
|
case 'delivery_billing':
|
|
return { isProspect: false, isDelivery: true, isBilling: 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.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)
|
|
}
|