From 1193832505a2753178a5a2e3f16da4cd42ea15ed Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 2 Jun 2026 17:20:19 +0200 Subject: [PATCH] feat(front) : regles metier + types + stub autocomplete creation client (ERP-63) - clientFormRules : RG-1.05/1.14 (contacts), RG-1.06/07/08/11 (exclusivite adresse + email facturation), RG-1.12/1.13 (banque/RIB selon type de reglement), construction des onglets (gating Comptabilite). 18 tests Vitest. - types/clientForm : brouillons locaux (contact/adresse/RIB) + fabriques. - useAddressAutocomplete : STUB ERP-63 conforme a la signature ERP-66 (mode degrade), a remplacer par l'implementation BAN d'ERP-66. Note : RG-1.04 (Information obligatoire pour la Commerciale) volontairement non miroitee cote front (le payload /api/me ne porte pas le code de role ; Bureau et Commerciale partagent les memes permissions). Le back l'applique de maniere fiable. --- .../modules/commercial/types/clientForm.ts | 98 ++++++++++++ .../utils/__tests__/clientFormRules.spec.ts | 144 ++++++++++++++++++ .../commercial/utils/clientFormRules.ts | 143 +++++++++++++++++ .../composables/useAddressAutocomplete.ts | 60 ++++++++ 4 files changed, 445 insertions(+) create mode 100644 frontend/modules/commercial/types/clientForm.ts create mode 100644 frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts create mode 100644 frontend/modules/commercial/utils/clientFormRules.ts create mode 100644 frontend/shared/composables/useAddressAutocomplete.ts diff --git a/frontend/modules/commercial/types/clientForm.ts b/frontend/modules/commercial/types/clientForm.ts new file mode 100644 index 0000000..c24474d --- /dev/null +++ b/frontend/modules/commercial/types/clientForm.ts @@ -0,0 +1,98 @@ +/** + * Types « brouillon » de l'ecran « Ajouter un client » (M1 Commercial). + * + * Ces interfaces decrivent l'etat LOCAL du formulaire (refs Vue), distinct des + * DTO de l'API : elles portent en plus des champs purement UI (`hasSecondaryPhone`) + * et l'`iri` Hydra des entites creees (necessaire pour rattacher une adresse a + * des contacts deja persistes, M2M). Partage par la page et les blocs reutilisables + * `ClientContactBlock` / `ClientAddressBlock` (reutilises par 1.11/1.12). + */ + +/** Un contact du client (onglet Contact). */ +export interface ContactFormDraft { + /** Id serveur une fois le contact cree (null tant que non persiste). */ + id: number | null + /** IRI Hydra du contact cree — utilise pour le rattachement M2M cote adresse. */ + iri: string | null + firstName: string | null + lastName: string | null + jobTitle: string | null + phonePrimary: string | null + phoneSecondary: string | null + email: string | null + /** UI : le 2e numero a ete revele via le bouton « + ». */ + hasSecondaryPhone: boolean +} + +/** Une adresse du client (onglet Adresse). */ +export interface AddressFormDraft { + id: number | null + isProspect: boolean + isDelivery: boolean + isBilling: boolean + country: string + postalCode: string | null + city: string | null + street: string | null + streetComplement: string | null + /** IRI des categories rattachees (hors DISTRIBUTEUR/COURTIER — RG-1.29). */ + categoryIris: string[] + /** IRI des sites Starseed rattaches (>= 1 obligatoire — RG-1.10). */ + siteIris: string[] + /** IRI des contacts rattaches (= blocs Contact deja crees). */ + contactIris: string[] + /** Email de facturation (obligatoire si isBilling — RG-1.11). */ + billingEmail: string | null +} + +/** Un RIB du client (onglet Comptabilite). */ +export interface RibFormDraft { + id: number | null + label: string | null + bic: string | null + iban: string | null +} + +/** Fabrique un contact vierge. */ +export function emptyContact(): ContactFormDraft { + return { + id: null, + iri: null, + firstName: null, + lastName: null, + jobTitle: null, + phonePrimary: null, + phoneSecondary: null, + email: null, + hasSecondaryPhone: false, + } +} + +/** Fabrique une adresse vierge (pays prerempli « France »). */ +export function emptyAddress(): AddressFormDraft { + return { + id: null, + isProspect: false, + isDelivery: false, + isBilling: false, + country: 'France', + postalCode: null, + city: null, + street: null, + streetComplement: null, + categoryIris: [], + siteIris: [], + contactIris: [], + billingEmail: null, + } +} + +/** Fabrique un RIB vierge. */ +export function emptyRib(): RibFormDraft { + return { + id: null, + label: null, + bic: null, + iban: null, + } +} diff --git a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts new file mode 100644 index 0000000..36e54dd --- /dev/null +++ b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from 'vitest' +import { + applyProspectExclusivity, + buildClientFormTabKeys, + canSelectDeliveryOrBilling, + canSelectProspect, + hasAtLeastOneValidContact, + isBankRequiredForPaymentType, + isBillingEmailRequired, + isContactNamed, + isRibRequiredForPaymentType, + type ContactDraft, +} from '../clientFormRules' + +describe('buildClientFormTabKeys (gating onglet Comptabilite)', () => { + it('inclut l onglet accounting si l utilisateur a accounting.view', () => { + expect(buildClientFormTabKeys(true)).toContain('accounting') + }) + + it('exclut l onglet accounting sinon (Bureau / Commerciale)', () => { + expect(buildClientFormTabKeys(false)).not.toContain('accounting') + }) + + it('place accounting entre transport et statistics quand present', () => { + const keys = buildClientFormTabKeys(true) + expect(keys).toEqual([ + 'information', + 'contact', + 'address', + 'transport', + 'accounting', + 'statistics', + 'reports', + 'exchanges', + ]) + }) +}) + +describe('isContactNamed (RG-1.05)', () => { + it('vrai si le prenom est renseigne', () => { + expect(isContactNamed({ firstName: 'Alice', lastName: null })).toBe(true) + }) + + it('vrai si le nom est renseigne', () => { + expect(isContactNamed({ firstName: null, lastName: 'Martin' })).toBe(true) + }) + + it('faux si les deux sont vides ou espaces uniquement', () => { + expect(isContactNamed({ firstName: null, lastName: null })).toBe(false) + expect(isContactNamed({ firstName: ' ', lastName: '' })).toBe(false) + }) +}) + +describe('hasAtLeastOneValidContact (RG-1.14)', () => { + it('faux sur une liste vide', () => { + expect(hasAtLeastOneValidContact([])).toBe(false) + }) + + it('faux si aucun contact n a de nom ni prenom', () => { + const contacts: ContactDraft[] = [ + { firstName: null, lastName: null }, + { firstName: '', lastName: ' ' }, + ] + expect(hasAtLeastOneValidContact(contacts)).toBe(false) + }) + + it('vrai des qu un contact a un nom ou un prenom', () => { + const contacts: ContactDraft[] = [ + { firstName: null, lastName: null }, + { firstName: 'Bob', lastName: null }, + ] + expect(hasAtLeastOneValidContact(contacts)).toBe(true) + }) +}) + +describe('exclusivite Prospect / Livraison / Facturation (RG-1.06/07/08)', () => { + it('Prospect est selectionnable tant que ni Livraison ni Facturation', () => { + expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true) + expect(canSelectProspect({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false) + expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: true })).toBe(false) + }) + + it('Livraison / Facturation selectionnables tant que pas Prospect', () => { + expect(canSelectDeliveryOrBilling({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true) + expect(canSelectDeliveryOrBilling({ isProspect: true, isDelivery: false, isBilling: false })).toBe(false) + }) + + it('cocher Prospect efface Livraison et Facturation', () => { + const next = applyProspectExclusivity( + { isProspect: false, isDelivery: true, isBilling: true }, + 'isProspect', + true, + ) + expect(next).toEqual({ isProspect: true, isDelivery: false, isBilling: false }) + }) + + it('cocher Livraison efface Prospect', () => { + const next = applyProspectExclusivity( + { isProspect: true, isDelivery: false, isBilling: false }, + 'isDelivery', + true, + ) + expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false }) + }) + + it('cocher Facturation efface Prospect mais conserve Livraison', () => { + const next = applyProspectExclusivity( + { isProspect: true, isDelivery: true, isBilling: false }, + 'isBilling', + true, + ) + expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: true }) + }) + + it('decocher un drapeau ne reactive rien d autre', () => { + const next = applyProspectExclusivity( + { isProspect: false, isDelivery: true, isBilling: true }, + 'isBilling', + false, + ) + expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false }) + }) +}) + +describe('isBillingEmailRequired (RG-1.11)', () => { + it('obligatoire uniquement si Facturation est coche', () => { + expect(isBillingEmailRequired({ isProspect: false, isDelivery: false, isBilling: true })).toBe(true) + expect(isBillingEmailRequired({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false) + }) +}) + +describe('regles type de reglement (RG-1.12 / RG-1.13)', () => { + it('banque obligatoire si VIREMENT', () => { + expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true) + expect(isBankRequiredForPaymentType('LCR')).toBe(false) + expect(isBankRequiredForPaymentType(null)).toBe(false) + }) + + it('RIB obligatoire si LCR', () => { + expect(isRibRequiredForPaymentType('LCR')).toBe(true) + expect(isRibRequiredForPaymentType('VIREMENT')).toBe(false) + expect(isRibRequiredForPaymentType(null)).toBe(false) + }) +}) diff --git a/frontend/modules/commercial/utils/clientFormRules.ts b/frontend/modules/commercial/utils/clientFormRules.ts new file mode 100644 index 0000000..135c5ef --- /dev/null +++ b/frontend/modules/commercial/utils/clientFormRules.ts @@ -0,0 +1,143 @@ +/** + * 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 RG-1.04 (Information obligatoire pour la Commerciale) : volontairement + * NON miroite cote front pour l'instant. Le payload /api/me ne porte pas le code + * de role (roles = IRIs opaques) et Bureau partage les memes permissions que + * Commerciale : aucun signal fiable pour distinguer le role cote front. Le back + * (ClientProcessor, via BusinessRoleAware) applique la regle de maniere fiable ; + * a rebrancher ici des qu'un code de role sera expose dans /api/me. + */ + +/** + * 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 + +/** + * Construit l'ordre des onglets de l'ecran « Ajouter un client ». L'onglet + * Comptabilite n'est present que si l'utilisateur a `accounting.view` — sinon il + * est totalement absent (Bureau / Commerciale ne le voient pas). Ordre aligne + * sur la spec M1 § Ecran « Ajouter un client ». + */ +export function buildClientFormTabKeys(canAccountingView: boolean): string[] { + const keys = ['information', 'contact', 'address', 'transport'] + if (canAccountingView) { + keys.push('accounting') + } + keys.push('statistics', 'reports', 'exchanges') + return keys +} + +/** 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) +} + +/** + * 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 +} + +/** 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 +} diff --git a/frontend/shared/composables/useAddressAutocomplete.ts b/frontend/shared/composables/useAddressAutocomplete.ts new file mode 100644 index 0000000..538447e --- /dev/null +++ b/frontend/shared/composables/useAddressAutocomplete.ts @@ -0,0 +1,60 @@ +// STUB ERP-63 — remplacé par l'implémentation BAN d'ERP-66. +// +// Ce fichier appartient fonctionnellement à ERP-66 (#66). ERP-63 n'en livre +// qu'un STUB pour ne pas se bloquer : la vraie implémentation (appels +// api-adresse.data.gouv.fr) viendra remplacer le CORPS des deux méthodes SANS +// changer leur signature ni l'usage côté composant. +// +// Contrat figé par ERP-66 (c'est lui qui fait foi) : +// searchCity(postalCode) -> liste { city, postalCode } +// searchAddress(query, cp?) -> liste { label, street, postalCode, city } +// En cas d'erreur/timeout, la méthode THROW. Le composant catch l'erreur, +// affiche un toast d'avertissement et bascule en saisie libre (MalioInputText). +// +// Comportement du stub : les deux méthodes throw systématiquement → l'onglet +// Adresse part directement en mode dégradé (Ville + Adresse en saisie libre, +// Code postal saisi manuellement). Aucun appel réseau n'est émis ici. + +/** Une suggestion de ville renvoyée à partir d'un code postal. */ +export interface CitySuggestion { + city: string + postalCode: string +} + +/** Une suggestion d'adresse complète (saisie assistée du champ « Adresse »). */ +export interface AddressSuggestion { + label: string + street: string + postalCode: string + city: string +} + +export interface AddressAutocomplete { + searchCity(postalCode: string): Promise + searchAddress(query: string, postalCode?: string): Promise +} + +/** Erreur signalant que le service d'autocomplétion BAN n'est pas disponible. */ +export class AddressAutocompleteUnavailableError extends Error { + constructor() { + // Message technique (non affiché tel quel) : le composant remonte son + // propre libellé i18n. Sert au debug / aux logs uniquement. + super('Address autocomplete (BAN) is not available yet — ERP-66 stub.') + this.name = 'AddressAutocompleteUnavailableError' + } +} + +/** + * STUB : renvoie un composable conforme au contrat ERP-66 dont les méthodes + * échouent toujours, forçant le mode dégradé côté onglet Adresse. + */ +export function useAddressAutocomplete(): AddressAutocomplete { + return { + async searchCity(_postalCode: string): Promise { + throw new AddressAutocompleteUnavailableError() + }, + async searchAddress(_query: string, _postalCode?: string): Promise { + throw new AddressAutocompleteUnavailableError() + }, + } +}