fix : retours métier ERP-193 (4 répertoires) (#139)
Auto Tag Develop / tag (push) Successful in 11s

Lot de retours métier **ERP-193** (« Fix tous les retours starseed »), transverse aux 4 répertoires (clients, fournisseurs, prestataires, transporteurs).

## Contenu

- **Pagination** : défaut à 25 items/page sur les 4 répertoires.
- **Libellé** : colonne « Dernière activité » → « Dernière modification ».
- **Consultation** : masquage des onglets vides (coquilles « à venir » + onglets de données sans donnée).
- **Chiffre d'affaires** : plafonné à 999 999 999 999,99 (clamp front + `Assert\LessThanOrEqual` back).
- **Date de création** : interdiction des dates futures (`:max` MalioDate + `Assert\LessThanOrEqual('today')` back).
- **Caractères spéciaux** : blocage des caractères parasites (`²³§~#|…`) dans les champs texte via une allow-list par profil (nom de personne / texte libre / adresse / code alphanumérique) — filtrage front à la frappe + `Assert\Regex` back autoritaire. Email/IBAN/BIC/TVA conservent leurs validateurs de format.
- **UI** : champs en consultation et onglets validés grisés (`readonly` → `disabled`).
- **UI** : boutons « Archiver » en rouge (variant `danger`).

## Tests

- Back : nouveaux tests RG (plafond CA, dates futures, caractères spéciaux) + garde-fou contraintes — suite complète verte (813 tests).
- Front : nouveaux tests unitaires (sanitizers, helpers date/montant) — 615 tests verts, eslint clean.

---------

Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #139
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #139.
This commit is contained in:
2026-06-22 09:40:40 +00:00
committed by Autin
parent 6c938756cc
commit 5e15c1f69f
93 changed files with 2791 additions and 803 deletions
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { clampRevenueAmount, REVENUE_AMOUNT_MAX } from '../amountInput'
describe('clampRevenueAmount', () => {
it('laisse les valeurs vides / nulles telles quelles', () => {
expect(clampRevenueAmount(null)).toBeNull()
expect(clampRevenueAmount(undefined)).toBeUndefined()
expect(clampRevenueAmount('')).toBe('')
})
it('laisse une valeur sous le plafond inchangee', () => {
expect(clampRevenueAmount('1000.50')).toBe('1000.50')
expect(clampRevenueAmount('999999999999.99')).toBe('999999999999.99')
})
it('plafonne une valeur au-dessus du maximum', () => {
expect(clampRevenueAmount('1000000000000')).toBe('999999999999.99')
expect(clampRevenueAmount('999999999999999.99')).toBe('999999999999.99')
})
it('tolere une saisie a virgule / avec espaces (securite)', () => {
expect(clampRevenueAmount('1 000 000 000 000,00')).toBe('999999999999.99')
expect(clampRevenueAmount('12,5')).toBe('12,5')
})
it('ne touche pas une saisie non numerique', () => {
expect(clampRevenueAmount('abc')).toBe('abc')
})
it('expose le plafond metier', () => {
expect(REVENUE_AMOUNT_MAX).toBe(999_999_999_999.99)
})
})
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'
import {
canEditClient,
categoryOptionsOf,
clientConsultationVisibleTabs,
contactOptionsOf,
hasAccountingData,
hasInformationData,
iriOf,
mapAccountingDraft,
mapAddressToDraft,
@@ -248,3 +251,73 @@ describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', (
expect(paymentTypeCodeOf(undefined)).toBeNull()
})
})
describe('hasInformationData', () => {
it('faux si tous les champs Information sont vides/absents', () => {
expect(hasInformationData({ '@id': '/api/clients/1', id: 1 })).toBe(false)
expect(hasInformationData({ '@id': '/api/clients/1', id: 1, description: ' ' })).toBe(false)
})
it('vrai des qu\'un champ Information porte une donnee', () => {
expect(hasInformationData({ '@id': '/api/clients/1', id: 1, directorName: 'Dupont' })).toBe(true)
expect(hasInformationData({ '@id': '/api/clients/1', id: 1, employeesCount: 0 })).toBe(true)
})
})
describe('hasAccountingData', () => {
it('faux sans champ comptable ni RIB', () => {
expect(hasAccountingData({ '@id': '/api/clients/1', id: 1 })).toBe(false)
})
it('vrai avec un champ comptable scalaire', () => {
expect(hasAccountingData({ '@id': '/api/clients/1', id: 1, siren: '123456789' })).toBe(true)
})
it('vrai avec une relation comptable embarquee (paymentType)', () => {
expect(hasAccountingData({
'@id': '/api/clients/1', id: 1,
paymentType: { '@id': '/api/payment_types/1', code: 'LCR' },
})).toBe(true)
})
it('vrai avec au moins un RIB', () => {
expect(hasAccountingData({
'@id': '/api/clients/1', id: 1,
ribs: [{ '@id': '/api/ribs/1', id: 1, iban: 'FR76...' }],
})).toBe(true)
})
})
describe('clientConsultationVisibleTabs', () => {
it('retourne [] tant que le client n\'est pas charge', () => {
expect(clientConsultationVisibleTabs(null, { canAccountingView: true })).toEqual([])
expect(clientConsultationVisibleTabs(undefined, { canAccountingView: true })).toEqual([])
})
it('masque les coquilles et les onglets vides (client minimal)', () => {
const client: ClientDetail = { '@id': '/api/clients/1', id: 1, companyName: 'ACME' }
expect(clientConsultationVisibleTabs(client, { canAccountingView: true })).toEqual([])
})
it('affiche les onglets non vides dans l\'ordre information/contact/address/accounting', () => {
const client: ClientDetail = {
'@id': '/api/clients/1', id: 1,
directorName: 'Dupont',
contacts: [{ '@id': '/api/client_contacts/1', id: 1 }],
addresses: [{ '@id': '/api/client_addresses/1', id: 1 }],
siren: '123456789',
}
expect(clientConsultationVisibleTabs(client, { canAccountingView: true }))
.toEqual(['information', 'contact', 'address', 'accounting'])
})
it('masque Comptabilite sans le droit accounting.view meme si des donnees existent', () => {
const client: ClientDetail = {
'@id': '/api/clients/1', id: 1,
contacts: [{ '@id': '/api/client_contacts/1', id: 1 }],
siren: '123456789',
}
expect(clientConsultationVisibleTabs(client, { canAccountingView: false }))
.toEqual(['contact'])
})
})
@@ -3,6 +3,8 @@ import {
canEditSupplier,
categoryOptionsOf,
contactOptionsOf,
hasAccountingData,
hasInformationData,
iriOf,
mapAccountingDraft,
mapAddressToDraft,
@@ -14,6 +16,7 @@ import {
showArchiveAction,
showRestoreAction,
siteOptionsOf,
supplierConsultationVisibleTabs,
type SupplierDetail,
} from '../supplierConsultation'
@@ -237,3 +240,60 @@ describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', (
expect(paymentTypeCodeOf(undefined)).toBeNull()
})
})
describe('hasInformationData (fournisseur)', () => {
it('faux si tous les champs Information (volume previsionnel inclus) sont vides', () => {
expect(hasInformationData({ '@id': '/api/suppliers/1', id: 1 })).toBe(false)
})
it('vrai des qu\'un champ Information porte une donnee', () => {
expect(hasInformationData({ '@id': '/api/suppliers/1', id: 1, directorName: 'Martin' })).toBe(true)
expect(hasInformationData({ '@id': '/api/suppliers/1', id: 1, volumeForecast: 1200 })).toBe(true)
})
})
describe('hasAccountingData (fournisseur)', () => {
it('faux sans champ comptable ni RIB', () => {
expect(hasAccountingData({ '@id': '/api/suppliers/1', id: 1 })).toBe(false)
})
it('vrai avec un champ comptable ou un RIB', () => {
expect(hasAccountingData({ '@id': '/api/suppliers/1', id: 1, siren: '123456789' })).toBe(true)
expect(hasAccountingData({
'@id': '/api/suppliers/1', id: 1,
ribs: [{ '@id': '/api/supplier_ribs/1', id: 1, iban: 'FR76...' }],
})).toBe(true)
})
})
describe('supplierConsultationVisibleTabs', () => {
it('retourne [] tant que le fournisseur n\'est pas charge', () => {
expect(supplierConsultationVisibleTabs(null, { canAccountingView: true })).toEqual([])
})
it('masque les coquilles et les onglets vides (fournisseur minimal)', () => {
expect(supplierConsultationVisibleTabs(
{ '@id': '/api/suppliers/1', id: 1, companyName: 'ACME' },
{ canAccountingView: true },
)).toEqual([])
})
it('affiche information/contacts/addresses/accounting (cles plurielles) dans l\'ordre', () => {
const supplier: SupplierDetail = {
'@id': '/api/suppliers/1', id: 1,
volumeForecast: 1000,
contacts: [{ '@id': '/api/supplier_contacts/1', id: 1 }],
addresses: [{ '@id': '/api/supplier_addresses/1', id: 1 }],
siren: '123456789',
}
expect(supplierConsultationVisibleTabs(supplier, { canAccountingView: true }))
.toEqual(['information', 'contacts', 'addresses', 'accounting'])
})
it('masque Comptabilite sans le droit accounting.view', () => {
expect(supplierConsultationVisibleTabs(
{ '@id': '/api/suppliers/1', id: 1, siren: '123456789' },
{ canAccountingView: false },
)).toEqual([])
})
})
@@ -0,0 +1,29 @@
/**
* Helpers de saisie des montants des formulaires Client / Fournisseur (commercial).
* Purs / testables. Pendant FRONT de la contrainte back `LessThanOrEqual` posee sur
* `revenueAmount` (Client/Supplier) — retour metier ERP-193 : le chiffre d'affaires
* est plafonne a 999 999 999 999,99.
*/
/** Plafond metier du chiffre d'affaires (CA) : 999 999 999 999,99. */
export const REVENUE_AMOUNT_MAX = 999_999_999_999.99
/** Valeur « modele » (decimale a point, sans separateur) renvoyee quand on plafonne. */
const REVENUE_AMOUNT_MAX_MODEL = '999999999999.99'
/**
* Plafonne le CA au maximum metier. Recoit le modele emis par `MalioInputAmount`
* (chaine propre a decimale `.`, sans espaces) ; tolere malgre tout une virgule /
* des espaces par securite. Renvoie la valeur telle quelle si elle est vide, non
* numerique ou sous le plafond ; sinon la valeur plafonnee.
*/
export function clampRevenueAmount(value: string | null | undefined): string | null | undefined {
if (value === null || value === undefined || value === '') {
return value
}
const n = Number(String(value).replace(/\s/g, '').replace(',', '.'))
if (Number.isNaN(n)) {
return value
}
return n > REVENUE_AMOUNT_MAX ? REVENUE_AMOUNT_MAX_MODEL : value
}
@@ -317,6 +317,77 @@ export function mapAddressView(address: AddressRead): AddressView {
}
}
/**
* Vrai si une valeur scalaire porte une donnee affichable (non null/undefined,
* et non chaine vide apres trim). Sert aux predicats « onglet vide » ci-dessous.
*/
function hasValue(value: unknown): boolean {
if (value === null || value === undefined) {
return false
}
if (typeof value === 'string') {
return value.trim() !== ''
}
return true
}
/**
* Vrai si l'onglet Information porte au moins une donnee. ERP-193 : en
* consultation on masque les onglets vides ; Information n'echappe pas a la
* regle malgre son statut d'onglet d'atterrissage par defaut.
*/
export function hasInformationData(client: ClientDetail): boolean {
return [
client.description,
client.competitors,
client.foundedAt,
client.employeesCount,
client.revenueAmount,
client.profitAmount,
client.directorName,
].some(hasValue)
}
/**
* Vrai si l'onglet Comptabilite porte au moins un champ comptable OU un RIB.
* (Le gating permission `accounting.view` reste applique en amont par l'appelant.)
*/
export function hasAccountingData(client: ClientDetail): boolean {
const draft = mapAccountingDraft(client)
const hasField = Object.values(draft).some(hasValue)
const hasRib = (client.ribs ?? []).length > 0
return hasField || hasRib
}
/**
* Onglets visibles en consultation (ERP-193, retour metier) : on masque les
* coquilles non implementees (Transport / Statistiques / Rapports / Echanges)
* ET tout onglet de donnees vide. L'ordre reproduit `buildClientFormTabKeys`.
* Retourne `[]` tant que le client n'est pas charge.
*/
export function clientConsultationVisibleTabs(
client: ClientDetail | null | undefined,
options: { canAccountingView: boolean },
): string[] {
if (!client) {
return []
}
const visible: string[] = []
if (hasInformationData(client)) {
visible.push('information')
}
if ((client.contacts ?? []).length > 0) {
visible.push('contact')
}
if ((client.addresses ?? []).length > 0) {
visible.push('address')
}
if (options.canAccountingView && hasAccountingData(client)) {
visible.push('accounting')
}
return visible
}
/**
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
@@ -292,6 +292,78 @@ export function mapAddressView(address: AddressRead): AddressView {
}
}
/**
* Vrai si une valeur scalaire porte une donnee affichable (non null/undefined,
* et non chaine vide apres trim). Sert aux predicats « onglet vide » ci-dessous.
*/
function hasValue(value: unknown): boolean {
if (value === null || value === undefined) {
return false
}
if (typeof value === 'string') {
return value.trim() !== ''
}
return true
}
/**
* Vrai si l'onglet Information porte au moins une donnee (volume previsionnel
* inclus, specifique fournisseur). ERP-193 : en consultation on masque les
* onglets vides, Information comprise.
*/
export function hasInformationData(supplier: SupplierDetail): boolean {
return [
supplier.description,
supplier.competitors,
supplier.foundedAt,
supplier.employeesCount,
supplier.revenueAmount,
supplier.profitAmount,
supplier.directorName,
supplier.volumeForecast,
].some(hasValue)
}
/**
* Vrai si l'onglet Comptabilite porte au moins un champ comptable OU un RIB.
* (Le gating permission `accounting.view` reste applique en amont par l'appelant.)
*/
export function hasAccountingData(supplier: SupplierDetail): boolean {
const draft = mapAccountingDraft(supplier)
const hasField = Object.values(draft).some(hasValue)
const hasRib = (supplier.ribs ?? []).length > 0
return hasField || hasRib
}
/**
* Onglets visibles en consultation (ERP-193, retour metier) : on masque les
* coquilles non implementees (Transport / Statistiques / Rapports / Echanges)
* ET tout onglet de donnees vide. L'ordre reproduit `buildSupplierFormTabKeys`.
* Retourne `[]` tant que le fournisseur n'est pas charge.
*/
export function supplierConsultationVisibleTabs(
supplier: SupplierDetail | null | undefined,
options: { canAccountingView: boolean },
): string[] {
if (!supplier) {
return []
}
const visible: string[] = []
if (hasInformationData(supplier)) {
visible.push('information')
}
if ((supplier.contacts ?? []).length > 0) {
visible.push('contacts')
}
if ((supplier.addresses ?? []).length > 0) {
visible.push('addresses')
}
if (options.canAccountingView && hasAccountingData(supplier)) {
visible.push('accounting')
}
return visible
}
/**
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta