/** * Helpers purs de l'ecran « Modification client » (M1 Commercial, 1.12). * * Deux responsabilites, toutes deux testables unitairement (cf. clientEdit.spec.ts) : * 1. Pre-remplissage : mapper le payload `GET /api/clients/{id}` (embed * contacts/adresses/ribs + scalaires) vers les brouillons « plats » edites * par la page et les blocs reutilisables (mappers contacts/adresses/ribs/ * comptabilite reutilises depuis clientConsultation). * 2. Scoping STRICT des payloads PATCH (mode strict RG-1.28 / ERP-74) : chaque * onglet n'envoie QUE les champs de SON groupe de serialisation, jamais un * payload mixte — un champ hors-permission = 403 sur l'integralite cote back. * * Ces helpers ne touchent ni a l'API ni a l'etat reactif. * * NOTE RG-1.04 (Information obligatoire pour la Commerciale) : volontairement NON * miroitee cote front (cf. clientFormRules.ts) — /api/me n'expose pas le code de * role et Bureau partage les permissions de Commerciale. Le back l'applique de * maniere fiable (422) ; on laisse remonter ce 422 en toast. */ import { iriOf, relationOf, type ClientDetail, } from '~/modules/commercial/utils/clientConsultation' import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm' /** * Etat « plat » du bloc principal (groupe client:write:main). Distinct des * brouillons Contact : ces champs vivent sur le Client lui-meme (companyName, * categories, relation, triage), pas sur une sous-ressource ClientContact. Les * coordonnees de contact (nom, prenom, telephones, email) ne sont plus portees * par le Client : elles vivent exclusivement dans l'onglet Contacts. */ export interface MainFormDraft { companyName: string | null /** IRI des categories rattachees (M2M). */ categoryIris: string[] relationType: 'distributeur' | 'courtier' | null distributorIri: string | null brokerIri: string | null triageService: boolean } /** Etat « plat » de l'onglet Information (groupe client:write:information). */ export interface InformationFormDraft { description: string | null competitors: string | null /** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */ foundedAt: string | null /** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */ employeesCount: string | null revenueAmount: string | null profitAmount: string | null directorName: string | null } /** Etat « plat » de l'onglet Comptabilite (groupe client:write:accounting). */ export interface AccountingFormDraft { siren: string | null accountNumber: string | null nTva: string | null tvaModeIri: string | null paymentDelayIri: string | null paymentTypeIri: string | null bankIri: string | null } /** Permissions de l'utilisateur courant pertinentes pour l'edition d'un client. */ export interface ClientEditAbilities { /** `commercial.clients.manage` : bloc principal + onglets metier. */ canManage: boolean /** `commercial.clients.accounting.view` : visibilite de l'onglet Comptabilite. */ canAccountingView: boolean /** `commercial.clients.accounting.manage` : edition de l'onglet Comptabilite. */ canAccountingManage: boolean } /** Editabilite resolue par zone d'onglet (deduite des permissions). */ export interface TabEditability { /** Bloc principal + onglets Information / Contact / Adresse editables. */ businessEditable: boolean /** Onglet Comptabilite present (affiche). */ accountingVisible: boolean /** Onglet Comptabilite editable. */ accountingEditable: boolean } // ── Pre-remplissage (GET detail -> brouillons) ────────────────────────────── /** * Mappe le detail client vers le brouillon du bloc principal. La relation * Distributeur/Courtier est resolue par exclusivite (RG-1.03) et son IRI extrait * de l'embed. */ export function mapMainDraft(client: ClientDetail): MainFormDraft { const relation = relationOf(client) return { companyName: client.companyName ?? null, categoryIris: (client.categories ?? []).map(c => c['@id']), relationType: relation.type, distributorIri: iriOf(client.distributor), brokerIri: iriOf(client.broker), triageService: client.triageService === true, } } /** Mappe le detail client vers le brouillon de l'onglet Information. */ export function mapInformationDraft(client: ClientDetail): InformationFormDraft { return { description: client.description ?? null, competitors: client.competitors ?? null, // MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime. foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null, employeesCount: client.employeesCount != null ? String(client.employeesCount) : null, revenueAmount: client.revenueAmount ?? null, profitAmount: client.profitAmount ?? null, directorName: client.directorName ?? null, } } /** Mappe les champs comptables du detail vers le brouillon de l'onglet (scalaires + IRI). */ export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraft { 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), } } // ── Scoping strict des payloads PATCH ──────────────────────────────────────── /** * Payload du bloc principal — groupe client:write:main UNIQUEMENT. La relation * Distributeur/Courtier est mutuellement exclusive (RG-1.03) : on ne renseigne * que la FK correspondant au type choisi, l'autre est forcee a null. */ export function buildMainPayload(main: MainFormDraft): Record { return { companyName: main.companyName, categories: main.categoryIris, distributor: main.relationType === 'distributeur' ? main.distributorIri : null, broker: main.relationType === 'courtier' ? main.brokerIri : null, triageService: main.triageService, } } /** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */ export function buildInformationPayload(information: InformationFormDraft): Record { return { description: information.description || null, competitors: information.competitors || null, foundedAt: information.foundedAt || null, employeesCount: information.employeesCount ? Number(information.employeesCount) : null, revenueAmount: information.revenueAmount || null, profitAmount: information.profitAmount || null, directorName: information.directorName || null, } } /** * Payload des scalaires de l'onglet Comptabilite — groupe client:write:accounting * UNIQUEMENT (les RIB passent par la sous-ressource /clients/{id}/ribs). La banque * n'a de sens que pour un Virement (RG-1.12) : forcee a null sinon. */ export function buildAccountingPayload( accounting: AccountingFormDraft, isBankRequired: boolean, ): Record { return { siren: accounting.siren || null, accountNumber: accounting.accountNumber || null, tvaMode: accounting.tvaModeIri, nTva: accounting.nTva || null, paymentDelay: accounting.paymentDelayIri, paymentType: accounting.paymentTypeIri, bank: isBankRequired ? accounting.bankIri : null, } } /** Payload d'un contact (sous-ressource client_contact). */ export function buildContactPayload(contact: ContactFormDraft): Record { return { firstName: contact.firstName || null, lastName: contact.lastName || null, jobTitle: contact.jobTitle || null, phonePrimary: contact.phonePrimary || null, phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null, email: contact.email || null, } } /** Payload d'une adresse (sous-ressource client_address). */ export function buildAddressPayload( address: AddressFormDraft, isBillingEmailRequired: boolean, ): Record { return { isProspect: address.isProspect, isDelivery: address.isDelivery, isBilling: address.isBilling, country: address.country, postalCode: address.postalCode || null, city: address.city || null, street: address.street || null, streetComplement: address.streetComplement || null, categories: address.categoryIris, sites: address.siteIris, contacts: address.contactIris, billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null, } } /** Payload d'un RIB (sous-ressource client_rib). */ export function buildRibPayload(rib: RibFormDraft): Record { return { label: rib.label, bic: rib.bic, iban: rib.iban, } } // ── Gating par permission ──────────────────────────────────────────────────── /** * Resout l'editabilite par zone a partir des permissions (option 1 ERP-74, * miroir UI du re-gating champ-par-champ du ClientProcessor) : * - bloc principal + Information/Contact/Adresse : editables ssi `manage` ; * - Comptabilite : visible ssi `accounting.view`, editable ssi `accounting.manage`. * * Produit le comportement attendu : * - Admin : tout editable. * - Bureau / Commerciale (manage, sans accounting) : metier editable, Compta masquee. * - Compta (accounting seul, sans manage) : metier readonly, Compta editable. */ export function resolveTabEditability(abilities: ClientEditAbilities): TabEditability { return { businessEditable: abilities.canManage, accountingVisible: abilities.canAccountingView, accountingEditable: abilities.canAccountingManage, } }