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
@@ -12,21 +12,15 @@ import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
*/
describe('providerAddress helpers', () => {
const SITE = '/api/sites/1'
const CAT = '/api/categories/7'
describe('isProviderAddressValid (RG-3.05 / RG-3.09)', () => {
describe('isProviderAddressValid (RG-3.05)', () => {
it('false sans site', () => {
const address = { ...emptyProviderAddress(), categoryIris: [CAT] }
const address = { ...emptyProviderAddress() }
expect(isProviderAddressValid(address)).toBe(false)
})
it('false sans categorie', () => {
it('true avec au moins un site', () => {
const address = { ...emptyProviderAddress(), siteIris: [SITE] }
expect(isProviderAddressValid(address)).toBe(false)
})
it('true avec au moins un site ET une categorie', () => {
const address = { ...emptyProviderAddress(), siteIris: [SITE], categoryIris: [CAT] }
expect(isProviderAddressValid(address)).toBe(true)
})
})
@@ -39,7 +33,6 @@ describe('providerAddress helpers', () => {
city: 'Châtellerault',
street: '1 rue du Test',
siteIris: [SITE],
categoryIris: [CAT],
contactIris: ['/api/provider_contacts/9'],
})
expect(payload).toEqual({
@@ -48,7 +41,6 @@ describe('providerAddress helpers', () => {
city: 'Châtellerault',
street: '1 rue du Test',
streetComplement: null,
categories: [CAT],
sites: [SITE],
contacts: ['/api/provider_contacts/9'],
})
@@ -61,7 +53,6 @@ describe('providerAddress helpers', () => {
const payload = buildProviderAddressPayload({
...emptyProviderAddress(),
siteIris: [SITE],
categoryIris: [CAT],
})
expect(payload).not.toHaveProperty('postalCode')
expect(payload).not.toHaveProperty('city')
@@ -10,6 +10,7 @@ const {
canEditProvider,
categoryOptionsOf,
contactOptionsOf,
hasAccountingData,
iriOf,
irisOf,
mapAccountingDraft,
@@ -17,6 +18,7 @@ const {
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
providerConsultationVisibleTabs,
referentialOptionOf,
showArchiveAction,
showRestoreAction,
@@ -74,7 +76,7 @@ describe('providerDetail helpers', () => {
})
describe('mapAddressToDraft', () => {
it('extrait les IRI des sites / categories / contacts embarques', () => {
it('extrait les IRI des sites / contacts embarques', () => {
const draft = mapAddressToDraft({
'@id': '/api/provider_addresses/3',
id: 3,
@@ -83,11 +85,9 @@ describe('providerDetail helpers', () => {
city: 'Châtellerault',
street: '1 rue du Test',
sites: [{ '@id': '/api/sites/1' }],
categories: [{ '@id': '/api/categories/7' }],
contacts: [{ '@id': '/api/provider_contacts/5' }, '/api/provider_contacts/6'],
})
expect(draft.siteIris).toEqual(['/api/sites/1'])
expect(draft.categoryIris).toEqual(['/api/categories/7'])
expect(draft.contactIris).toEqual(['/api/provider_contacts/5', '/api/provider_contacts/6'])
expect(draft.id).toBe(3)
})
@@ -165,3 +165,48 @@ describe('providerDetail helpers', () => {
})
})
})
describe('hasAccountingData (prestataire)', () => {
it('faux sans champ comptable ni RIB', () => {
expect(hasAccountingData({ '@id': '/api/providers/1', id: 1 })).toBe(false)
})
it('vrai avec un champ comptable ou un RIB', () => {
expect(hasAccountingData({ '@id': '/api/providers/1', id: 1, siren: '123456789' })).toBe(true)
expect(hasAccountingData({
'@id': '/api/providers/1', id: 1,
ribs: [{ '@id': '/api/provider_ribs/1', id: 1, iban: 'FR76...' }],
})).toBe(true)
})
})
describe('providerConsultationVisibleTabs', () => {
it('retourne [] tant que le prestataire n\'est pas charge', () => {
expect(providerConsultationVisibleTabs(null, { canAccountingView: true })).toEqual([])
})
it('masque les coquilles (reports/exchanges) et les onglets vides', () => {
expect(providerConsultationVisibleTabs(
{ '@id': '/api/providers/1', id: 1, companyName: 'ACME' },
{ canAccountingView: true },
)).toEqual([])
})
it('affiche contacts/address/accounting dans l\'ordre (pas d\'onglet information)', () => {
const provider = {
'@id': '/api/providers/1', id: 1,
contacts: [{ '@id': '/api/provider_contacts/1', id: 1 }],
addresses: [{ '@id': '/api/provider_addresses/1', id: 1 }],
siren: '123456789',
}
expect(providerConsultationVisibleTabs(provider, { canAccountingView: true }))
.toEqual(['contacts', 'address', 'accounting'])
})
it('masque Comptabilite sans le droit accounting.view', () => {
expect(providerConsultationVisibleTabs(
{ '@id': '/api/providers/1', id: 1, siren: '123456789' },
{ canAccountingView: false },
)).toEqual([])
})
})
@@ -14,12 +14,12 @@ import type { ProviderAddressFormDraft } from '~/modules/technique/types/provide
const REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const
/**
* RG-3.05 (+ RG-3.09) : une adresse est « valide » pour autoriser l'ajout d'un
* nouveau bloc des qu'elle porte au moins un site ET au moins une categorie. Les
* scalaires (CP/ville/rue) restent valides par le back (422 inline).
* RG-3.05 : une adresse est « valide » pour autoriser l'ajout d'un nouveau bloc
* des qu'elle porte au moins un site. Les scalaires (CP/ville/rue) restent valides
* par le back (422 inline).
*/
export function isProviderAddressValid(address: ProviderAddressFormDraft): boolean {
return address.siteIris.length >= 1 && address.categoryIris.length >= 1
return address.siteIris.length >= 1
}
/**
@@ -34,7 +34,6 @@ export function buildProviderAddressPayload(address: ProviderAddressFormDraft):
city: address.city || null,
street: address.street || null,
streetComplement: address.streetComplement || null,
categories: [...address.categoryIris],
sites: [...address.siteIris],
contacts: [...address.contactIris],
}
@@ -68,7 +68,6 @@ export interface AddressRead extends HydraRef {
street?: string | null
streetComplement?: string | null
sites?: SiteRead[]
categories?: CategoryRead[]
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
contacts?: Array<HydraRef | string>
}
@@ -146,7 +145,6 @@ export function mapAddressToDraft(address: AddressRead): ProviderAddressFormDraf
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'])),
}
@@ -224,6 +222,58 @@ export function paymentTypeCodeOf(relation: Relation): string | null {
return (relation.code as string | undefined) ?? null
}
/**
* Vrai si une valeur scalaire porte une donnee affichable (non null/undefined,
* et non chaine vide apres trim). Sert au predicat « 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 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(provider: ProviderDetail): boolean {
const draft = mapAccountingDraft(provider)
const hasField = Object.values(draft).some(hasValue)
const hasRib = (provider.ribs ?? []).length > 0
return hasField || hasRib
}
/**
* Onglets visibles en consultation (ERP-193, retour metier) : on masque les
* coquilles non implementees (Rapports / Echanges) ET tout onglet de donnees
* vide. Le prestataire n'a pas d'onglet Information (bloc principal au-dessus
* des onglets). Ordre : Contacts · Adresse · Comptabilite. Retourne `[]` tant
* que le prestataire n'est pas charge.
*/
export function providerConsultationVisibleTabs(
provider: ProviderDetail | null | undefined,
options: { canAccountingView: boolean },
): string[] {
if (!provider) {
return []
}
const visible: string[] = []
if ((provider.contacts ?? []).length > 0) {
visible.push('contacts')
}
if ((provider.addresses ?? []).length > 0) {
visible.push('address')
}
if (options.canAccountingView && hasAccountingData(provider)) {
visible.push('accounting')
}
return visible
}
/**
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet —
* `manage` (onglets metier) OU `accounting.manage` (le role Compta doit pouvoir