4d20583e1b
Le passage d'un tiers de LCR vers virement (ou autre) supprimait ses RIB en base via le front (DELETE differe). Le RIB est une coordonnee bancaire du tiers, decouplee du mode de reglement : on le conserve desormais pour un eventuel retour en LCR. Clients ET fournisseurs (new.vue / [id]/edit.vue) : - onPaymentTypeChange ne marque plus les RIB existants pour suppression et ne vide plus la saisie ; les RIB sont seulement masques (visibleRibs) et reapparaissent tels quels au retour LCR. - submitAccounting ne (re)soumet les RIB que sous LCR ; seules les suppressions EXPLICITES (corbeille d'un bloc) restent en DELETE. Consultation ([id]/index.vue) : RIB dormants totalement masques hors-LCR, via le helper pur type-safe paymentTypeCodeOf (clientConsultation / supplierConsultation) + tests Vitest. Aucune modification back (RG LCR -> >=1 RIB deja correcte, rien n'interdit un RIB sur un tiers non-LCR) ni migration.
339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
/**
|
|
* Helpers purs de l'ecran « Consultation client » (M1 Commercial, lecture seule).
|
|
*
|
|
* Mappent le payload `GET /api/clients/{id}` (relations embarquees, cf. groupe
|
|
* `client:item:read` + `client:read:accounting`) vers les brouillons « plats »
|
|
* partages avec les blocs reutilisables `ClientContactBlock` / `ClientAddressBlock`
|
|
* et l'onglet Comptabilite. Ne touchent ni a l'API ni a l'etat reactif : testables
|
|
* unitairement (cf. clientConsultation.spec.ts).
|
|
*
|
|
* Rappels de contrat back (verifies sur l'API reelle) :
|
|
* - les relations ManyToOne (distributor/broker/tvaMode/paymentType/...) sont
|
|
* serialisees en OBJETS embarques (avec @id + companyName/code/label), pas en IRI nu ;
|
|
* - les champs nuls sont OMIS du JSON (skip_null_values) → toujours lire avec `?? null` ;
|
|
* - les champs comptables et `ribs` sont TOTALEMENT ABSENTS sans permission
|
|
* accounting.view (gate serveur via ClientReadGroupContextBuilder).
|
|
*/
|
|
|
|
import { formatPhoneFR } from '~/shared/utils/phone'
|
|
import type {
|
|
AddressFormDraft,
|
|
ContactFormDraft,
|
|
RibFormDraft,
|
|
} from '~/modules/commercial/types/clientForm'
|
|
|
|
/** Reference Hydra embarquee minimale (@id toujours present). */
|
|
export interface HydraRef {
|
|
'@id': string
|
|
[key: string]: unknown
|
|
}
|
|
|
|
/** Une relation peut etre embarquee (objet), un IRI nu (chaine) ou absente. */
|
|
export type Relation = HydraRef | string | null | undefined
|
|
|
|
/** Site embarque dans une adresse (groupe site:read). */
|
|
export interface SiteRead extends HydraRef {
|
|
name?: string
|
|
color?: string
|
|
}
|
|
|
|
/** Categorie embarquee (groupe category:read). */
|
|
export interface CategoryRead extends HydraRef {
|
|
code?: string
|
|
name?: string
|
|
}
|
|
|
|
/** Contact embarque (groupe client_contact:read). */
|
|
export interface ContactRead extends HydraRef {
|
|
id: number
|
|
firstName?: string | null
|
|
lastName?: string | null
|
|
jobTitle?: string | null
|
|
phonePrimary?: string | null
|
|
phoneSecondary?: string | null
|
|
email?: string | null
|
|
}
|
|
|
|
/** Adresse embarquee (groupe client_address:read). */
|
|
export interface AddressRead extends HydraRef {
|
|
id: number
|
|
country?: string | null
|
|
postalCode?: string | null
|
|
city?: string | null
|
|
street?: string | null
|
|
streetComplement?: string | null
|
|
billingEmail?: string | null
|
|
billingEmailSecondary?: string | null
|
|
isProspect?: boolean
|
|
isDelivery?: boolean
|
|
isBilling?: boolean
|
|
isBroker?: boolean
|
|
isDistributor?: boolean
|
|
sites?: SiteRead[]
|
|
categories?: CategoryRead[]
|
|
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
|
|
contacts?: Array<HydraRef | string>
|
|
}
|
|
|
|
/** RIB embarque (groupe client:read:accounting, present ssi accounting.view). */
|
|
export interface RibRead extends HydraRef {
|
|
id: number
|
|
label?: string | null
|
|
bic?: string | null
|
|
iban?: string | null
|
|
}
|
|
|
|
/** Client relie (distributeur / courtier) embarque (groupe client:read). */
|
|
export interface RelatedClientRead extends HydraRef {
|
|
companyName?: string | null
|
|
}
|
|
|
|
/**
|
|
* Detail d'un client tel que renvoye par `GET /api/clients/{id}`. Tous les
|
|
* champs sont optionnels : skip_null_values cote serveur et gating accounting
|
|
* peuvent omettre n'importe quelle cle.
|
|
*/
|
|
export interface ClientDetail extends HydraRef {
|
|
id: number
|
|
companyName?: string | null
|
|
triageService?: boolean
|
|
isArchived?: boolean
|
|
categories?: CategoryRead[]
|
|
distributor?: RelatedClientRead | string | null
|
|
broker?: RelatedClientRead | string | null
|
|
contacts?: ContactRead[]
|
|
addresses?: AddressRead[]
|
|
ribs?: RibRead[]
|
|
// Onglet Information
|
|
description?: string | null
|
|
competitors?: string | null
|
|
foundedAt?: string | null
|
|
employeesCount?: number | null
|
|
revenueAmount?: string | null
|
|
profitAmount?: string | null
|
|
directorName?: string | null
|
|
// Onglet Comptabilite (present ssi accounting.view)
|
|
siren?: string | null
|
|
accountNumber?: string | null
|
|
nTva?: string | null
|
|
tvaMode?: Relation
|
|
paymentDelay?: Relation
|
|
paymentType?: Relation
|
|
bank?: Relation
|
|
}
|
|
|
|
/** Etat « plat » de l'onglet Comptabilite (miroir lecture du formulaire 1.10). */
|
|
export interface AccountingDraft {
|
|
siren: string | null
|
|
accountNumber: string | null
|
|
nTva: string | null
|
|
tvaModeIri: string | null
|
|
paymentDelayIri: string | null
|
|
paymentTypeIri: string | null
|
|
bankIri: string | null
|
|
}
|
|
|
|
/** Relation Distributeur/Courtier resolue pour l'affichage en lecture seule. */
|
|
export interface ClientRelation {
|
|
type: 'distributeur' | 'courtier' | null
|
|
name: string | null
|
|
}
|
|
|
|
/** Option de select ({ value, label }) construite a partir de l'embed. */
|
|
export interface SelectOption {
|
|
value: string
|
|
label: string
|
|
}
|
|
|
|
/** Option de categorie enrichie de son code (compatible CategoryOption des blocs). */
|
|
export interface CategorySelectOption extends SelectOption {
|
|
code: string
|
|
}
|
|
|
|
/**
|
|
* Vue d'une adresse pour la consultation : le brouillon + ses options de select
|
|
* construites a partir de l'embed (sites/categories propres a CETTE adresse).
|
|
*/
|
|
export interface AddressView {
|
|
draft: AddressFormDraft
|
|
siteOptions: SelectOption[]
|
|
categoryOptions: CategorySelectOption[]
|
|
}
|
|
|
|
/** Extrait l'IRI d'une relation (objet embarque, IRI nu, ou null si absente). */
|
|
export function iriOf(relation: Relation): string | null {
|
|
if (relation === null || relation === undefined) {
|
|
return null
|
|
}
|
|
if (typeof relation === 'string') {
|
|
return relation
|
|
}
|
|
return relation['@id'] ?? null
|
|
}
|
|
|
|
/**
|
|
* Resout la relation Distributeur/Courtier (RG-1.03 : mutuellement exclusives).
|
|
* Le nom est lu sur l'objet embarque (`companyName`) ; null si la relation est
|
|
* un IRI nu ou absente.
|
|
*/
|
|
export function relationOf(client: ClientDetail): ClientRelation {
|
|
const nameOf = (rel: RelatedClientRead | string | null | undefined): string | null =>
|
|
rel && typeof rel === 'object' ? (rel.companyName ?? null) : null
|
|
|
|
if (client.distributor) {
|
|
return { type: 'distributeur', name: nameOf(client.distributor) }
|
|
}
|
|
if (client.broker) {
|
|
return { type: 'courtier', name: nameOf(client.broker) }
|
|
}
|
|
return { type: null, name: null }
|
|
}
|
|
|
|
/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */
|
|
export function mapContactToDraft(contact: ContactRead): ContactFormDraft {
|
|
const phoneSecondary = contact.phoneSecondary ?? null
|
|
return {
|
|
id: contact.id,
|
|
iri: contact['@id'] ?? null,
|
|
firstName: contact.firstName ?? null,
|
|
lastName: contact.lastName ?? null,
|
|
jobTitle: contact.jobTitle ?? null,
|
|
phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
|
|
phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
|
|
email: contact.email ?? null,
|
|
hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
|
|
}
|
|
}
|
|
|
|
/** Mappe une adresse embarquee vers un brouillon (IRI extraits des sous-collections). */
|
|
export function mapAddressToDraft(address: AddressRead): AddressFormDraft {
|
|
return {
|
|
id: address.id,
|
|
isProspect: address.isProspect ?? false,
|
|
isDelivery: address.isDelivery ?? false,
|
|
isBilling: address.isBilling ?? false,
|
|
isBroker: address.isBroker ?? false,
|
|
isDistributor: address.isDistributor ?? false,
|
|
country: address.country ?? 'France',
|
|
postalCode: address.postalCode ?? null,
|
|
city: address.city ?? null,
|
|
street: address.street ?? null,
|
|
streetComplement: address.streetComplement ?? null,
|
|
categoryIris: (address.categories ?? []).map(c => c['@id']),
|
|
siteIris: (address.sites ?? []).map(s => s['@id']),
|
|
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
|
|
billingEmail: address.billingEmail ?? null,
|
|
billingEmailSecondary: address.billingEmailSecondary ?? null,
|
|
hasSecondaryBillingEmail: (address.billingEmailSecondary ?? null) !== null && address.billingEmailSecondary !== '',
|
|
}
|
|
}
|
|
|
|
/** Mappe un RIB embarque vers un brouillon. */
|
|
export function mapRibToDraft(rib: RibRead): RibFormDraft {
|
|
return {
|
|
id: rib.id,
|
|
label: rib.label ?? null,
|
|
bic: rib.bic ?? null,
|
|
iban: rib.iban ?? null,
|
|
}
|
|
}
|
|
|
|
/** Mappe les champs comptables du client (scalaires + IRI des referentiels). */
|
|
export function mapAccountingDraft(client: ClientDetail): AccountingDraft {
|
|
return {
|
|
siren: client.siren ?? null,
|
|
accountNumber: client.accountNumber ?? null,
|
|
nTva: client.nTva ?? null,
|
|
tvaModeIri: iriOf(client.tvaMode),
|
|
paymentDelayIri: iriOf(client.paymentDelay),
|
|
paymentTypeIri: iriOf(client.paymentType),
|
|
bankIri: iriOf(client.bank),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Options de categories (value=IRI, label=nom, code) construites depuis l'embed.
|
|
* Source role-independante : evite de dependre de `GET /categories` (403 pour les
|
|
* roles metier non-admin), qui laisserait les libelles vides.
|
|
*/
|
|
export function categoryOptionsOf(categories: CategoryRead[] | undefined): CategorySelectOption[] {
|
|
return (categories ?? []).map(c => ({
|
|
value: c['@id'],
|
|
label: c.name ?? c.code ?? c['@id'],
|
|
code: c.code ?? '',
|
|
}))
|
|
}
|
|
|
|
/** Options de sites (value=IRI, label=nom) construites depuis l'embed d'une adresse. */
|
|
export function siteOptionsOf(sites: SiteRead[] | undefined): SelectOption[] {
|
|
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] }))
|
|
}
|
|
|
|
/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed client. */
|
|
export function contactOptionsOf(contacts: ContactRead[] | undefined): SelectOption[] {
|
|
return (contacts ?? []).map(c => ({
|
|
value: c['@id'],
|
|
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? c['@id']),
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Liste a une seule option (ou vide) construite depuis un referentiel embarque
|
|
* (TvaMode / PaymentDelay / PaymentType / Bank) pour alimenter un MalioSelect en
|
|
* lecture seule. Le libelle vient de l'embed (`label` ou `name`), jamais d'un
|
|
* `GET` de referentiel — l'affichage reste correct quel que soit le role.
|
|
*/
|
|
export function referentialOptionOf(relation: Relation): SelectOption[] {
|
|
if (!relation || typeof relation === 'string') {
|
|
return []
|
|
}
|
|
const label = (relation.label as string | undefined)
|
|
?? (relation.name as string | undefined)
|
|
?? relation['@id']
|
|
return [{ value: relation['@id'], label }]
|
|
}
|
|
|
|
/**
|
|
* Code metier d'un referentiel embarque (ex: PaymentType.code = 'LCR' / 'VIREMENT'),
|
|
* ou null si la relation est absente / serialisee en IRI nu. Type-safe : la branche
|
|
* chaine (IRI nu) et l'absence sont court-circuitees avant l'acces au code. Sert a
|
|
* conditionner l'affichage selon le type de reglement courant (ERP-121 : RIB masques
|
|
* hors-LCR en consultation).
|
|
*/
|
|
export function paymentTypeCodeOf(relation: Relation): string | null {
|
|
if (!relation || typeof relation === 'string') {
|
|
return null
|
|
}
|
|
|
|
return (relation.code as string | undefined) ?? null
|
|
}
|
|
|
|
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
|
|
export function mapAddressView(address: AddressRead): AddressView {
|
|
return {
|
|
draft: mapAddressToDraft(address),
|
|
siteOptions: siteOptionsOf(address.sites),
|
|
categoryOptions: categoryOptionsOf(address.categories),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
|
|
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
|
|
* doit pouvoir ouvrir l'edition pour son onglet Comptabilite). Le readonly fin
|
|
* par onglet est gere sur l'ecran d'edition (1.12).
|
|
*/
|
|
export function canEditClient(canAny: (codes: string[]) => boolean): boolean {
|
|
return canAny(['commercial.clients.manage', 'commercial.clients.accounting.manage'])
|
|
}
|
|
|
|
/** Bouton « Archiver » : permission archive ET client encore actif. */
|
|
export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
|
return can('commercial.clients.archive') && !isArchived
|
|
}
|
|
|
|
/** Bouton « Restaurer » : permission archive ET client deja archive. */
|
|
export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
|
return can('commercial.clients.archive') && isArchived
|
|
}
|