/** * Helpers purs de l'ecran « Consultation fournisseur » (M2 Commercial, lecture * seule). Miroir de `clientConsultation.ts` (M1), adapte aux differences M2. * * Mappent le payload `GET /api/suppliers/{id}` (relations embarquees, cf. groupe * `supplier:item:read` + `supplier:read:accounting`) vers les brouillons « plats » * partages avec les blocs reutilisables `SupplierContactBlock` / `SupplierAddressBlock` * et l'onglet Comptabilite. Ne touchent ni a l'API ni a l'etat reactif : testables * unitairement (cf. supplierConsultation.spec.ts). * * Rappels de contrat back (verifies sur le JSON reel fige — ERP-92, spec-back § 4.0.bis) : * - les relations ManyToOne (tvaMode/paymentDelay/paymentType/bank) sont * serialisees en OBJETS embarques (`{id, 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 (cle omise, pas `null`) * sans permission accounting.view (gate serveur via SupplierReadGroupContextBuilder). * * Differences M2 vs M1 : * - Adresse via enum `addressType` (PROSPECT/DEPART/RENDU, RG-2.09) — pas de * drapeaux isProspect/isDelivery/isBilling. * - Adresse : champs specifiques fournisseur `bennes` (nombre) et `triageProvider`. * Pas d'email de facturation. * - Information : champ specifique fournisseur `volumeForecast`. * - Pas de relation Distributeur/Courtier ni de triage sur le bloc principal. */ import { formatPhoneFR } from '~/shared/utils/phone' import { emptyAddress, type SupplierAddressFormDraft, type SupplierAddressType, type SupplierContactFormDraft, type SupplierRibFormDraft, } from '~/modules/commercial/types/supplierForm' /** 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 supplier_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 supplier_address:read). */ export interface AddressRead extends HydraRef { id: number addressType?: SupplierAddressType | null country?: string | null postalCode?: string | null city?: string | null street?: string | null streetComplement?: string | null bennes?: number | null triageProvider?: boolean sites?: SiteRead[] categories?: CategoryRead[] // L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu. contacts?: Array } /** RIB embarque (groupe supplier:read:accounting, present ssi accounting.view). */ export interface RibRead extends HydraRef { id: number label?: string | null bic?: string | null iban?: string | null } /** * Detail d'un fournisseur tel que renvoye par `GET /api/suppliers/{id}`. Tous les * champs sont optionnels : skip_null_values cote serveur et gating accounting * peuvent omettre n'importe quelle cle. */ export interface SupplierDetail extends HydraRef { id: number companyName?: string | null isArchived?: boolean categories?: CategoryRead[] 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 /** Volume previsionnel (entier, specifique fournisseur). */ volumeForecast?: number | 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). */ export interface AccountingDraft { siren: string | null accountNumber: string | null nTva: string | null tvaModeIri: string | null paymentDelayIri: string | null paymentTypeIri: string | null bankIri: 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: SupplierAddressFormDraft 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 } /** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */ export function mapContactToDraft(contact: ContactRead): SupplierContactFormDraft { 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). * `bennes` (entier) est converti en chaine pour MalioInputNumber (defaut « 0 »). */ export function mapAddressToDraft(address: AddressRead): SupplierAddressFormDraft { return { id: address.id, addressType: address.addressType ?? null, 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'])), bennes: address.bennes != null ? String(address.bennes) : '0', triageProvider: address.triageProvider ?? false, } } /** Mappe un RIB embarque vers un brouillon. */ export function mapRibToDraft(rib: RibRead): SupplierRibFormDraft { return { id: rib.id, label: rib.label ?? null, bic: rib.bic ?? null, iban: rib.iban ?? null, } } /** Mappe les champs comptables du fournisseur (scalaires + IRI des referentiels). */ export function mapAccountingDraft(supplier: SupplierDetail): AccountingDraft { return { siren: supplier.siren ?? null, accountNumber: supplier.accountNumber ?? null, nTva: supplier.nTva ?? null, tvaModeIri: iriOf(supplier.tvaMode), paymentDelayIri: iriOf(supplier.paymentDelay), paymentTypeIri: iriOf(supplier.paymentType), bankIri: iriOf(supplier.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 fournisseur. */ 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 }] } /** 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 (96). */ export function canEditSupplier(canAny: (codes: string[]) => boolean): boolean { return canAny(['commercial.suppliers.manage', 'commercial.suppliers.accounting.manage']) } /** Bouton « Archiver » : permission archive ET fournisseur encore actif. */ export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean { return can('commercial.suppliers.archive') && !isArchived } /** Bouton « Restaurer » : permission archive ET fournisseur deja archive. */ export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean { return can('commercial.suppliers.archive') && isArchived } /** Brouillon d'adresse vierge (reexport pour la page : 1 bloc vide si aucune adresse). */ export { emptyAddress }