a340d8139a
Auto Tag Develop / tag (push) Successful in 8s
## Contexte ERP-148 — mise à jour @malio/layer-ui et amélioration des champs date (onglet Information, Client & Fournisseur). ## Changements - **MalioDate v1.7.10** : le composant expose désormais son état de validité (`@update:valid`) et la saisie brute invalide (`@update:rawValue`). - **Validation back-autoritaire du format** : `foundedAt` n'accepte plus que l'ISO strict `Y-m-d` (`#[Context]` DateTimeNormalizer) + `collectDenormalizationErrors` sur `Client` et `Supplier`. Toute saisie non-ISO renvoie un **422 porté sur le champ**. - Corrige un cas piège : `12/25/2026` (invalide en JJ/MM/AAAA côté front) était auparavant accepté par PHP en M/J/AAAA → 25 décembre. Désormais rejeté. - **Front** : la saisie invalide est transmise au back ; le message technique de type-error est surchargé par une clé i18n via le **code de violation** (`resolveViolationMessage` / `VIOLATION_MESSAGE_I18N`), affiché inline par `useFormErrors`. - Réorganisation des utils de formulaire sous `utils/forms/`. ## Tests - Back : `ClientFoundedAtFormatTest` / `SupplierFoundedAtFormatTest` (dont le cas piège `12/25/2026`). - Front : résolveur i18n (`api.test.ts`, `useFormErrors.test.ts`) + payloads (`clientEdit`/`supplierEdit` specs). - Suite Commercial verte ; vérifié bout-en-bout en navigateur (PATCH → 422, erreur inline, submit bloqué). ## Note Échecs JWT aléatoires connus du hook pre-commit (401/500 sur tests d'auth sans rapport) ; tous verts en isolation. Reviewed-on: #92 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
/**
|
|
* Helpers purs des ecrans « Ajouter » / « Modifier » un fournisseur (M2
|
|
* Commercial) — miroir de `clientEdit.ts` (M1). Deux responsabilites, toutes deux
|
|
* testables unitairement (cf. supplierEdit.spec.ts) :
|
|
* 1. Pre-remplissage : mapper le payload `GET /api/suppliers/{id}` (embed +
|
|
* scalaires) vers les brouillons « plats » edites par la page de modification.
|
|
* 2. Scoping STRICT des payloads PATCH (mode strict RG-2.16 / 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.
|
|
*/
|
|
|
|
import {
|
|
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
|
|
blankEmptyRequired,
|
|
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
|
omitEmptyRequired,
|
|
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
|
} from '~/modules/commercial/utils/forms/supplierFormRules'
|
|
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
|
|
import type {
|
|
SupplierAddressFormDraft,
|
|
SupplierContactFormDraft,
|
|
SupplierRibFormDraft,
|
|
} from '~/modules/commercial/types/supplierForm'
|
|
|
|
/** Etat « plat » du bloc principal (groupe supplier:write:main). */
|
|
export interface MainFormDraft {
|
|
companyName: string | null
|
|
/** IRI des categories rattachees (M2M, type FOURNISSEUR). */
|
|
categoryIris: string[]
|
|
}
|
|
|
|
/** Etat « plat » de l'onglet Information (groupe supplier: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
|
|
/**
|
|
* Saisie brute invalide remontee par MalioDate (`@update:rawValue`) : '' tant
|
|
* que la saisie est valide/vide, sinon le texte tel que tape. On l'envoie au
|
|
* back en priorite sur `foundedAt` pour que la 422 (validation autoritaire du
|
|
* format, ERP-101) porte sur le champ et s'affiche inline. Cf. MUI-44.
|
|
*/
|
|
foundedAtRaw: string
|
|
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
|
|
employeesCount: string | null
|
|
revenueAmount: string | null
|
|
profitAmount: string | null
|
|
directorName: string | null
|
|
/** Volume previsionnel (entier >= 0, specifique fournisseur) en chaine. */
|
|
volumeForecast: string | null
|
|
}
|
|
|
|
/** Etat « plat » de l'onglet Comptabilite (groupe supplier: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 fournisseur. */
|
|
export interface SupplierEditAbilities {
|
|
/** `commercial.suppliers.manage` : bloc principal + onglets metier. */
|
|
canManage: boolean
|
|
/** `commercial.suppliers.accounting.view` : visibilite de l'onglet Comptabilite. */
|
|
canAccountingView: boolean
|
|
/** `commercial.suppliers.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 / Contacts / Adresses editables. */
|
|
businessEditable: boolean
|
|
/** Onglet Comptabilite present (affiche). */
|
|
accountingVisible: boolean
|
|
/** Onglet Comptabilite editable. */
|
|
accountingEditable: boolean
|
|
}
|
|
|
|
// ── Pre-remplissage (GET detail -> brouillons) ──────────────────────────────
|
|
|
|
/** Mappe le detail fournisseur vers le brouillon du bloc principal. */
|
|
export function mapMainDraft(supplier: SupplierDetail): MainFormDraft {
|
|
return {
|
|
companyName: supplier.companyName ?? null,
|
|
categoryIris: (supplier.categories ?? []).map(c => c['@id']),
|
|
}
|
|
}
|
|
|
|
/** Mappe le detail fournisseur vers le brouillon de l'onglet Information. */
|
|
export function mapInformationDraft(supplier: SupplierDetail): InformationFormDraft {
|
|
return {
|
|
description: supplier.description ?? null,
|
|
competitors: supplier.competitors ?? null,
|
|
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
|
foundedAt: supplier.foundedAt ? supplier.foundedAt.slice(0, 10) : null,
|
|
// Aucune saisie brute invalide au chargement (la valeur stockee est valide).
|
|
foundedAtRaw: '',
|
|
employeesCount: supplier.employeesCount != null ? String(supplier.employeesCount) : null,
|
|
revenueAmount: supplier.revenueAmount ?? null,
|
|
profitAmount: supplier.profitAmount ?? null,
|
|
directorName: supplier.directorName ?? null,
|
|
// Volume previsionnel (entier, specifique fournisseur) en chaine pour la saisie.
|
|
volumeForecast: supplier.volumeForecast != null ? String(supplier.volumeForecast) : null,
|
|
}
|
|
}
|
|
|
|
/** Mappe les champs comptables du detail vers le brouillon de l'onglet (scalaires + IRI). */
|
|
export function mapAccountingFormDraft(supplier: SupplierDetail): AccountingFormDraft {
|
|
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),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resout l'editabilite par zone a partir des permissions (option 1 ERP-74,
|
|
* miroir UI du re-gating champ-par-champ du SupplierProcessor) :
|
|
* - bloc principal + Information/Contacts/Adresses : 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: SupplierEditAbilities): TabEditability {
|
|
return {
|
|
businessEditable: abilities.canManage,
|
|
accountingVisible: abilities.canAccountingView,
|
|
accountingEditable: abilities.canAccountingManage,
|
|
}
|
|
}
|
|
|
|
// ── Scoping strict des payloads PATCH/POST ──────────────────────────────────
|
|
|
|
/**
|
|
* Options de construction d'un payload d'ecriture.
|
|
* - `forUpdate: false` (defaut, CREATION/POST) : les champs requis vides sont OMIS
|
|
* -> 422 NotBlank a l'insert (le back ne reçoit pas la cle).
|
|
* - `forUpdate: true` (EDITION/PATCH d'une ligne existante) : les champs requis
|
|
* vides sont envoyes en `''` -> 422 NotBlank (sinon une cle omise laisse la valeur
|
|
* serveur inchangee, faux 200 — cf. blankEmptyRequired).
|
|
*/
|
|
export interface BuildPayloadOptions {
|
|
forUpdate?: boolean
|
|
}
|
|
|
|
/** Selectionne le finaliseur des champs requis selon création (omit) vs édition (blank). */
|
|
function finalizeRequired<T extends Record<string, unknown>>(
|
|
payload: T,
|
|
requiredKeys: readonly string[],
|
|
options: BuildPayloadOptions,
|
|
): T {
|
|
return options.forUpdate
|
|
? blankEmptyRequired(payload, requiredKeys)
|
|
: omitEmptyRequired(payload, requiredKeys)
|
|
}
|
|
|
|
/**
|
|
* Payload du bloc principal — groupe supplier:write:main UNIQUEMENT.
|
|
* companyName vide -> 422 NotBlank (omis a la creation, `''` en edition — ERP-119).
|
|
*/
|
|
export function buildMainPayload(main: MainFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> {
|
|
return finalizeRequired({
|
|
companyName: main.companyName,
|
|
categories: main.categoryIris,
|
|
}, MAIN_REQUIRED_NON_NULLABLE_KEYS, options)
|
|
}
|
|
|
|
/** Payload de l'onglet Information — groupe supplier:write:information UNIQUEMENT. */
|
|
export function buildInformationPayload(information: InformationFormDraft): Record<string, unknown> {
|
|
return {
|
|
description: information.description || null,
|
|
competitors: information.competitors || null,
|
|
// Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle
|
|
// pour que le back renvoie une 422 sur foundedAt (cf. foundedAtRaw).
|
|
foundedAt: information.foundedAtRaw || information.foundedAt || null,
|
|
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
|
revenueAmount: information.revenueAmount || null,
|
|
profitAmount: information.profitAmount || null,
|
|
directorName: information.directorName || null,
|
|
volumeForecast: information.volumeForecast ? Number(information.volumeForecast) : null,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Payload des scalaires de l'onglet Comptabilite — groupe supplier:write:accounting
|
|
* UNIQUEMENT (les RIB passent par la sous-ressource /suppliers/{id}/ribs). La
|
|
* banque n'a de sens que pour un Virement (RG-2.07) : forcee a null sinon.
|
|
*/
|
|
export function buildAccountingPayload(
|
|
accounting: AccountingFormDraft,
|
|
isBankRequired: boolean,
|
|
): Record<string, unknown> {
|
|
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 supplier_contact). */
|
|
export function buildContactPayload(contact: SupplierContactFormDraft): Record<string, unknown> {
|
|
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 supplier_address). postalCode / city /
|
|
* street omis si vides -> 422 NotBlank (ERP-119). Specifique fournisseur :
|
|
* `bennes` (entier, 0 par defaut) + `triageProvider` (booleen). Pas d'email de
|
|
* facturation (difference M1).
|
|
*/
|
|
export function buildAddressPayload(address: SupplierAddressFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> {
|
|
return finalizeRequired({
|
|
addressType: address.addressType,
|
|
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,
|
|
bennes: address.bennes !== null && address.bennes !== '' ? Number(address.bennes) : null,
|
|
triageProvider: address.triageProvider,
|
|
}, ADDRESS_REQUIRED_NON_NULLABLE_KEYS, options)
|
|
}
|
|
|
|
/** Payload d'un RIB (sous-ressource supplier_rib). */
|
|
export function buildRibPayload(rib: SupplierRibFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> {
|
|
return finalizeRequired({
|
|
label: rib.label,
|
|
bic: rib.bic,
|
|
iban: rib.iban,
|
|
}, RIB_REQUIRED_NON_NULLABLE_KEYS, options)
|
|
}
|