fix : retours métier ERP-193 (4 répertoires) (#139)
Auto Tag Develop / tag (push) Successful in 11s
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user